Skip to content

Commit

Permalink
Resource provider args resolving (#27)
Browse files Browse the repository at this point in the history
* up ruff hook version, migrate from pdm to uv

* add args resolving for resource provider and tests with sqlalchemy
  • Loading branch information
nightblure authored Dec 14, 2024
1 parent fbacdd3 commit 517b9d8
Show file tree
Hide file tree
Showing 15 changed files with 2,557 additions and 2,433 deletions.
21 changes: 8 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ jobs:
with:
python-version: "3.12"

- name: Install Pre-Commit
- name: Install pre-commit
run: python -m pip install pre-commit

- name: Execute Pre-Commit
run: make lint-ci
run: pre-commit install && pre-commit run --color=always --all-files

mypy:
needs: lint
Expand All @@ -34,21 +34,17 @@ jobs:
with:
python-version: "3.8"

- uses: pdm-project/setup-pdm@v4
name: Set up PDM
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
python-version: ${{ matrix.python-version }}
allow-python-prereleases: false
prerelease: false
cache: true
cache-dependency-path: |
./pdm.lock
enable-cache: true
cache-dependency-glob: "uv.lock"

- name: Install dependencies
run: pdm install -G:all
run: uv sync --locked --all-extras --no-install-project

- name: Execute mypy
run: make mypy
run: uv run mypy src tests

test:
needs: mypy
Expand All @@ -60,7 +56,6 @@ jobs:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ __pycache__/

# C extensions
*.so

*.db
# Distribution / packaging
.Python
build/
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.1
rev: v0.8.3
hooks:
- id: ruff
entry: ruff check src tests --fix --exit-non-zero-on-fix --show-fixes
Expand Down
21 changes: 7 additions & 14 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,25 @@ test-py:
hatch test -i python="$(v)" --cover --randomize

lint:
pdm run pre-commit install
pdm run pre-commit run --all-files

lint-ci:
pre-commit install && pre-commit run --color=always --all-files
pre-commit install
pre-commit run --all-files

deps:
pdm install
uv sync

build:
rm -r -f dist && pdm run hatch build

hatch-env-prune:
pdm run hatch env prune
hatch env prune

docs-server:
rm -rf docs/build
pdm run sphinx-autobuild docs docs/build
sphinx-autobuild docs docs/build

build-docs:
rm -rf docs/build/* && rm -rf docs/build/{*,.*}
pdm run sphinx-build docs docs/build

# https://pdm-project.org/latest/usage/dependency/#select-a-subset-of-dependency-groups-to-install
docs-deps:
pdm install -G docs
sphinx-build docs docs/build

# example: make tag v="v3.9.2", TAG MUST INCLUDE v
release:
Expand All @@ -50,4 +43,4 @@ release-minor:
make release

mypy:
pdm run mypy src tests
mypy src tests
113 changes: 59 additions & 54 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,91 +56,96 @@ pip install deps-injection
| [Litestar](https://github.com/litestar-org/litestar) |||||


## Using example with FastAPI
## Using example with FastAPI and SQLAlchemy
```python3
from typing import Annotated
from unittest.mock import Mock
from contextlib import contextmanager
from random import Random
from typing import Annotated, Any, Callable, Dict, Iterator

import pytest
from fastapi import APIRouter, Depends, FastAPI
from fastapi.testclient import TestClient
from fastapi import Depends, FastAPI
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session, sessionmaker
from starlette.testclient import TestClient

from injection import DeclarativeContainer, Provide, inject, providers


class Settings:
redis_url: str = "redis://localhost"
redis_port: int = 6379
@contextmanager
def db_session_resource(session_factory: Callable[..., Session]) -> Iterator[Session]:
session = session_factory()
try:
yield session
except Exception:
session.rollback()
finally:
session.close()


class Redis:
def __init__(self, *, url: str, port: int):
self.uri = url + ":" + str(port)
self.url = url
self.port = port
class SomeDAO:
def __init__(self, db_session: Session) -> None:
self.db_session = db_session

def get(self, key):
return key
def get_some_data(self, num: int) -> int:
stmt = text("SELECT :num").bindparams(num=num)
data: int = self.db_session.execute(stmt).scalar_one()
return data


class Container(DeclarativeContainer):
settings = providers.Singleton(Settings)
redis = providers.Singleton(
Redis,
port=settings.provided.redis_port,
url=settings.provided.redis_url,
class DIContainer(DeclarativeContainer):
db_engine = providers.Singleton(
create_engine,
url="sqlite:///db.db",
pool_size=20,
max_overflow=0,
pool_pre_ping=False,
)

session_factory = providers.Singleton(
sessionmaker,
db_engine.cast,
autoflush=False,
autocommit=False,
)

router = APIRouter(prefix="/api")
db_session = providers.Resource(
db_session_resource,
session_factory=session_factory.cast,
function_scope=True,
)

some_dao = providers.Factory(SomeDAO, db_session=db_session.cast)

def create_app():
app = FastAPI()
app.include_router(router)
return app

SomeDAODependency = Annotated[SomeDAO, Depends(Provide[DIContainer.some_dao])]

RedisDependency = Annotated[Redis, Depends(Provide[Container.redis])]
app = FastAPI()


@router.get("/values")
@app.get("/values/{value}")
@inject
def some_get_endpoint_handler(redis: RedisDependency):
value = redis.get(299)
async def sqla_resource_handler_async(
value: int,
some_dao: SomeDAODependency,
) -> Dict[str, Any]:
value = some_dao.get_some_data(num=value)
return {"detail": value}
```

## Testing example with overriding providers for above FastAPI example
```python3
@pytest.fixture(scope="session")
def app():
return create_app()


@pytest.fixture(scope="session")
def container():
return Container.instance()


@pytest.fixture()
def test_client(app):
def test_client() -> TestClient:
client = TestClient(app)
return client


def test_override_providers(test_client, container):
def mock_get_method(_):
return "mock_get_method"

mock_redis = Mock()
mock_redis.get = mock_get_method

providers_to_override = {"redis": mock_redis}
def test_sqla_resource(test_client: TestClient) -> None:
rnd = Random()
random_int = rnd.randint(-(10**6), 10**6)

with container.override_providers(providers_to_override):
response = test_client.get("/api/values")
response = test_client.get(f"/values/{random_int}")

assert response.status_code == 200
assert not DIContainer.db_session.initialized
body = response.json()
assert body["detail"] == "mock_get_method"
assert body["detail"] == random_int
```
3 changes: 2 additions & 1 deletion docs/dev/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ with fixes and new features!

Modern packages, dependencies and practices were used in the development of the Injection:
* linter and formatter - [Ruff](https://docs.astral.sh/ruff/);
* dependency manager - [PDM](https://pdm-project.org/en/latest/);
* type checking - [mypy](https://github.com/python/mypy);
* package manager - [uv](https://github.com/astral-sh/uv);
* package builder - [Hatch](https://github.com/pypa/hatch);
* testing - [pytest](https://github.com/pytest-dev/pytest);
* assembly and documentation management - [Sphinx](https://www.sphinx-doc.org/en/master/).
Expand Down
98 changes: 97 additions & 1 deletion docs/providers/resource.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* **inheritors** of `ContextManager` and `AsyncContextManager` classes;
* functions wrapped into `@contextmanager` and `@asynccontextmanager` **decorators**.

## Working scope
## Resource scopes
Resource provider can works with two scopes: **singleton** and **function-scope**.

**Function-scope** requires to set parameter of `Resource` provider `function_scope=True`.
Expand Down Expand Up @@ -61,3 +61,99 @@ async def main() -> None:
if __name__ == "__main__":
await main()
```

---

## Example with SQLAlchemy and FastAPI
```python
from contextlib import contextmanager
from random import Random
from typing import Annotated, Any, Callable, Dict, Iterator

import pytest
from fastapi import Depends, FastAPI
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session, sessionmaker
from starlette.testclient import TestClient

from injection import DeclarativeContainer, Provide, inject, providers


@contextmanager
def db_session_resource(session_factory: Callable[..., Session]) -> Iterator[Session]:
session = session_factory()
try:
yield session
except Exception:
session.rollback()
finally:
session.close()


class SomeDAO:
def __init__(self, db_session: Session) -> None:
self.db_session = db_session

def get_some_data(self, num: int) -> int:
stmt = text("SELECT :num").bindparams(num=num)
data: int = self.db_session.execute(stmt).scalar_one()
return data


class DIContainer(DeclarativeContainer):
db_engine = providers.Singleton(
create_engine,
url="sqlite:///db.db",
pool_size=20,
max_overflow=0,
pool_pre_ping=False,
)

session_factory = providers.Singleton(
sessionmaker,
db_engine.cast,
autoflush=False,
autocommit=False,
)

db_session = providers.Resource(
db_session_resource,
session_factory=session_factory.cast,
function_scope=True,
)

some_dao = providers.Factory(SomeDAO, db_session=db_session.cast)


SomeDAODependency = Annotated[SomeDAO, Depends(Provide[DIContainer.some_dao])]

app = FastAPI()


@app.get("/values/{value}")
@inject
async def sqla_resource_handler_async(
value: int,
some_dao: SomeDAODependency,
) -> Dict[str, Any]:
value = some_dao.get_some_data(num=value)
return {"detail": value}


@pytest.fixture(scope="session")
def test_client() -> TestClient:
client = TestClient(app)
return client


def test_sqla_resource(test_client: TestClient) -> None:
rnd = Random()
random_int = rnd.randint(-(10**6), 10**6)

response = test_client.get(f"/values/{random_int}")

assert response.status_code == 200
assert not DIContainer.db_session.initialized
body = response.json()
assert body["detail"] == random_int
```
Loading

0 comments on commit 517b9d8

Please sign in to comment.