Skip to content

Commit

Permalink
Populate unit tests.
Browse files Browse the repository at this point in the history
  • Loading branch information
Etienne Jodry authored and Etienne Jodry committed May 13, 2024
1 parent 8f9b4b4 commit 38c6d57
Show file tree
Hide file tree
Showing 18 changed files with 371 additions and 141 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ venv*/

# Caches
__pycache__/
.pytest_cache/
.*_cache/

# Build artifacts
build/
Expand Down
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ include = ["*"] # package names should match these glob patterns (["*"] by defa
exclude = ["*example*"] # exclude packages matching these glob patterns (empty by default)
namespaces = false # to disable scanning PEP 420 namespaces (true by default)

[tool.coverage.report]
exclude_also = [
"if TYPE_CHECKING:",
"raise NotImplementedError",
"def __repr__",
]

[tool.mypy]
exclude = ["bak"]

Expand Down
1 change: 0 additions & 1 deletion src/biodm/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ def __init__(
self.config: Config = instance.get('config')
m = instance.get('manifests')
if m:
# So that it is passed as a parameter for k8 manager.
self.config.K8_MANIFESTS = m

## Logger.
Expand Down
2 changes: 1 addition & 1 deletion src/biodm/basics/k8scontroller.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def routes(self, schema=False) -> Mount:
Route("/schema", self.openapi_schema),
])
if schema:
# Mock up an individual route for each available manifest, copying it's doc.
# Mock up an individual route for each available manifest, copying doc.
r_create = m.routes[0]
mans = self.k8s.manifests
keys = [k for k in mans.__dict__.keys() if not k.startswith('__')]
Expand Down
2 changes: 1 addition & 1 deletion src/biodm/basics/rootcontroller.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ async def syn_ack(self, request):
return PlainTextResponse(token['access_token'] + '\n')

@login_required
async def authenticated(self, request, userid, groups, projects):
async def authenticated(self, _, userid, groups, projects):
"""
---
description: Route to check token validity.
Expand Down
7 changes: 4 additions & 3 deletions src/biodm/components/controllers/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,10 @@ def validate(cls, data: bytes) -> (Any | list | dict | None):
return cls.schema.loads(json_data=data)

except ValidationError as e:
raise PayloadValidationError(e) from e
raise PayloadValidationError() from e
except json.JSONDecodeError as e:
raise PayloadJSONDecodingError(e) from e
raise PayloadJSONDecodingError() from e


@classmethod
def serialize(
Expand All @@ -102,7 +103,7 @@ def serialize(
:param data: some request body
:type data: dict, class:`biodm.components.Base`, List[class:`biodm.components.Base`]
:param many: plurality flag, essential to marshmallow
:type data: bool
:type many: bool
:param only: List of fields to restrict serialization on, optional, defaults to None
:type only: List[str]
"""
Expand Down
8 changes: 3 additions & 5 deletions src/biodm/components/controllers/resourcecontroller.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from functools import partial
from typing import TYPE_CHECKING, List, Any

from marshmallow.schema import EXCLUDE
from marshmallow.schema import RAISE #EXCLUDE
from starlette.routing import Mount, Route
from starlette.requests import Request
from starlette.responses import Response
Expand Down Expand Up @@ -55,7 +55,6 @@ class ResourceController(EntityController):
Implements and exposes routes under a prefix named as the resource pluralized
that act as a standard REST-to-CRUD interface.
:param app: running server
:type app: Api
:param entity: entity name, defaults to None, inferred if None
Expand All @@ -79,8 +78,7 @@ def __init__(

self.pk = tuple(self.table.pk())
self.svc = self._infer_svc()(app=self.app, table=self.table)
# schema = schema if schema else self._infer_schema()
self.__class__.schema = (schema if schema else self._infer_schema())(unknown=EXCLUDE)
self.__class__.schema = (schema if schema else self._infer_schema())(unknown=RAISE)

def _infer_entity_name(self) -> str:
"""Infer entity name from controller name."""
Expand Down Expand Up @@ -167,7 +165,7 @@ async def _extract_body(self, request: Request) -> bytes:
:rtype: bytes
"""
body = await request.body()
if not body:
if body == b'{}':
raise PayloadEmptyError
return body

Expand Down
4 changes: 3 additions & 1 deletion src/biodm/components/services/dbservice.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ async def filter(self, query_params: dict, serializer: Callable = None, **kwargs
# exclude = True

# In case no value is associated we should be in the case of a numerical operator.
operator = None if csval else self._parse_int_operators(attr.pop())
operator = None if csval else self._parse_int_operators(attr)
# elif any(op in dskey for op in SUPPORTED_INT_OPERATORS):
# raise ValueError("'field.op()=value' type of query is not yet supported.")
stmt, (col, ctype) = self._filter_process_attr(stmt, attr)
Expand All @@ -256,6 +256,7 @@ async def filter(self, query_params: dict, serializer: Callable = None, **kwargs
op, val = operator
op = getattr(col, f"__{op}__")
stmt = stmt.where(op(ctype(val)))
continue

## Filters
# Wildcards.
Expand Down Expand Up @@ -378,6 +379,7 @@ async def _insert_composite(

# Populate main object with nested object id if matching field is found.
# TODO: hypehen the importance of that convention in the documentation.
# TODO: Find way to not have it depend on the name.
for key, sub in composite.nested.items():
attr = f"id_{key}"
if hasattr(item, attr):
Expand Down
131 changes: 68 additions & 63 deletions src/biodm/components/table.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, List
import datetime

from sqlalchemy import (
Expand All @@ -19,19 +19,27 @@ class Base(DeclarativeBase, AsyncAttrs):
"""Base class for ORM declarative Tables."""
# Enable entity - service linkage.
svc: DatabaseService
# perm: Permission

@declared_attr
def __tablename__(cls) -> str:
"""Generate tablename."""
return cls.__name__.upper()

@classmethod
def relationships(cls):
"""Return table relationships."""
return inspect(cls).mapper.relationships

@classmethod
def target_table(cls, name):
"""Returns target table of a property."""
"""Return target table of a property."""
c = cls.col(name).property
return c.target if isinstance(c, Relationship) else None

@classmethod
def pk(cls):
"""Return primary key names."""
return (
str(pk).rsplit('.', maxsplit=1)[-1]
for pk in cls.__table__.primary_key.columns
Expand All @@ -48,11 +56,6 @@ def colinfo(cls, name):
c = cls.col(name)
return c, c.type.python_type

@declared_attr
def __tablename__(cls) -> str:
"""Generate tablename."""
return cls.__name__.upper()


class S3File:
"""Class to use in order to have a file managed on S3 bucket associated to this table
Expand All @@ -77,60 +80,62 @@ def user(cls) -> Mapped["User"]:
validated_at = Column(TIMESTAMP(timezone=True))


# TODO: VERSIONNED, does the auto increment, and sets adjacency list. Use on dataset instead.

class Permission:
"""Class that produces necessary fields to declare ressource permissions for an entity.
for each action in [CREATE, READ, UPDATE, DELETE, DOWNLOAD].
"""
# def __init_subclass__(cls, **kwargs) -> None:
# """To restrict on some tables."""
# if S3File in cls.__bases__:
# cls.id_ls_download = declared_attr(cls.id_ls_download)
# cls.ls_download = declared_attr(cls.ls_download)
# super().__init_subclass__(**kwargs)

@declared_attr
def id_ls_download(_):
return Column(ForeignKey("LISTGROUP.id"), nullable=True)

@declared_attr
@classmethod
def ls_download(cls) -> Mapped["ListGroup"]:
return relationship("ListGroup", foreign_keys=[cls.id_ls_download], lazy="select")

@declared_attr
def id_ls_create(_):
return Column(ForeignKey("LISTGROUP.id"), nullable=True)

@declared_attr
@classmethod
def ls_create(cls) -> Mapped["ListGroup"]:
return relationship("ListGroup", foreign_keys=[cls.id_ls_create], lazy="select")

@declared_attr
def id_ls_read(_):
return Column(ForeignKey("LISTGROUP.id"), nullable=True)

@declared_attr
@classmethod
def ls_read(cls) -> Mapped["ListGroup"]:
return relationship("ListGroup", foreign_keys=[cls.id_ls_read], lazy="select")

@declared_attr
def id_ls_update(_):
return Column(ForeignKey("LISTGROUP.id"), nullable=True)

@declared_attr
@classmethod
def ls_update(cls) -> Mapped["ListGroup"]:
return relationship("ListGroup", foreign_keys=[cls.id_ls_update], lazy="select")

@declared_attr
def name_owner_group(_):
return Column(ForeignKey("GROUP.name"), nullable=True)

@declared_attr
@classmethod
def owner_group(cls) -> Mapped["Group"]:
return relationship(foreign_keys=[cls.name_owner_group], lazy="select")
def __init__(self, propagates_to: List[Base]) -> None:
pass

""""""
"""Class that produces necessary fields to declare ressource permissions for an entity.
for each action in [CREATE, READ, UPDATE, DELETE, DOWNLOAD].
"""
# def __init_subclass__(cls, **kwargs) -> None:
# """To restrict on some tables."""
# if S3File in cls.__bases__:
# cls.id_ls_download = declared_attr(cls.id_ls_download)
# cls.ls_download = declared_attr(cls.ls_download)
# super().__init_subclass__(**kwargs)

# @declared_attr
# def id_ls_download(_):
# return Column(ForeignKey("LISTGROUP.id"), nullable=True)

# @declared_attr
# @classmethod
# def ls_download(cls) -> Mapped["ListGroup"]:
# return relationship("ListGroup", foreign_keys=[cls.id_ls_download], lazy="select")

# @declared_attr
# def id_ls_create(_):
# return Column(ForeignKey("LISTGROUP.id"), nullable=True)

# @declared_attr
# @classmethod
# def ls_create(cls) -> Mapped["ListGroup"]:
# return relationship("ListGroup", foreign_keys=[cls.id_ls_create], lazy="select")

# @declared_attr
# def id_ls_read(_):
# return Column(ForeignKey("LISTGROUP.id"), nullable=True)

# @declared_attr
# @classmethod
# def ls_read(cls) -> Mapped["ListGroup"]:
# return relationship("ListGroup", foreign_keys=[cls.id_ls_read], lazy="select")

# @declared_attr
# def id_ls_update(_):
# return Column(ForeignKey("LISTGROUP.id"), nullable=True)

# @declared_attr
# @classmethod
# def ls_update(cls) -> Mapped["ListGroup"]:
# return relationship("ListGroup", foreign_keys=[cls.id_ls_update], lazy="select")

# @declared_attr
# def name_owner_group(_):
# return Column(ForeignKey("GROUP.name"), nullable=True)

# @declared_attr
# @classmethod
# def owner_group(cls) -> Mapped["Group"]:
# return relationship(foreign_keys=[cls.name_owner_group], lazy="select")
16 changes: 11 additions & 5 deletions src/biodm/managers/dbmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def __init__(self, app: Api):
expire_on_commit=False,
)
except SQLAlchemyError as e:
raise PostgresUnavailableError(f"Failed to connect to Postgres: {e}") from e
raise PostgresUnavailableError(f"Failed to connect to DB") from e

@staticmethod
def async_database_url(url) -> str:
Expand Down Expand Up @@ -98,25 +98,31 @@ async def wrapper(*args, **kwargs):
""" Applies a bit of arguments manipulation whose goal is to maximize
convenience of use of the decorator by allowing explicing or implicit
argument calling.
Relevant doc: https://docs.python.org/3/library/inspect.html#inspect.Signature.bind
Then produces and passes down a session if needed.
Finally after the function returns, serialization is applied if needed.
Doc:
- https://docs.python.org/3/library/inspect.html#inspect.Signature.bind
"""
serializer = kwargs.pop('serializer', None)

bound_args = signature(db_exec).bind_partial(*args, **kwargs)
bound_args.apply_defaults()
bound_args = bound_args.arguments
if bound_args.get('kwargs') == {}:
# Else it will get passed around.
bound_args.pop('kwargs')

svc: DatabaseService = bound_args['self']
session = bound_args.get('session', None)

async with AsyncExitStack() as stack:
# Produce session
# Ensure session.
bound_args['session'] = (
session if session
else await stack.enter_async_context(svc.app.db.session())
)

# Call and serialize result if requested.
db_res = await db_exec(**bound_args)
return await bound_args['session'].run_sync(
lambda _, data: serializer(data), db_res
Expand Down
20 changes: 16 additions & 4 deletions src/biodm/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import relationship
from starlette.config import Config
from starlette.testclient import TestClient

from biodm.api import Api
from biodm.components import Base
Expand All @@ -28,7 +29,7 @@ class A(Base):
id_c = sa.Column(sa.ForeignKey("C.id"))

bs: Mapped[List["B"]] = relationship(secondary=asso_a_b, uselist=True, lazy="select")
ac: Mapped["C"] = relationship(foreign_keys=[id_c], backref="ca", lazy="select")
c: Mapped["C"] = relationship(foreign_keys=[id_c], backref="ca", lazy="select")


class B(Base):
Expand All @@ -48,7 +49,8 @@ class ASchema(ma.Schema):
y = ma.fields.Integer()
id_c = ma.fields.Integer()

bs = ma.fields.List(ma.fields.Nested("B"))
bs = ma.fields.List(ma.fields.Nested("BSchema"))
c = ma.fields.Nested("CSchema")


class BSchema(ma.Schema):
Expand All @@ -60,6 +62,8 @@ class CSchema(ma.Schema):
id = ma.fields.Integer()
data = ma.fields.String(required=True)

ca = ma.fields.Nested("ASchema")


## Api componenents.
class AController(ResourceController):
Expand Down Expand Up @@ -132,8 +136,16 @@ def __init__(self, app):


@pytest.fixture()
def client_args() -> dict:
return {"app": app, 'backend_options': {"use_uvloop": True}}
def client():
with TestClient(**
{
"app": app,
'backend_options': {
"use_uvloop": True
}
}
) as c:
yield c


def json_bytes(d):
Expand Down
Loading

0 comments on commit 38c6d57

Please sign in to comment.