Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1f3b71c
refactor: Add 'typings' directory to .gitignore
AriasBros Jul 23, 2026
050ceac
feat: Allow to pass parameters when resolving a dependency
AriasBros Jul 23, 2026
cd3a5a7
feat: Implement tablename generation for intermediate tables
AriasBros Jul 23, 2026
ec03a9c
fix: Allow coverage report upload to proceed without failing on errors
AriasBros Jul 24, 2026
7b1908a
fix: Correct report-type to report_type in Codecov configuration
AriasBros Jul 24, 2026
a284330
feat: Allow "all" method to call from the beginning
AriasBros Jul 24, 2026
9ec1caf
refactor: Improve error messages for required 'meta' attribute in Res…
AriasBros Jul 24, 2026
ed0ccea
feat: Consolidate filter methods in AbstractQueryBuilder
AriasBros Jul 24, 2026
34f9a2e
feat: Allow "all" method to call from the beginning
AriasBros Jul 24, 2026
31df20f
fix: Correct spelling of 'repository' in imports across multiple files
AriasBros Jul 24, 2026
461c104
fix: Add app base path to the migration path
AriasBros Jul 25, 2026
a64eb70
refactor: Rename some ORM interfaces and update database provider to …
AriasBros Jul 25, 2026
0ac6cec
tests: Add new tests for the SQLAlchemy Schema implementation
AriasBros Jul 25, 2026
db2f8d6
chore: Bump version to 0.5.7
AriasBros Jul 25, 2026
56d326e
fix: Remove implementation from overload method
AriasBros Jul 25, 2026
e3065d2
refactor: Remove debug print statements from test files
AriasBros Jul 25, 2026
26ac3bc
feat: Add to_kebab_case function
AriasBros Jul 26, 2026
9f193e9
tests: Add more tests for the ORM implemented with SQLAlchemy
AriasBros Jul 26, 2026
0aa18bb
tests: Update log_on_exception to use a driver that raises exceptions
AriasBros Jul 26, 2026
ce93f40
Merge branch 'main' into dev
AriasBros Jul 26, 2026
3b0933e
fix: Update call method in ContainerInterface to accept additional ar…
AriasBros Jul 26, 2026
47fe8bf
fix: Update relationship definitions in BarModel for clarity and corr…
AriasBros Jul 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion pyrannic/contracts/container/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,12 @@ async def resolve(
"""Resolve the given type from the container."""

@abstractmethod
async def call(self, callback: type[T] | Callable[..., Any]) -> T:
async def call(
self,
callback: type[T] | Callable[..., Any],
*args: Any,
**kwargs: Any,
) -> T:
"""Call the given callback (Closure, class@method...) and inject its dependencies."""

@abstractmethod
Expand Down
1 change: 1 addition & 0 deletions pyrannic/orm/sqlalchemy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from .async_repository import AsyncRepository as AsyncRepository
from .connector import AsyncConnector as AsyncConnector
from .connector import Connector as Connector
from .mixins.has_timestamps import HasTimestamp as HasTimestamp
from .mixins.has_timestamps import HasTimestamps as HasTimestamps
from .mixins.soft_deletes import SoftDeletes as SoftDeletes
from .model import Model as Model
Expand Down
10 changes: 5 additions & 5 deletions pyrannic/orm/sqlalchemy/abstract_query_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,12 @@ def filter(self, *filters: ColumnExpressionArgument[Any]) -> Self:

@overload
def filter(self, **kwargs: Any) -> Self:
self._prepare_query()

if isinstance(self._query, (Select, Delete)):
self._query = self._query.filter_by(**kwargs)
"""
Apply filtering conditions to the query using keyword arguments.

return self
:param kwargs: Column-value pairs for filtering.
:return: The current instance of the query builder.
"""

Comment on lines 146 to 154
def filter(self, *filters: ColumnExpressionArgument[Any], **kwargs: Any) -> Self:
self._prepare_query()
Expand Down
7 changes: 2 additions & 5 deletions pyrannic/orm/sqlalchemy/serializable.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,15 @@ def to_dict(
:return: dict
"""
result = dict[str, Any]()
columns: list[str] = self.columns + self.properties # pyright: ignore[reportUnknownMemberType]
columns: list[str] = self.columns + self.properties

if exclude is None:
view_cols = columns
else:
view_cols = filter(lambda e: e not in exclude, columns)

for key in view_cols:
try:
result[key] = getattr(self, key)
except Exception as e:
print(key, e)
result[key] = getattr(self, key)

if hybrid_attributes:
for key in self.hybrid_properties:
Expand Down
12 changes: 12 additions & 0 deletions pyrannic/support/string.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
import re


def to_kebab_case(value: str) -> str:
"""Convert a string to kebab case."""

value = (
re.sub(r"(?<=[a-z])(?=[A-Z])|[^a-zA-Z0-9]", " ", value)
.strip()
.replace(" ", "-")
)

return "".join(value.lower())


def to_snake_case(value: str) -> str:
"""Convert a string to snake case."""

Expand Down
2 changes: 0 additions & 2 deletions tests/application/app/http/middlewares/middleware_a.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,5 @@

class A_Middleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Callable[..., Any]) -> Any:
print("A_Middleware: Before request")
response = await call_next(request)
print("A_Middleware: After request")
return response
2 changes: 0 additions & 2 deletions tests/application/app/http/middlewares/middleware_b.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,5 @@

class B_Middleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Callable[..., Any]) -> Any:
print("B_Middleware: Before request")
response = await call_next(request)
print("B_Middleware: After request")
return response
7 changes: 0 additions & 7 deletions tests/application/app/http/routers/heroes.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,6 @@ def index(
repository: HeroesRepository = Depends(),
# repository: Scoped[Repository[HeroModel]],
) -> HeroesCollection:
print(
"Container in index endpoint",
repository,
# repository2,
# foo.get_app_name(),
# bar.foo.get_app_name(),
)
return HeroesCollection(repository.where(HeroModel.name.like("%man%")).paginate())


Expand Down
2 changes: 0 additions & 2 deletions tests/unit/container/container/test_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ async def test_make_with_interface_not_bound(container: ContainerInterface):
await container.make(FooInterface)

error = str(exc_info.value)
print(error)
assert "No binding found for interface FooInterface" in error


Expand All @@ -81,5 +80,4 @@ async def test_make_with_key_not_bound(container: ContainerInterface):
await container.make("FooInterface")

error = str(exc_info.value)
print(error)
assert "No binding found for key FooInterface" in error
2 changes: 0 additions & 2 deletions tests/unit/container/container/test_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ async def test_resolve_with_interface_not_bound(container: ContainerInterface):
await container.resolve(FooInterface)

error = str(exc_info.value)
print(error)
assert "No binding found for interface FooInterface" in error


Expand All @@ -81,5 +80,4 @@ async def test_resolve_with_key_not_bound(container: ContainerInterface):
await container.resolve("FooInterface")

error = str(exc_info.value)
print(error)
assert "No binding found for key FooInterface" in error
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,5 @@

class A_Middleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Callable[..., Any]) -> Any:
print("A_Middleware: Before request")
response = await call_next(request)
print("A_Middleware: After request")
return response
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,5 @@

class B_Middleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Callable[..., Any]) -> Any:
print("B_Middleware: Before request")
response = await call_next(request)
print("B_Middleware: After request")
return response
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
from fastapi import APIRouter, Depends

from pyrannic import ResourceNotFoundException
from pyrannic.ioc import App, Container, Resolve
from pyrannic.contracts import ContainerInterface
from pyrannic.ioc import App, Container, Resolve
from tests.application.app.http.resources.hero import Hero, HeroesCollection
from tests.application.app.models.hero import Hero as HeroModel
from tests.application.app.repositories.heroes import HeroesRepository
Expand All @@ -28,11 +28,6 @@ def index(
bar: Resolve[BarService],
repository2: HeroesRepository = Depends(),
) -> HeroesCollection:
print(
"Container in index endpoint",
foo.get_app_name(),
bar.foo.get_app_name(),
)
return HeroesCollection(
repository2.where(HeroModel.name.like("%batman%")).paginate()
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,5 @@

class AMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Callable[..., Any]) -> Any:
print("A_Middleware: Before request")
response = await call_next(request)
print("A_Middleware: After request")
return response
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,5 @@

class BMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Callable[..., Any]) -> Any:
print("B_Middleware: Before request")
response = await call_next(request)
print("B_Middleware: After request")
return response
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
from fastapi import APIRouter, Depends

from pyrannic import ResourceNotFoundException
from pyrannic.ioc import App, Container, Resolve
from pyrannic.contracts import ContainerInterface
from pyrannic.ioc import App, Container, Resolve
from tests.application.app.http.resources.hero import Hero, HeroesCollection
from tests.application.app.models.hero import Hero as HeroModel
from tests.application.app.repositories.heroes import HeroesRepository
Expand All @@ -28,11 +28,6 @@ def index(
bar: Resolve[BarService],
repository2: HeroesRepository = Depends(),
) -> HeroesCollection:
print(
"Container in index endpoint",
foo.get_app_name(),
bar.foo.get_app_name(),
)
return HeroesCollection(
repository2.where(HeroModel.name.like("%batman%")).paginate()
)
Expand Down
3 changes: 0 additions & 3 deletions tests/unit/http/resources/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,6 @@ class FooResourceWithRelationships(Resource, HasTimestamps, SoftDeletes):

@classmethod
def _relationships(cls, model: SerializableInterface) -> dict[str, Any]:

print("_relationships called with model:", getattr(model, "children", []))

return {
"children": [
FooResourceWithRelationships.from_model(child)
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/orm/sqlalchemy/mixins/test_sa_has_timestamp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from datetime import datetime, timezone

from pyrannic.support.datetime import get_current_utc_datetime
from tests.unit.orm.sqlalchemy.utils import HasTimestampModel


def test_initial_created_at() -> None:
created_at = get_current_utc_datetime()
model = HasTimestampModel()

assert model.created_at.tzinfo == timezone.utc
assert abs((model.created_at - created_at).total_seconds()) < 1


def test_set_created_at() -> None:
model = HasTimestampModel()
assert model.created_at is not None

new_created_at = datetime(2024, 1, 1, 12, 0, 0)
model.set_created_at(new_created_at)
assert model.created_at == new_created_at
38 changes: 38 additions & 0 deletions tests/unit/orm/sqlalchemy/mixins/test_sa_has_timestamps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from datetime import datetime, timezone

from pyrannic.support.datetime import get_current_utc_datetime
from tests.unit.orm.sqlalchemy.utils import HasTimestampsModel


def test_initial_created_at() -> None:
created_at = get_current_utc_datetime()
model = HasTimestampsModel()

assert model.created_at.tzinfo == timezone.utc
assert abs((model.created_at - created_at).total_seconds()) < 1


def test_set_created_at() -> None:
model = HasTimestampsModel()
assert model.created_at is not None

new_created_at = datetime(2024, 1, 1, 12, 0, 0)
model.set_created_at(new_created_at)
assert model.created_at == new_created_at


def test_initial_updated_at() -> None:
updated_at = get_current_utc_datetime()
model = HasTimestampsModel()

assert model.updated_at.tzinfo == timezone.utc
assert abs((model.updated_at - updated_at).total_seconds()) < 1


def test_set_updated_at() -> None:
model = HasTimestampsModel()
assert model.updated_at is not None

new_updated_at = datetime(2024, 1, 1, 12, 0, 0)
model.set_updated_at(new_updated_at)
assert model.updated_at == new_updated_at
29 changes: 29 additions & 0 deletions tests/unit/orm/sqlalchemy/mixins/test_sa_soft_deletes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from datetime import datetime

from tests.unit.orm.sqlalchemy.utils import SoftDeletesModel


def test_initial_deleted_at() -> None:
model = SoftDeletesModel()
assert model.deleted_at is None


def test_set_deleted_at() -> None:
model = SoftDeletesModel()
assert model.deleted_at is None

new_deleted_at = datetime(2024, 1, 1, 12, 0, 0)
model.set_deleted_at(new_deleted_at)
assert model.deleted_at == new_deleted_at


def test_is_deleted_property() -> None:
model = SoftDeletesModel()
assert model.is_deleted is False

model.set_deleted_at(datetime(2024, 1, 1, 12, 0, 0))
assert model.is_deleted is True


def test_deleted_at_column_name() -> None:
assert SoftDeletesModel.deleted_at_column() == "deleted_at"
49 changes: 49 additions & 0 deletions tests/unit/orm/sqlalchemy/test_sa_connector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from logging import Logger
from unittest.mock import Mock

import pytest
from pytest import MonkeyPatch
from sqlalchemy.ext.asyncio import AsyncEngine

from pyrannic.contracts import ApplicationInterface
from pyrannic.orm.sqlalchemy import AsyncConnector, Connector


@pytest.mark.asyncio
async def test_connector_disconnect(
application: ApplicationInterface,
monkeypatch: MonkeyPatch,
) -> None:
container = application.container
connector = Connector(
application,
await container.resolve(Logger),
await container.resolve("config"),
)

mock = Mock()

monkeypatch.setattr(connector, "_engine", mock)

await connector.disconnect()
mock.dispose.assert_called_once()


@pytest.mark.asyncio
async def test_async_connector_disconnect(
application: ApplicationInterface,
monkeypatch: MonkeyPatch,
) -> None:
container = application.container
connector = AsyncConnector(
application,
await container.resolve(Logger),
await container.resolve("config"),
)

mock = Mock(spec=AsyncEngine)

monkeypatch.setattr(connector, "_engine", mock)

await connector.disconnect()
mock.dispose.assert_called_once()
Loading