diff --git a/poetry.lock b/poetry.lock index fe390e8..58e0745 100644 --- a/poetry.lock +++ b/poetry.lock @@ -367,6 +367,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" @@ -1505,4 +1516,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = "^3.7" -content-hash = "bc16e9c8d9b067e6cee6870a30473e74a8c641305f5da551b9157f96c1546bee" +content-hash = "e29038a3548768e3be7cc1bce96c0170325667dff52ebf2126f63536fbd82e0f" diff --git a/pyproject.toml b/pyproject.toml index 3ab7e97..909700d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ classifiers = [ python = "^3.7" requests = "^2.28.1" portalocker = "^2.7.0" +inflection = "^0.5.1" [tool.poetry.group.dev] optional = true diff --git a/test/test_util.py b/test/test_util.py index 021194c..4f58f19 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -10,6 +10,7 @@ clean_request_params, clean_request_payload, find_model_id, + to_camel_case, to_snake_case, ) @@ -129,7 +130,8 @@ def test_clean_datetime(dt, expected, request): "FOOBar", ], ) -def test_to_snake_case(input): +def test_case_converters(input): + assert to_camel_case(input) == "fooBar" assert to_snake_case(input) == "foo_bar" diff --git a/welkin/util.py b/welkin/util.py index 4424dd8..c92cdc5 100644 --- a/welkin/util.py +++ b/welkin/util.py @@ -1,10 +1,12 @@ -import functools -import re from datetime import date, datetime, timezone +from functools import cache, wraps +from types import FunctionType from typing import Any from uuid import UUID -from welkin.models.base import SchemaBase +import inflection + +from welkin.models.base import Collection, Resource, SchemaBase # NOTE: `clean_request_payload` and `clean_request_params` are intentionally DRY # violations. The code may be the same, but they represent different knowledge. @@ -111,21 +113,46 @@ def clean_datetime(dt: datetime) -> str: ) -def find_model_id(obj, model: str): - if obj.__class__.__name__ == model: - return obj.id - elif hasattr(obj, f"{to_snake_case(model)}Id"): - return obj.patientId - elif obj._parent: - return find_model_id(obj._parent, model) +def find_model_id(instance: Collection | Resource, model_name: str) -> str: + """Recursively traverse the `_parent` chain searching for a model id. - raise AttributeError(f"Cannot find {model} id. Model._parent chain ends in {obj}") + Args: + instance (Collection | Resource): The instanceect instance to inspect. + model_name (str): The class name of the model to find. + Raises: + AttributeError: If recursion ends without finding the model id. -def model_id(*models): - def decorator(f): - @functools.wraps(f) - def wrapper(self, *args, **kwargs): + Returns: + str: The model id. + """ + body_id_key = f"{to_camel_case(model_name)}Id" + + if instance.__class__.__name__ == model_name: + return instance.id + elif hasattr(instance, body_id_key): + return getattr(instance, body_id_key) + elif instance._parent: + return find_model_id(instance._parent, model_name) + + raise AttributeError( + f"Cannot find {model_name} id. Model._parent chain ends in {instance}" + ) + + +def model_id(*models: tuple[str]) -> FunctionType: + """Insert values for `model_id` arguments if not provided. + + Args: + *models (tuple[str]): The model names to search for. + + Raises: + TypeError: If no ID is found and no arguments are provided. + """ + + def decorator(f: FunctionType): + @wraps(f) + def wrapper(self, *args, **kwargs) -> FunctionType: outer_exc = None for model in models: key = f"{to_snake_case(model)}_id" @@ -139,6 +166,7 @@ def wrapper(self, *args, **kwargs): try: return f(self, *args, **kwargs) except TypeError as exc: + # Raise from the outer `AttributeError` so we don't lose context. raise exc from outer_exc return wrapper @@ -146,9 +174,27 @@ def wrapper(self, *args, **kwargs): return decorator -def to_snake_case(s): - first = re.compile(r"(.)([A-Z][a-z]+)") - second = re.compile(r"([a-z0-9])([A-Z])") - repl = r"\1_\2" +@cache +def to_camel_case(s: str) -> str: + """Convert a string to camelCase. + + Args: + s (str): The string to convert. + + Returns: + str: The converted camelCase string. + """ + return inflection.camelize(to_snake_case(s), uppercase_first_letter=False) + + +@cache +def to_snake_case(s: str) -> str: + """Convert a string to snake_case. + + Args: + s (str): The string to convert. - return second.sub(repl, first.sub(repl, s)).lower() + Returns: + str: The converted snake_case string. + """ + return inflection.underscore(s)