From 39fdb88d9a482c21dd88b4dec514e454f7e8eae9 Mon Sep 17 00:00:00 2001 From: vokeralex Date: Fri, 21 Nov 2025 15:00:41 -0800 Subject: [PATCH 1/2] setup repo --- .gitignore | 3 + .python-version | 1 + docs/README-TEMPLATE.md | 0 examples/database.py | 37 ++++++++++ examples/endpoints.py | 40 +++++++++++ pyproject.toml | 15 ++++ testing_utils/__init__.py | 8 +++ testing_utils/models.py | 45 ++++++++++++ testing_utils/py.typed | 0 testing_utils/sort.py | 65 +++++++++++++++++ testing_utils/utils.py | 147 ++++++++++++++++++++++++++++++++++++++ tests/test_sort.py | 0 tests/test_utils.py | 0 13 files changed, 361 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 docs/README-TEMPLATE.md create mode 100644 examples/database.py create mode 100644 examples/endpoints.py create mode 100644 pyproject.toml create mode 100644 testing_utils/__init__.py create mode 100644 testing_utils/models.py create mode 100644 testing_utils/py.typed create mode 100644 testing_utils/sort.py create mode 100644 testing_utils/utils.py create mode 100644 tests/test_sort.py create mode 100644 tests/test_utils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23cf457 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.mypy_cache +.venv +uv.lock diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/docs/README-TEMPLATE.md b/docs/README-TEMPLATE.md new file mode 100644 index 0000000..e69de29 diff --git a/examples/database.py b/examples/database.py new file mode 100644 index 0000000..1928642 --- /dev/null +++ b/examples/database.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Self + +from pydantic import BaseModel + +from testing_utils import BaseUtils + + +class Transaction: + """ + TODO: + """ + + def __init__(self, utils: DatabaseUtils) -> None: + self._utils = utils + + async def commit(self) -> DatabaseUtils: + await self._utils.commit(self) + return self._utils + + +class DatabaseUtils(BaseUtils[Transaction, BaseModel]): + """ + TODO: + """ + + # TODO: add types + def __init__(self, db, **kwargs) -> None: + super().__init__(**kwargs) + self._db = db + + def fork(self, label: str = "") -> Self: + return self._fork(DatabaseUtils, label) + + def start_transaction(self) -> Transaction: + return Transaction(self) diff --git a/examples/endpoints.py b/examples/endpoints.py new file mode 100644 index 0000000..c48e499 --- /dev/null +++ b/examples/endpoints.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import Self + +from pydantic import BaseModel + +from testing_utils import BaseUtils, Model + + +class Transaction: + """ + TODO: + """ + + def __init__(self, utils: EndpointUtils) -> None: + self._utils = utils + + async def commit(self) -> EndpointUtils: + await self._utils.commit(self) + return self._utils + + +class EndpointUtils(BaseUtils[Transaction, BaseModel]): + """ + TODO: + """ + + # TODO: add types + def __init__(self, client, **kwargs) -> None: + super().__init__(**kwargs) + self._client = client + self._models = [ + Model(name="", requires=[]) + ] + + def fork(self, label: str = "") -> Self: + return self._fork(EndpointUtils, label) + + def start_transaction(self) -> Transaction: + return Transaction(self) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..204ce8c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "testing-utils" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.14" +dependencies = [] + +[dependency-groups] +dev = [ + "black>=25.11.0", + "isort>=7.0.0", + "mypy>=1.18.2", + "pytest>=9.0.1", +] diff --git a/testing_utils/__init__.py b/testing_utils/__init__.py new file mode 100644 index 0000000..10236db --- /dev/null +++ b/testing_utils/__init__.py @@ -0,0 +1,8 @@ +from .utils import BaseUtils +from .models import Model, ModelRequest + +__all__ = [ + "BaseUtils", + "Model", + "ModelRequest", +] diff --git a/testing_utils/models.py b/testing_utils/models.py new file mode 100644 index 0000000..9e2237f --- /dev/null +++ b/testing_utils/models.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass +from typing import Any, Optional, TypeVar + + +@dataclass +class Model: + """ + TODO: + """ + name: str + requires: list[str] + plural: Optional[str] = None + + @property + def plural_name(self) -> str: + if self.plural is not None: + return self.plural + + return f"{self.name}s" + + +@dataclass +class ModelRequest: + name: str + args: dict[str, Any] + + +@dataclass +class ModelWithRequest: + """ + TODO: + """ + model: Model + request: ModelRequest + + +T = TypeVar("T") + + +def or_(*args: T | None) -> T: + for arg in args: + if arg is not None: + return arg + + assert False, "or_() was given no non-None values" diff --git a/testing_utils/py.typed b/testing_utils/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/testing_utils/sort.py b/testing_utils/sort.py new file mode 100644 index 0000000..8425877 --- /dev/null +++ b/testing_utils/sort.py @@ -0,0 +1,65 @@ +from .models import Model, ModelRequest, ModelWithRequest + + +def topological_sort_and_fill( + models: list[Model], + requests: list[ModelRequest], +) -> list[ModelWithRequest]: + models_with_requests: list[ModelWithRequest] = [] + request_model_names = {request.name for request in requests} + + for request in requests: + model = next((m for m in models if m.name == request.name), None) + + assert model is not None, f"Model {request.name} not found in models list" + + models_with_requests.append(ModelWithRequest(model=model, request=request)) + + visited_names = set[str]() + stack: list[ModelWithRequest] = [] + + def dfs(node: ModelWithRequest) -> None: + visited_names.add(node.model.name) + + for dependency in node.model.requires: + if ( + dependency not in request_model_names + and dependency not in visited_names + ): + # user didn't specify this model, so add in default + model_to_add = next( + (m for m in models if m.name == dependency), + None, + ) + + msg = f"Model {dependency} not found in models list" + assert model_to_add is not None, msg + + node_to_add = ModelWithRequest( + model=model_to_add, + request=ModelRequest( + name=model_to_add.name, + args={}, + ), + ) + + dfs(node_to_add) + elif dependency not in visited_names: + # add parent dependency + user_specified_node_to_add: ModelWithRequest | None = next( + (m for m in models_with_requests if m.model.name == dependency), + None, + ) + + msg = f"Model {dependency} not found in models list" + assert user_specified_node_to_add is not None, msg + + dfs(user_specified_node_to_add) + + stack.append(node) + + for node in models_with_requests: + if node.model.name not in visited_names: + dfs(node) + + return stack diff --git a/testing_utils/utils.py b/testing_utils/utils.py new file mode 100644 index 0000000..4c478ff --- /dev/null +++ b/testing_utils/utils.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from logging import getLogger +from typing import Any, Optional, Self + +from .models import Model, ModelRequest, or_ +from .sort import topological_sort_and_fill + +logger = getLogger("testing-utils") + + +class BaseUtils[TTransaction, TValue](ABC): + + def __init__( + self, + name: str = "root", + parent: Optional[Self] = None, + requests: Optional[list[ModelRequest]] = None, + **kwargs: Any, + ) -> None: + self._data: dict[str, Any] = {} + self._name = name + self._parent = parent + # self._is_setup = False + # self._setup_complete = False + self._created_values: dict[str, TValue] = {} + self._children: list[BaseUtils] = [] + self._parent = parent + self._requests = or_(requests, []) + self._models: list[Model] = [] + self._kwargs = kwargs + + def _find_value(self, name: str) -> TValue | None: + # if not self._is_setup: + # msg = "TestingUtils is not set up. Call setup() before using it." + # raise RuntimeError(msg) + + if name in self._created_values: + return self._created_values[name] + + if self._parent is not None: + return self._parent._find_value(name) + + in_request = next( + (request for request in self._requests if request.name == name), + None, + ) + + # if self._setup_complete and in_request is None: + # raise Exception(f"accessing {name} not in request") + + return None + + def _add_request(self, request: ModelRequest) -> None: + self._requests.append(request) + + def _get_value(self, name: str) -> TValue: + val = self._find_value(name) + + if val is not None: + return val + + msg = f"Value {name} not found in created values or parent." + raise RuntimeError(msg) + + @abstractmethod + def start(self) -> TTransaction: + """ + TODO: + """ + + # + # TODO: support sync too + # + async def _dispatch( + self, + tx: TTransaction, + model: Model, + data: dict[str, Any], + ) -> None: + repo = getattr(self, f"_get_{model.plural_name}_repo")() + create_defaults_func = getattr(tx, f"_create_{model.name}_defaults") + defaults = create_defaults_func(**data) + value = await getattr(repo, f"create_{model.name}")(**defaults) + self._created_values[model.name] = value + + # + # TODO: support sync too + # + async def commit(self, tx: TTransaction) -> None: + """ + TODO: + """ + await self._commit(tx) + + children = self._children.copy() + + while len(children) > 0: + child = children.pop(0) + await child._commit(tx) + children.extend(child._children) + + # + # TODO: support sync too + # + async def _commit(self, tx: TTransaction) -> None: + models_to_create = topological_sort_and_fill( + self._models, + self._requests, + ) + + for model in models_to_create: + if self._find_value(model.model.name) is not None: + continue + + logger.debug( + "%s creating %s with args: %s", + self._name, + model.model.name, + model.request.args, + ) + + await self._dispatch(tx, model.model, model.request.args) + + self._requests = [] + + @abstractmethod + def fork(self, label: str = "") -> Self: + """ + TODO: + """ + + def _fork[T: BaseUtils](self, cls: type[T], label: str = "") -> T: + name = f"{self._name}.{len(self._children)}" + + if len(label) > 0: + name += f".{label}" + + child = cls( + name=name, + parent=self, + requests=self._requests.copy(), + **self._kwargs, + ) + self._children.append(child) + return child diff --git a/tests/test_sort.py b/tests/test_sort.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..e69de29 From 9837f3dcdbda7d7a559edf681018c0149de32be7 Mon Sep 17 00:00:00 2001 From: vokeralex Date: Sun, 23 Nov 2025 20:17:00 -0800 Subject: [PATCH 2/2] rename --- examples/database.py | 2 +- examples/endpoints.py | 2 +- testing_utils/__init__.py | 4 +- testing_utils/models.py | 6 +-- testing_utils/sort.py | 34 +++++++------- testing_utils/utils.py | 95 +++++++++++++++++++++++++++------------ 6 files changed, 90 insertions(+), 53 deletions(-) diff --git a/examples/database.py b/examples/database.py index 1928642..1354bb2 100644 --- a/examples/database.py +++ b/examples/database.py @@ -33,5 +33,5 @@ def __init__(self, db, **kwargs) -> None: def fork(self, label: str = "") -> Self: return self._fork(DatabaseUtils, label) - def start_transaction(self) -> Transaction: + def start(self) -> Transaction: return Transaction(self) diff --git a/examples/endpoints.py b/examples/endpoints.py index c48e499..c01b6a5 100644 --- a/examples/endpoints.py +++ b/examples/endpoints.py @@ -36,5 +36,5 @@ def __init__(self, client, **kwargs) -> None: def fork(self, label: str = "") -> Self: return self._fork(EndpointUtils, label) - def start_transaction(self) -> Transaction: + def start(self) -> Transaction: return Transaction(self) diff --git a/testing_utils/__init__.py b/testing_utils/__init__.py index 10236db..42616f7 100644 --- a/testing_utils/__init__.py +++ b/testing_utils/__init__.py @@ -1,8 +1,8 @@ from .utils import BaseUtils -from .models import Model, ModelRequest +from .models import Model, FixtureSpec __all__ = [ "BaseUtils", "Model", - "ModelRequest", + "FixtureSpec", ] diff --git a/testing_utils/models.py b/testing_utils/models.py index 9e2237f..93bbc31 100644 --- a/testing_utils/models.py +++ b/testing_utils/models.py @@ -20,18 +20,18 @@ def plural_name(self) -> str: @dataclass -class ModelRequest: +class FixtureSpec: name: str args: dict[str, Any] @dataclass -class ModelWithRequest: +class ModelWithFixture: """ TODO: """ model: Model - request: ModelRequest + fixture: FixtureSpec T = TypeVar("T") diff --git a/testing_utils/sort.py b/testing_utils/sort.py index 8425877..a0a5155 100644 --- a/testing_utils/sort.py +++ b/testing_utils/sort.py @@ -1,29 +1,29 @@ -from .models import Model, ModelRequest, ModelWithRequest +from .models import Model, FixtureSpec, ModelWithFixture def topological_sort_and_fill( models: list[Model], - requests: list[ModelRequest], -) -> list[ModelWithRequest]: - models_with_requests: list[ModelWithRequest] = [] - request_model_names = {request.name for request in requests} + fixtures: list[FixtureSpec], +) -> list[ModelWithFixture]: + models_with_fixtures: list[ModelWithFixture] = [] + fixture_model_names = {fixture.name for fixture in fixtures} - for request in requests: - model = next((m for m in models if m.name == request.name), None) + for fixture in fixtures: + model = next((m for m in models if m.name == fixture.name), None) - assert model is not None, f"Model {request.name} not found in models list" + assert model is not None, f"Model {fixture.name} not found in models list" - models_with_requests.append(ModelWithRequest(model=model, request=request)) + models_with_fixtures.append(ModelWithFixture(model=model, fixture=fixture)) visited_names = set[str]() - stack: list[ModelWithRequest] = [] + stack: list[ModelWithFixture] = [] - def dfs(node: ModelWithRequest) -> None: + def dfs(node: ModelWithFixture) -> None: visited_names.add(node.model.name) for dependency in node.model.requires: if ( - dependency not in request_model_names + dependency not in fixture_model_names and dependency not in visited_names ): # user didn't specify this model, so add in default @@ -35,9 +35,9 @@ def dfs(node: ModelWithRequest) -> None: msg = f"Model {dependency} not found in models list" assert model_to_add is not None, msg - node_to_add = ModelWithRequest( + node_to_add = ModelWithFixture( model=model_to_add, - request=ModelRequest( + fixture=FixtureSpec( name=model_to_add.name, args={}, ), @@ -46,8 +46,8 @@ def dfs(node: ModelWithRequest) -> None: dfs(node_to_add) elif dependency not in visited_names: # add parent dependency - user_specified_node_to_add: ModelWithRequest | None = next( - (m for m in models_with_requests if m.model.name == dependency), + user_specified_node_to_add: ModelWithFixture | None = next( + (m for m in models_with_fixtures if m.model.name == dependency), None, ) @@ -58,7 +58,7 @@ def dfs(node: ModelWithRequest) -> None: stack.append(node) - for node in models_with_requests: + for node in models_with_fixtures: if node.model.name not in visited_names: dfs(node) diff --git a/testing_utils/utils.py b/testing_utils/utils.py index 4c478ff..ec58678 100644 --- a/testing_utils/utils.py +++ b/testing_utils/utils.py @@ -4,7 +4,7 @@ from logging import getLogger from typing import Any, Optional, Self -from .models import Model, ModelRequest, or_ +from .models import Model, FixtureSpec, or_ from .sort import topological_sort_and_fill logger = getLogger("testing-utils") @@ -16,7 +16,7 @@ def __init__( self, name: str = "root", parent: Optional[Self] = None, - requests: Optional[list[ModelRequest]] = None, + fixtures: Optional[list[FixtureSpec]] = None, **kwargs: Any, ) -> None: self._data: dict[str, Any] = {} @@ -27,7 +27,7 @@ def __init__( self._created_values: dict[str, TValue] = {} self._children: list[BaseUtils] = [] self._parent = parent - self._requests = or_(requests, []) + self._fixtures = or_(fixtures, []) self._models: list[Model] = [] self._kwargs = kwargs @@ -42,18 +42,18 @@ def _find_value(self, name: str) -> TValue | None: if self._parent is not None: return self._parent._find_value(name) - in_request = next( - (request for request in self._requests if request.name == name), + in_fixture = next( + (fixture for fixture in self._fixtures if fixture.name == name), None, ) - # if self._setup_complete and in_request is None: - # raise Exception(f"accessing {name} not in request") + # if self._setup_complete and in_fixture is None: + # raise Exception(f"accessing {name} not in fixture") return None - def _add_request(self, request: ModelRequest) -> None: - self._requests.append(request) + def _add_fixture(self, fixture: FixtureSpec) -> None: + self._fixtures.append(fixture) def _get_value(self, name: str) -> TValue: val = self._find_value(name) @@ -70,9 +70,6 @@ def start(self) -> TTransaction: TODO: """ - # - # TODO: support sync too - # async def _dispatch( self, tx: TTransaction, @@ -85,45 +82,85 @@ async def _dispatch( value = await getattr(repo, f"create_{model.name}")(**defaults) self._created_values[model.name] = value - # - # TODO: support sync too - # + def _dispatch_sync( + self, + tx: TTransaction, + model: Model, + data: dict[str, Any], + ) -> None: + repo = getattr(self, f"_get_{model.plural_name}_repo")() + create_defaults_func = getattr(tx, f"_create_{model.name}_defaults") + defaults = create_defaults_func(**data) + value = getattr(repo, f"create_{model.name}")(**defaults) + self._created_values[model.name] = value + async def commit(self, tx: TTransaction) -> None: """ TODO: """ - await self._commit(tx) + await self._commit_async(tx) children = self._children.copy() while len(children) > 0: child = children.pop(0) - await child._commit(tx) + await child._commit_async(tx) children.extend(child._children) - # - # TODO: support sync too - # - async def _commit(self, tx: TTransaction) -> None: + def commit_sync(self, tx: TTransaction) -> None: + """ + TODO: + """ + self._commit_sync(tx) + + children = self._children.copy() + + while len(children) > 0: + child = children.pop(0) + child._commit_sync(tx) + children.extend(child._children) + + async def _commit_async(self, tx: TTransaction) -> None: + models_to_create = topological_sort_and_fill( + self._models, + self._fixtures, + ) + + for model_with_fixture in models_to_create: + if self._find_value(model_with_fixture.model.name) is not None: + continue + + logger.debug( + "%s creating %s with args: %s", + self._name, + model_with_fixture.model.name, + model_with_fixture.fixture.args, + ) + + await self._dispatch(tx, model_with_fixture.model, model_with_fixture.fixture.args) + + self._fixtures = [] + + def _commit_sync(self, tx: TTransaction) -> None: models_to_create = topological_sort_and_fill( self._models, - self._requests, + self._fixtures, ) - for model in models_to_create: - if self._find_value(model.model.name) is not None: + for model_with_fixture in models_to_create: + if self._find_value(model_with_fixture.model.name) is not None: continue logger.debug( "%s creating %s with args: %s", self._name, - model.model.name, - model.request.args, + model_with_fixture.model.name, + model_with_fixture.fixture.args, ) - await self._dispatch(tx, model.model, model.request.args) + self._dispatch_sync(tx, model_with_fixture.model, model_with_fixture.fixture.args) - self._requests = [] + self._fixtures = [] @abstractmethod def fork(self, label: str = "") -> Self: @@ -140,7 +177,7 @@ def _fork[T: BaseUtils](self, cls: type[T], label: str = "") -> T: child = cls( name=name, parent=self, - requests=self._requests.copy(), + fixtures=self._fixtures.copy(), **self._kwargs, ) self._children.append(child)