Skip to content

Commit

Permalink
Add Instruments. (#227)
Browse files Browse the repository at this point in the history
* Add Instruments models
* Update models/base.py
  * Remove len from BaseModel
  * Add __first__ BaseSchema class attribute to help filter before
  object load
  * Add BasePaginator and BasePaginatorSchema
* models/sessionmanager.py
  * Update timeout in SessionManager
  * Add many keyword to schema load
  * Add guard for common schema error (class instead of object)
* Add PyrhValueError, InvalidOperation, and remove InvalidInstrumentId
* Integrate instruments with robinhood.py
* Remove xdoctest: not quite useful since a lot of doc tests cannot be
  run.
  * Update poetry.lock
  • Loading branch information
adithyabsk committed Apr 18, 2020
1 parent c56507f commit 0113d31
Show file tree
Hide file tree
Showing 14 changed files with 368 additions and 144 deletions.
1 change: 1 addition & 0 deletions newsfragments/227.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add Instruments model to the project.
30 changes: 5 additions & 25 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ pytest = "^5.4.1"
pytest-cov = "^2.8.1"
pytest-mock = "^2.0.0"
requests-mock = "^1.7.0"
xdoctest = "^0.11.0"

# Automation
towncrier = "^19.2.0"
Expand Down
16 changes: 10 additions & 6 deletions pyrh/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,30 @@ class PyrhException(Exception):
pass


class PyrhValueError(ValueError, PyrhException):
"""Value Error for the pyrh library."""


class InvalidCacheFile(PyrhException):
"""Error when the cache config file is found to be invalid."""

pass


class AuthenticationError(PyrhException):
"""Error when trying to login to robinhood."""
class InvalidOperation(PyrhException):
"""An invalid operation was requsted to be performed."""

pass


class InvalidTickerSymbol(PyrhException):
"""When an invalid ticker (stock symbol) is given/"""
class AuthenticationError(PyrhException):
"""Error when trying to login to robinhood."""

pass


class InvalidInstrumentId(PyrhException):
"""When an invalid instrument id is given/"""
class InvalidTickerSymbol(PyrhException):
"""When an invalid ticker (stock symbol) is given/"""

pass

Expand Down
14 changes: 13 additions & 1 deletion pyrh/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
"""pyrh models and schemas."""

from .instrument import (
Instrument,
InstrumentManager,
InstrumentPaginator,
InstrumentPaginatorSchema,
InstrumentSchema,
)
from .oauth import Challenge, ChallengeSchema, OAuth, OAuthSchema
from .portfolio import PortfolioSchema
from .portfolio import Portfolio, PortfolioSchema
from .sessionmanager import SessionManager, SessionManagerSchema


Expand All @@ -14,4 +21,9 @@
"SessionManagerSchema",
"Portfolio",
"PortfolioSchema",
"Instrument",
"InstrumentSchema",
"InstrumentManager",
"InstrumentPaginator",
"InstrumentPaginatorSchema",
]
114 changes: 101 additions & 13 deletions pyrh/models/base.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""Base Model."""

from collections import MutableSequence
from types import SimpleNamespace
from typing import Any, Dict, Mapping
from typing import Any, Callable, Dict, Iterable, Mapping, Optional

from marshmallow import INCLUDE, Schema, fields, post_load
from yarl import URL

from marshmallow import INCLUDE, Schema, post_load
from pyrh.exceptions import InvalidOperation


JSON = Dict[str, Any]
Expand Down Expand Up @@ -58,15 +61,6 @@ def __repr__(self) -> str:
else:
return repr_

def __len__(self) -> int:
"""Return the length of the model.
Returns:
The number of attributes a given model has.
"""
return len(self.__dict__)


class UnknownModel(BaseModel):
"""A convenience class that inherits from `BaseModel`."""
Expand All @@ -78,7 +72,9 @@ class BaseSchema(Schema):
"""The default schema for all models."""

__model__: Any = UnknownModel
"""Determines the object that is created when the load method is called."""
"""Determine the object that is created when the load method is called."""
__first__: Optional[str] = None
"""Determine if `make_object` will try to get the first element the input key."""

class Meta:
unknown = INCLUDE
Expand All @@ -95,4 +91,96 @@ def make_object(self, data: JSON, **kwargs: Any) -> "__model__":
An instance of the `__model__` class.
"""
if self.__first__ is not None:
data_list = data.get("results", [{}])
# guard against empty return list of a valid results return
data = data_list[0] if len(data_list) != 0 else {}
return self.__model__(**data)


def has_results(func: Callable[..., Any]) -> Callable[..., Any]:
"""Check whether a particular function has results when filtering its data.
Args:
func: The function to be decorated
Returns:
The decorated function.
Raises:
InvalidOperation: If the decorated function does not have teh results attribute.
"""

def _decorator(self: Any, *args: Any, **kwargs: Any) -> Any:
if not hasattr(self, "results") or self.results is None:
raise InvalidOperation(
"The result attribute cannot be None for this method call."
)
return func(self, *args, **kwargs)

return _decorator


# TODO: figure mypy complains on this line
class BasePaginator(BaseModel, MutableSequence): # type: ignore
"""Thin wrapper around `self.results` for a robinhood paginator."""

@has_results
def __getitem__(self, key: Any) -> Any: # noqa: D
return self.results[key]

@has_results
def __setitem__(self, key: Any, value: Any) -> Any: # noqa: D
self.results[key] = value

@has_results
def __delitem__(self, key: Any) -> None: # noqa: D
del self.results[key]

@has_results
def __len__(self) -> int: # noqa: D105
return len(self.results)

@has_results
def insert(self, index: int, element: Any) -> None: # noqa: D
self.results.insert(index, element)


class BasePaginatorSchema(BaseSchema):
"""BasePaginatorSchema for the BasePaginator class.
Note:
Make sure to re-define the results attribute based on the subclass schema.
"""

__model__ = BasePaginator

next = fields.URL(allow_none=True)
previous = fields.URL(allow_none=True)
results = fields.List(fields.Nested(UnknownModel))


# TODO: Figure how to resolve the circular import with SessionManager (type ignore)
def base_paginator(seed_url: "URL", session_manager: "SessionManager", schema: Any) -> Iterable[Any]: # type: ignore # noqa: F821, E501
"""Create a paginator using the passed parameters.
Args:
seed_url: The url to get the first batch of results.
session_manager: The session manager that will manage the get.
schema: The Schema subclass used to build individual instances.
Yields:
Instances of the object passed in the schema field.
"""
resource_endpoint = seed_url
while True:
paginator = session_manager.get(resource_endpoint, schema=schema)
for instrument in paginator:
yield instrument
if paginator.next is not None:
resource_endpoint = paginator.next
else:
break
Loading

0 comments on commit 0113d31

Please sign in to comment.