diff --git a/examples/activities.py b/examples/activities.py index 36c9ad9..bc6584c 100755 --- a/examples/activities.py +++ b/examples/activities.py @@ -23,8 +23,8 @@ token = oidc_client.authorize() client = TimedAPIClient(token, URL, API_NAMESPACE) -attributes = {"comment": "made with libtimed"} -relationships = {"task": "3605343"} -r = client.activities.start(attributes, relationships) +r = client.activities.start(attributes={"comment": "Made with libtimed!"}) +print(r.json()) time.sleep(7) r = client.activities.stop() +print(r.json()) diff --git a/poetry.lock b/poetry.lock index 698ea29..d074336 100644 --- a/poetry.lock +++ b/poetry.lock @@ -401,6 +401,17 @@ docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker perf = ["ipython"] testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] +[[package]] +name = "inflection" +version = "0.5.1" +description = "A port of Ruby on Rails inflector to Python" +optional = false +python-versions = ">=3.5" +files = [ + {file = "inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"}, + {file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"}, +] + [[package]] name = "iniconfig" version = "2.0.0" @@ -793,4 +804,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "80a3d01c476136c2d29a74f5ce280d5985e00c7f1c0f3cf817b8415db2ebb42a" +content-hash = "a16117d81a166c7d705dcf9e55d810f6bdddaa2d479fdf9e3fff58f7d9b83d20" diff --git a/pyproject.toml b/pyproject.toml index 4d97763..e8e7565 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ packages = [{ include = "libtimed", from = "src" }] python = "^3.11" requests = "^2.31.0" keyring = "^24.1.0" +inflection = "^0.5.1" [tool.poetry.group.dev.dependencies] @@ -24,6 +25,7 @@ pdbpp = "^0.10.3" +# Dependencies only used in examples [tool.poetry.group.example.dependencies] pyfzf = "^0.3.1" diff --git a/src/libtimed/__init__.py b/src/libtimed/__init__.py index 9d3f0e8..6570944 100644 --- a/src/libtimed/__init__.py +++ b/src/libtimed/__init__.py @@ -12,9 +12,9 @@ def __init__(self, token, url, api_namespace): self.session.headers["Content-Type"] = "application/vnd.api+json" # Models - self.users: models.Users = models.Users(self) - self.reports: models.Reports = models.Reports(self) - self.overtime: models.Overtime = models.Overtime(self) + self.users = models.Users(self) + self.reports = models.Reports(self) + self.overtime = models.WorktimeBalances(self) self.activities = models.Activities(self) self.customers = models.Customers(self) self.tasks = models.Tasks(self) diff --git a/src/libtimed/models.py b/src/libtimed/models.py index ef36a93..0daec71 100644 --- a/src/libtimed/models.py +++ b/src/libtimed/models.py @@ -1,9 +1,27 @@ import functools from datetime import date, datetime, timedelta +from enum import Enum +from inflection import underscore from requests import Response -from libtimed.utils import deserialize_duration, handle_response, serialize_duration +from libtimed import transforms + +DATE = ("date", date.today(), transforms.Date) +FROM_DATE = ("from_date", None, transforms.Date) +TO_DATE = ("to_date", None, transforms.Date) +DURATION = ("duration", timedelta(minutes=15), transforms.Duration) +COMMENT = ("comment", "", transforms.Type(str, False)) +REVIEW = ("review", False, transforms.Type(bool, False)) +NOT_BILLABLE = ("not-billable", False, transforms.Type(bool, False)) +NAME = ("name", None, transforms.Type(str)) + + +class UserOrdering(Enum): + EMAIL = "email" + FIRST_NAME = "first-name" + LAST_NAME = "last_name" + USERNAME = "username" class GetOnlyMixin: @@ -20,223 +38,205 @@ class BaseModel: def __init__(self, client) -> None: self.client = client - resource_name: str - attribute_defaults: list[tuple] - relationship_defaults: list[tuple] - filter_defaults: list[tuple] + attributes: list[tuple] + relationships: list[tuple] + filters: list[tuple] def get(self, filters={}, include=None, id=None, raw=False) -> dict: url = f"{self.url}/{id}" if id else self.url if id: params = include else: - params = self._parsed_defaults(self.__class__.filter_defaults, filters) + params = self._parse_filters(filters) if include: params["include"] = include + # TODO: map included resources to relationships + raw = True + resp = self.client.session.get(url, params=params) - resp = handle_response(resp).json() + resp = resp.json() - if included := resp.get("included"): - for data in resp["data"]: - for key, value in data["relationships"].items(): - data["relationships"][key] = next( + # de-serialize + if data := ([resp.get("data")] if id else resp.get("data")): + for item in data: + for key, value in item["attributes"].items(): + transform = next( ( - include - for include in included - if value.get("data") - and include["type"] == value["data"]["type"] - and include["id"] == value["data"]["id"] + transform + for name, _, transform in self.__class__.attributes + if name == key ), None, ) + item["attributes"][key] = ( + (transform).deserialize(value) if transform else value + ) + relationships = item.get("relationships") + if not relationships: + continue + for key, value in relationships.items(): + related_model = next( + ( + related_model + for name, _, related_model, in self.__class__.relationships + if name == key + ), + None, + ) + item["relationships"][key] = ( + transforms.Relationship(related_model).deserialize(value) + if related_model + else value + ) + return resp if raw else resp.get("data") - if raw: - resp["included"] = None - return resp - return resp["data"] - - def post(self, attributes={}, relationships={}, raw=False) -> Response: - json = self._parse_patch_create_json(attributes, relationships, raw) + def post(self, attributes={}, relationships={}) -> Response: + json = self._parse_post_json(attributes, relationships) resp = self.client.session.post(self.url, json=json) return resp - def patch(self, id, attributes={}, relationships={}, raw=False) -> Response: - json = self._parse_patch_create_json(attributes, relationships, raw) + def patch(self, id, attributes={}, relationships={}) -> Response: + json = self._parse_post_json(attributes, relationships) json["data"]["id"] = id resp = self.client.session.patch(f"{self.url}/{id}", json=json) return resp - def all(self, filters, include=None): - # self.get but with different defaults - raise NotImplementedError + def delete(self, id) -> Response: + return self.client.session.delete(f"{self.url}/{id}") - def _parse_patch_create_json(self, attributes, relationships, raw: bool) -> dict: - cls = self.__class__ + @classmethod + @property + def resource_name(cls): + return underscore(cls.__name__).replace("_", "-") + + def _parse_post_json(self, attributes, relationships) -> dict: return { "data": { - "attributes": self._parsed_defaults(cls.attribute_defaults, attributes), - "relationships": self._parsed_relationships( - cls.relationship_defaults, relationships - ) - if not raw - else relationships, - "type": cls.resource_name, + "attributes": self._parse_attributes(attributes), + "relationships": self._parse_relationships(relationships), + "type": self.resource_name, } } - def _id_to_relationship(self, id, resource_name): - if not id: - return {"data": None} + def _parse_attributes(self, passed_attributes: dict = {}): + attributes = self.__class__.attributes + return { - "data": { - "type": resource_name, - "id": id, - } + name: (transform).serialize((passed_attributes.get(name) or value)) + for name, value, transform in attributes } - def _parsed_relationships(self, defaults: list[tuple], relationships: dict) -> dict: - parsed = self._parsed_defaults(defaults, relationships) + def _parse_filters(self, passed_filters: dict = {}): + filters = self.__class__.filters + return { - key: self._id_to_relationship(id, key + "s") for key, id in parsed.items() + name: (transform).serialize( + passed_filters.get(name) or value, is_filter=True, client=self.client + ) + for name, value, transform in filters } - def _parsed_value(self, value, type): - if isinstance(value, datetime): - value = value.strftime("%H:%M:%S") - if isinstance(value, timedelta): - value = serialize_duration(value) - if isinstance(value, date): - value = value.isoformat() - return value if value != "user-id" else self.client.users.me["id"] + def _parse_relationships(self, passed_relationships): + relationships = self.__class__.relationships - def _parsed_defaults(self, defaults: list[tuple], values: dict) -> dict: return { - default[0]: self._parsed_value( - values.get(default[0]) or default[1], - default[2] if len(default) > 2 else dict, + name: transforms.Relationship(related_model).serialize( + passed_relationships.get(name) or value, client=self.client ) - for default in defaults + for name, value, related_model in relationships } @functools.cached_property def url(self): - return self.client.url + self.__class__.resource_name + return self.client.url + self.resource_name class Users(GetOnlyMixin, BaseModel): - resource_name = "users" - - filter_defaults = [ - ( - "ordering", - "username", - str, - "After what field should the users be ordered, can be 'email', 'username', 'first-name' or 'last-name'", - ), - ("active", None, bool, "Only active/inactive users."), + filters = [ + ("ordering", UserOrdering.USERNAME, transforms.Enum(UserOrdering)), + ("active", None, transforms.Type(bool)), ] + attributes = [] + relationships = [] + @functools.cached_property def me(self): """Return the current logged in user.""" return self.get(id="me") -class Reports(BaseModel): - resource_name = "reports" - - attribute_defaults = [ - ("comment", None, str, "Comment -> what exactly did you work on"), - ("date", date.today(), date, "Date of the report."), - ( - "duration", - timedelta(minutes=15), - timedelta, - "Duration, only in 15 min differences.", - ), - ("review", False, bool, "Needs to be reviewed."), - ("not-billable", False, bool, "Is not billable."), - ] - relationship_defaults = [ - ( - "user", - "user-id", - "The users id, defaults to the logged in users id, another users id may require elevated permissions.", - ), - ("task", None, "The tasks id."), - ] - - filter_defaults = [ - ( - "user", - "user-id", - int, - "The users id, another users id requires elevated permissions.", - ), - ("date", date.today(), date, "Date of the report."), - ("from_date", None, date, "Date of the report."), - ("to_date", None, date, "Date of the report."), - ] +CURRENT_USER_FILTER = ( + "user", + transforms.RelationShipProperty("me"), + transforms.Relationship(Users), +) +CURRENT_USER_RELATIONSHIP = ("user", transforms.RelationShipProperty("me"), Users) -class Overtime( +class WorktimeBalances( GetOnlyMixin, BaseModel, ): - resource_name = "worktime-balances" - filter_defaults = [ - ( - "user", - "user-id", - int, - "The user, using other users might require elevated permissions.", - ), - ("date", date.today(), date, "Overtime on that date."), - ("from_date", None, date, "Overtime from this date."), - ("to_date", None, date, "Overtime to this date."), - ] + filters = [CURRENT_USER_FILTER, DATE, FROM_DATE, TO_DATE] + attributes = [DATE, ("balance", None, transforms.Duration)] + relationships = [("user", None, Users)] - def get(self, *args, raw=False, **kwargs): - kwargs["raw"] = raw - data = super().get(*args, **kwargs) - if raw: - return data - elif kwargs.get("include"): - return data[0] - overtime = deserialize_duration(data[0]["attributes"]["balance"]) - return overtime + def get(self, *args, **kwargs): + overtimes = super().get(*args, **kwargs) + return ( + overtimes + if (kwargs.get("raw") or kwargs.get("include")) + else overtimes[0]["attributes"]["balance"] + ) -class Activities(BaseModel): - resource_name = "activities" +class Customers(GetOnlyMixin, BaseModel): + filters = [("archived", None, transforms.Type(bool))] + attributes = [NAME, ("archived", False, transforms.Type(bool, False))] + relationships = [] + + +class Projects(GetOnlyMixin, BaseModel): + filters = [("customer", None, transforms.Relationship(Customers))] + attributes = [NAME] + relationships = [("customer", None, Customers)] + - filter_defaults = [ - ("active", None, bool, "Is the activity currently active?"), - ("day", date.today(), date, "The day/date if the Activity"), +class Tasks(GetOnlyMixin, BaseModel): + filters = [("project", None, transforms.Relationship(Projects))] + attributes = [NAME] + relationships = [("project", None, Projects)] + + +class Activities(BaseModel): + filters = [ + ("active", None, transforms.Type(bool)), + ("day", date.today(), transforms.Date), ] - attribute_defaults = [ - ("comment", "", str, "The comment on the activity"), - ("date", date.today(), date, "The date of the activity"), - ("from-time", datetime.now(), datetime, "The beginning time of the activity."), - ("to-time", None, datetime, "The end time of the activity."), - ("review", False, bool, "Needs to be reviewed."), - ("not-billable", False, bool, "Is not billable."), + attributes = [ + ("from-time", datetime.now(), transforms.Time), + ("to-time", None, transforms.Time), + COMMENT, + DATE, + REVIEW, + NOT_BILLABLE, ] - relationship_defaults = [ - ("task", None, "The id of the task of the activity"), - ("user", "user-id", "The users id whoms't the activitty belongs to."), + relationships = [ + CURRENT_USER_RELATIONSHIP, + ("task", None, Tasks), ] @property def current(self): - return (self.get({"active": True}) or [[]])[0] + return (self.get({"active": True}) or [{}])[0] - def start(self, attributes: dict, relationships: dict): + def start(self, **kwargs): if self.current: self.stop() - return self.post(attributes, relationships) + return self.post(**kwargs) def stop(self): if self.current: @@ -247,16 +247,9 @@ def stop(self): return r -class Customers(GetOnlyMixin, BaseModel): - resource_name = "customers" - filter_defaults = [("archived", None, bool, "Is project archived?")] - - -class Projects(GetOnlyMixin, BaseModel): - resource_name = "projects" - filter_defaults = [("customer", None, int, "Customer of project")] +class Reports(BaseModel): + attributes = [COMMENT, DATE, DURATION, REVIEW, NOT_BILLABLE] + relationships = [CURRENT_USER_RELATIONSHIP, ("task", None, Tasks)] -class Tasks(GetOnlyMixin, BaseModel): - resource_name = "tasks" - filter_defaults = [("project", None, int, "Project of task")] + filters = [CURRENT_USER_FILTER, DATE, FROM_DATE, TO_DATE] diff --git a/src/libtimed/transforms.py b/src/libtimed/transforms.py new file mode 100644 index 0000000..cc9b1ba --- /dev/null +++ b/src/libtimed/transforms.py @@ -0,0 +1,175 @@ +from datetime import date, datetime, timedelta +from enum import Enum as EnumClass +from typing import Optional, Type as TypingType, Union + + +class SerializationError(ValueError): + """Error raised only inside of Transforms when trying to (de)serialize values.""" + + +class BaseTransform: + """Base class for serializers.""" + + @staticmethod + def serialize(value_for_api): + """Serialize a value so that it can be sent to the API.""" + return value_for_api + + @staticmethod + def deserialize(value_from_api): + """Deserialize a value from the API so that it can be used in a pythonic way.""" + return value_from_api + + +class Type(BaseTransform): + """Transform for types.""" + + def __init__(self, type: TypingType, allow_none: bool = True): + self.type = type + self.allow_none = allow_none + + def _validate(self, value): + if not isinstance(value, self.type) and not (value is None and self.allow_none): + raise SerializationError( + f"The provided value ({value}) is not of type {self.type} but instead of type {type(self.type)}" + ) + return value + + def serialize(self, value, **_): + return self._validate(value) + + def deserialize(self, value): + return self._validate(value) + + +class Duration(BaseTransform): + """Transform for durations.""" + + @staticmethod + def serialize(duration: Union[timedelta, str], **_) -> str: + if isinstance(duration, str): + # validate by calling deserialize on it + return duration + + Type(timedelta, False).serialize(duration) + days = "" + if duration.days != 0: + days = str(duration.days) + " " + hours, minutes, seconds = str(duration).split(" ")[-1].split(":") + return f"{days}{hours.zfill(2)}:{minutes}:{seconds}" + + @staticmethod + def deserialize(duration: str) -> timedelta: + # TODO: add validation + days = 0 + if len(duration.split(" ")) != 1: + days, duration = duration.split(" ") + hours, minutes, seconds = map(int, duration.split(":")) + delta = timedelta(days=int(days), hours=hours, minutes=minutes, seconds=seconds) + return delta + + +class RelationShipProperty: + def __init__(self, property_name) -> None: + self.property_name = property_name + + +class Relationship(BaseTransform): + """Transform for relationships. This is very hacky and should be replaced with a better solution.""" + + def __init__(self, related_model) -> None: + self.related_model = related_model + + def serialize( + self, + value: Union[int, str, RelationShipProperty, None], + is_filter=False, + client=None, + ) -> Union[dict, str, None]: + if not value: + return {"data": None} + data = {} + if isinstance(value, RelationShipProperty): + if not client: + raise SerializationError( + "Client has to be passed when using a property" + ) + try: + data["data"] = getattr(self.related_model(client), value.property_name) + except AttributeError: + raise SerializationError( + f"Unknown property {value} on {self.related_model}!" + ) + return_value: dict = data or { + "data": {"type": self.related_model.resource_name, "id": data or value} + } + return return_value["data"].get("id") if is_filter else return_value + + def deserialize(self, value: dict) -> Optional[dict]: + data = value["data"] or {} + if ( + recieved_type := (data or {}).get("type") + ) != self.related_model.resource_name: + if recieved_type is None: + return None + raise SerializationError( + f"Recieved realtionship of type ({recieved_type}), expected type ({self.related_model.resource_name}" + ) + return data + + +class Date(BaseTransform): + """Transform for dates.""" + + @staticmethod + def serialize(value: Union[date, str], **_) -> str: + if isinstance(value, str): + try: + value = datetime.fromisoformat(value) + except ValueError: + raise SerializationError( + f"The provided value ({value}) is not formatted correctly." + ) + return value if value is None else value.isoformat() + + @staticmethod + def deserialize(value_from_api) -> date: + return datetime.strptime(value_from_api, "%Y-%m-%d").date() + + +class Time(BaseTransform): + """Transform for times.""" + + @staticmethod + def serialize(value: Union[datetime, str], **_) -> Optional[str]: + FORMAT = "%H:%M:%S" + if isinstance(value, str): + try: + value = datetime.strptime(value, FORMAT) + except ValueError: + raise SerializationError( + f"The provided value ({value}) is not formatted correctly ({FORMAT})." + ) + return value.strftime(FORMAT) if value else None + + @staticmethod + def deserialize(value) -> Optional[date]: + return datetime.strptime(value, "%H:%M:%S") if value else None + + +class Enum(BaseTransform): + def __init__(self, enum: TypingType[EnumClass]) -> None: + self.enum = enum + + def serialize(self, value, **_): + value = value if isinstance(value, str) else value.value + if value not in self.enum._value2member_map_: + raise SerializationError( + f"The provided value ({value}) is not an option, consider using {self.enum.__name__}.{{{','.join(self.enum.__members__)}}}." + ) + + def deserialize(self, value): + if value not in self.enum._value2member_map_: + raise SerializationError( + f"The value ({value}) provided by the API is not a member of {self.enum.__name__}, options are: {self.enum.__name__}.{{{','.join(self.enum.__members__)}}}." + ) diff --git a/src/libtimed/utils.py b/src/libtimed/utils.py deleted file mode 100644 index 7c7c911..0000000 --- a/src/libtimed/utils.py +++ /dev/null @@ -1,25 +0,0 @@ -from datetime import timedelta - -import requests - - -def serialize_duration(duration: timedelta) -> str: - days = "" - if duration.days != 0: - days = str(duration.days) + " " - hours, minutes, seconds = str(duration).split(" ")[-1].split(":") - return f"{days}{hours.zfill(2)}:{minutes}:{seconds}" - - -def deserialize_duration(duration: str) -> timedelta: - days = 0 - if len(duration.split(" ")) != 1: - days, duration = duration.split(" ") - hours, minutes, seconds = map(int, duration.split(":")) - delta = timedelta(days=int(days), hours=hours, minutes=minutes, seconds=seconds) - return delta - - -def handle_response(resp: requests.Response) -> requests.Response: - # handle responses - return resp diff --git a/tests/test_client.py b/tests/test_client.py index db4ca94..bda352e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -11,3 +11,8 @@ def test_overtime(client): def test_users(client): result = client.users.get() assert all(isinstance(user, dict) for user in result) + + +def test_reports(client): + result = client.reports.get() + assert result diff --git a/tests/test_models.py b/tests/test_models.py index c708c8b..6d471c4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -6,18 +6,9 @@ def test_get_only_mixin(client): with pytest.raises(NotImplementedError) as exc_info: client.users.post() - assert exc_info.value.args[0] == GetOnlyMixin.message def test_includes(client): overtime = client.overtime.get(include="user") - assert overtime["relationships"]["user"] == client.users.me - - -def test_parse_defaults(): - assert True - - -def test_parse_relationships(): - assert True + assert overtime["data"][0]["relationships"]["user"]["id"] == client.users.me["id"] diff --git a/tests/test_transforms.py b/tests/test_transforms.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index fd422e4..0000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,18 +0,0 @@ -from datetime import timedelta - -import pytest - -from libtimed.utils import deserialize_duration, serialize_duration - - -@pytest.mark.parametrize( - "duration,result", - [ - ("04:30:00", timedelta(hours=4, minutes=30)), - ("-1 19:30:00", timedelta(days=-1, hours=19, minutes=30)), - ("1 03:30:00", timedelta(days=1, hours=3, minutes=30)), - ], -) -def test_parse_duration(duration, result): - assert deserialize_duration(duration) == result - assert serialize_duration(result) == duration