perfact-api-base 0.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ name: Tox tests
2
+ on:
3
+ push:
4
+ branches:
5
+ - 'main'
6
+ pull_request: {}
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - name: Set up Python ${{ matrix.python-version }}
18
+ uses: actions/setup-python@v5
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+ - name: Install dependencies
22
+ run: |
23
+ python -m pip install --upgrade pip
24
+ python -m pip install tox tox-gh-actions
25
+ - name: Test with tox
26
+ run: tox
@@ -0,0 +1,31 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "[0-9]*"
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ with:
14
+ fetch-depth: 0
15
+
16
+ - name: Set up Python
17
+ uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.x"
20
+
21
+ - name: Install build dependencies
22
+ run: python -m pip install --upgrade build twine
23
+
24
+ - name: Build package
25
+ run: python -m build
26
+
27
+ - name: Publish to PyPI
28
+ env:
29
+ TWINE_USERNAME: __token__
30
+ TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
31
+ run: twine upload dist/*
@@ -0,0 +1,7 @@
1
+ tags*
2
+ __pycache__
3
+ *.egg-info
4
+ .coverage
5
+ .tox
6
+ build
7
+ .claude
@@ -0,0 +1,6 @@
1
+ {
2
+ "[python]": {
3
+ "editor.defaultFormatter": "charliermarsh.ruff",
4
+ "editor.formatOnSave": true
5
+ }
6
+ }
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: perfact-api-base
3
+ Version: 0.6
4
+ Summary: PerFact API - base package for common infrastructure
5
+ Author-email: Viktor Dick <viktor.dick@perfact.de>
6
+ License: GPL-2.0-or-later
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: SQL
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: psycopg[binary]
13
+ Requires-Dist: sqlalchemy
14
+ Requires-Dist: pydantic-settings
15
+
16
+ # perfact-api-base
17
+
18
+ Base package for PerFact API common infrastructure. Part of the `perfact.api.base` namespace.
19
+
20
+ ## What it provides
21
+
22
+ ### `Base`
23
+
24
+ Declarative base for all ORM table mappings. Subclasses automatically get:
25
+
26
+ - `id` — `BigInteger` primary key
27
+ - `modtime` — `DateTime` with timezone, defaults to `now()`
28
+ - `author` — set from the DB session user via `db_username()`
29
+ - Column names are automatically prefixed with the table name (e.g. a field `name` on `AppUser` becomes the DB column `appuser_name`)
30
+ - `__tablename__` is derived from the class name in lowercase if not set explicitly
31
+
32
+ ### `View`
33
+
34
+ Separate declarative base for SQL view definitions. Views are not created as tables during schema generation. Columns must be declared with explicit names matching the view definition.
35
+
36
+ ### Re-exports
37
+
38
+ `ForeignKey`, `relationship`, `Mapped`, `mapped_column` are re-exported so model packages only need to import from this module.
39
+
40
+ ## Usage
41
+
42
+ ```python
43
+ from perfact.api.base.model import Base, ForeignKey, Mapped, mapped_column, relationship
44
+
45
+ class MyEntity(Base):
46
+ name: Mapped[str]
47
+ parent_id: Mapped[int | None] = mapped_column(ForeignKey("myentity.myentity_id"))
48
+ ```
49
+
50
+ ## Dependencies
51
+
52
+ - `sqlalchemy`
53
+ - `psycopg[c]`
54
+ - `pydantic-settings`
55
+
56
+ ## Maintainers
57
+
58
+ - Viktor Dick <viktor.dick@perfact.de>
59
+ - Alexander Rolfes <alexander.rolfes@perfact.de>
@@ -0,0 +1,44 @@
1
+ # perfact-api-base
2
+
3
+ Base package for PerFact API common infrastructure. Part of the `perfact.api.base` namespace.
4
+
5
+ ## What it provides
6
+
7
+ ### `Base`
8
+
9
+ Declarative base for all ORM table mappings. Subclasses automatically get:
10
+
11
+ - `id` — `BigInteger` primary key
12
+ - `modtime` — `DateTime` with timezone, defaults to `now()`
13
+ - `author` — set from the DB session user via `db_username()`
14
+ - Column names are automatically prefixed with the table name (e.g. a field `name` on `AppUser` becomes the DB column `appuser_name`)
15
+ - `__tablename__` is derived from the class name in lowercase if not set explicitly
16
+
17
+ ### `View`
18
+
19
+ Separate declarative base for SQL view definitions. Views are not created as tables during schema generation. Columns must be declared with explicit names matching the view definition.
20
+
21
+ ### Re-exports
22
+
23
+ `ForeignKey`, `relationship`, `Mapped`, `mapped_column` are re-exported so model packages only need to import from this module.
24
+
25
+ ## Usage
26
+
27
+ ```python
28
+ from perfact.api.base.model import Base, ForeignKey, Mapped, mapped_column, relationship
29
+
30
+ class MyEntity(Base):
31
+ name: Mapped[str]
32
+ parent_id: Mapped[int | None] = mapped_column(ForeignKey("myentity.myentity_id"))
33
+ ```
34
+
35
+ ## Dependencies
36
+
37
+ - `sqlalchemy`
38
+ - `psycopg[c]`
39
+ - `pydantic-settings`
40
+
41
+ ## Maintainers
42
+
43
+ - Viktor Dick <viktor.dick@perfact.de>
44
+ - Alexander Rolfes <alexander.rolfes@perfact.de>
@@ -0,0 +1,2 @@
1
+ assert_used:
2
+ skips: ["*/test_*.py"]
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.2", "setuptools-scm>=8.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "perfact-api-base"
7
+ authors = [
8
+ {name="Viktor Dick", email="viktor.dick@perfact.de"},
9
+ ]
10
+ description = "PerFact API - base package for common infrastructure"
11
+ readme = "README.md"
12
+ license = {text = "GPL-2.0-or-later"}
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: SQL",
16
+ "Operating System :: POSIX :: Linux",
17
+ ]
18
+ dependencies = [
19
+ "psycopg[binary]",
20
+ "sqlalchemy",
21
+ "pydantic-settings",
22
+ ]
23
+ dynamic = ["version"]
24
+ requires-python = ">=3.10"
25
+
26
+ [project.scripts]
27
+
28
+ [tool.distutils.bdist_wheel]
29
+ universal = 1
30
+
31
+ [tool.setuptools]
32
+ include-package-data = true
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["src"]
36
+
37
+ [tool.setuptools_scm]
38
+
39
+ [tool.ruff]
40
+ [tool.ruff.lint]
41
+ select = ["E", "F", "W", "I"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,17 @@
1
+ from .authinfo import AuthInfo
2
+ from .model import Base, PydanticBase, View
3
+ from .visibility import (
4
+ VisibilityAwareModel,
5
+ VisibilityPolicy,
6
+ VisibilityPolicyRegistry,
7
+ )
8
+
9
+ __all__ = [
10
+ "AuthInfo",
11
+ "Base",
12
+ "PydanticBase",
13
+ "View",
14
+ "VisibilityAwareModel",
15
+ "VisibilityPolicy",
16
+ "VisibilityPolicyRegistry",
17
+ ]
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class AuthInfo:
9
+ name: Optional[str]
10
+ roles: list[str]
11
+ appstc: Optional[int]
12
+
13
+ @staticmethod
14
+ def get_unauthorized_authinfo() -> AuthInfo:
15
+ """
16
+ Returns a predefined AuthInfo for unauthorized requests.
17
+ """
18
+ return AuthInfo(None, [], None)
@@ -0,0 +1,65 @@
1
+ from datetime import datetime
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+ from sqlalchemy import BigInteger, DateTime, ForeignKey, String, func
5
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
6
+
7
+ # Re-export for easier use
8
+ ForeignKey = ForeignKey
9
+ relationship = relationship
10
+
11
+
12
+ class Base(DeclarativeBase):
13
+ """
14
+ Base class for table mappings. Allows declaring fields without their table prefix,
15
+ the subclass hook will automatically rewrite them.
16
+ """
17
+
18
+ type_annotation_map = {str: String(), int: BigInteger()}
19
+
20
+ id: Mapped[int] = mapped_column(primary_key=True)
21
+ modtime: Mapped[datetime] = mapped_column(
22
+ DateTime(timezone=True), server_default=func.now()
23
+ )
24
+ author: Mapped[str | None] = mapped_column(server_default=func.db_username())
25
+
26
+ def __init_subclass__(cls, **kw):
27
+ """
28
+ Automatically add __tablename__ and prefix mapped columns with table name
29
+ """
30
+ # 1. Ensure tablename exists before mapping
31
+ if "__tablename__" not in cls.__dict__:
32
+ cls.__tablename__ = cls.__name__.lower()
33
+
34
+ # 2. Let SQLAlchemy build the mapper, table, columns, etc.
35
+ super().__init_subclass__(**kw)
36
+
37
+ # 3. Now we have cls.__table__ and real Column objects. Adjust these so they
38
+ # map to the prefixed column names on the database
39
+ if hasattr(cls, "__table__"):
40
+ prefix = cls.__tablename__ + "_"
41
+
42
+ for col in cls.__table__.columns:
43
+ # Only touch columns that still use their key as name
44
+ # (i.e., no explicit name was given)
45
+ if col.name == col.key:
46
+ new_name = prefix + col.key
47
+ col.name = new_name
48
+ col.key = new_name # keep ORM key in sync
49
+
50
+
51
+ class View(DeclarativeBase):
52
+ """
53
+ Separate base for view definitions, so they are not created as tables but
54
+ can be used like tables
55
+ """
56
+
57
+ type_annotation_map = {str: String(), int: BigInteger()}
58
+
59
+
60
+ class PydanticBase(BaseModel):
61
+ model_config = ConfigDict(from_attributes=True)
62
+
63
+ id: int
64
+ author: str | None
65
+ modtime: datetime
File without changes
@@ -0,0 +1,37 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import ClassVar
3
+
4
+ from sqlalchemy.sql.elements import ColumnElement
5
+
6
+ from .authinfo import AuthInfo
7
+
8
+
9
+ class VisibilityAwareModel:
10
+ """Marker mixin; signals that a model has an associated VisibilityPolicy."""
11
+
12
+
13
+ class VisibilityPolicy(ABC):
14
+ """Row-level visibility rule; subclasses must set model and implement filter()."""
15
+
16
+ model: ClassVar[type]
17
+
18
+ @abstractmethod
19
+ def filter(self, auth: AuthInfo) -> ColumnElement[bool]: ...
20
+
21
+
22
+ class VisibilityPolicyRegistry:
23
+ """Model-to-policy map; register() skips if already set, override() always wins."""
24
+
25
+ def __init__(self) -> None:
26
+ self._policies: dict[type, VisibilityPolicy] = {}
27
+
28
+ def register(self, model: type, policy_cls: type[VisibilityPolicy]) -> None:
29
+ if model not in self._policies:
30
+ self._policies[model] = policy_cls()
31
+
32
+ def override(self, model: type, policy_cls: type[VisibilityPolicy]) -> None:
33
+ self._policies[model] = policy_cls()
34
+
35
+ def get(self, model: type) -> VisibilityPolicy:
36
+ """Raises KeyError if no policy is registered for model."""
37
+ return self._policies[model]
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: perfact-api-base
3
+ Version: 0.6
4
+ Summary: PerFact API - base package for common infrastructure
5
+ Author-email: Viktor Dick <viktor.dick@perfact.de>
6
+ License: GPL-2.0-or-later
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: SQL
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: psycopg[binary]
13
+ Requires-Dist: sqlalchemy
14
+ Requires-Dist: pydantic-settings
15
+
16
+ # perfact-api-base
17
+
18
+ Base package for PerFact API common infrastructure. Part of the `perfact.api.base` namespace.
19
+
20
+ ## What it provides
21
+
22
+ ### `Base`
23
+
24
+ Declarative base for all ORM table mappings. Subclasses automatically get:
25
+
26
+ - `id` — `BigInteger` primary key
27
+ - `modtime` — `DateTime` with timezone, defaults to `now()`
28
+ - `author` — set from the DB session user via `db_username()`
29
+ - Column names are automatically prefixed with the table name (e.g. a field `name` on `AppUser` becomes the DB column `appuser_name`)
30
+ - `__tablename__` is derived from the class name in lowercase if not set explicitly
31
+
32
+ ### `View`
33
+
34
+ Separate declarative base for SQL view definitions. Views are not created as tables during schema generation. Columns must be declared with explicit names matching the view definition.
35
+
36
+ ### Re-exports
37
+
38
+ `ForeignKey`, `relationship`, `Mapped`, `mapped_column` are re-exported so model packages only need to import from this module.
39
+
40
+ ## Usage
41
+
42
+ ```python
43
+ from perfact.api.base.model import Base, ForeignKey, Mapped, mapped_column, relationship
44
+
45
+ class MyEntity(Base):
46
+ name: Mapped[str]
47
+ parent_id: Mapped[int | None] = mapped_column(ForeignKey("myentity.myentity_id"))
48
+ ```
49
+
50
+ ## Dependencies
51
+
52
+ - `sqlalchemy`
53
+ - `psycopg[c]`
54
+ - `pydantic-settings`
55
+
56
+ ## Maintainers
57
+
58
+ - Viktor Dick <viktor.dick@perfact.de>
59
+ - Alexander Rolfes <alexander.rolfes@perfact.de>
@@ -0,0 +1,19 @@
1
+ .gitignore
2
+ README.md
3
+ bandit.yml
4
+ pyproject.toml
5
+ tox.ini
6
+ .gitea/workflows/check.yml
7
+ .gitea/workflows/publish.yml
8
+ .vscode/settings.json
9
+ src/perfact/api/base/__init__.py
10
+ src/perfact/api/base/authinfo.py
11
+ src/perfact/api/base/model.py
12
+ src/perfact/api/base/py.typed
13
+ src/perfact/api/base/visibility.py
14
+ src/perfact_api_base.egg-info/PKG-INFO
15
+ src/perfact_api_base.egg-info/SOURCES.txt
16
+ src/perfact_api_base.egg-info/dependency_links.txt
17
+ src/perfact_api_base.egg-info/requires.txt
18
+ src/perfact_api_base.egg-info/top_level.txt
19
+ tests/test_visibility_policy.py
@@ -0,0 +1,3 @@
1
+ psycopg[binary]
2
+ sqlalchemy
3
+ pydantic-settings
@@ -0,0 +1,86 @@
1
+ import pytest
2
+ from sqlalchemy.orm import Mapped
3
+ from sqlalchemy.sql.elements import ColumnElement
4
+
5
+ from perfact.api.base import (
6
+ AuthInfo,
7
+ VisibilityAwareModel,
8
+ VisibilityPolicy,
9
+ VisibilityPolicyRegistry,
10
+ )
11
+ from perfact.api.base.model import Base
12
+
13
+
14
+ class ModelA(Base, VisibilityAwareModel):
15
+ __tablename__ = "model_a"
16
+ name: Mapped[str]
17
+
18
+
19
+ class ModelB(Base, VisibilityAwareModel):
20
+ __tablename__ = "model_b"
21
+ name: Mapped[str]
22
+
23
+
24
+ class PolicyForA(VisibilityPolicy):
25
+ model = ModelA
26
+
27
+ def filter(self, auth: AuthInfo) -> ColumnElement[bool]:
28
+ return ModelA.id > 0
29
+
30
+
31
+ class PolicyForAOverride(VisibilityPolicy):
32
+ model = ModelA
33
+
34
+ def filter(self, auth: AuthInfo) -> ColumnElement[bool]:
35
+ return ModelA.id > 1
36
+
37
+
38
+ def test_register_sets_policy() -> None:
39
+ registry = VisibilityPolicyRegistry()
40
+ registry.register(ModelA, PolicyForA)
41
+ assert isinstance(registry.get(ModelA), PolicyForA)
42
+
43
+
44
+ def test_register_does_not_overwrite_existing() -> None:
45
+ registry = VisibilityPolicyRegistry()
46
+ registry.register(ModelA, PolicyForA)
47
+ registry.register(ModelA, PolicyForAOverride)
48
+ assert isinstance(registry.get(ModelA), PolicyForA)
49
+
50
+
51
+ def test_override_sets_policy() -> None:
52
+ registry = VisibilityPolicyRegistry()
53
+ registry.override(ModelA, PolicyForA)
54
+ assert isinstance(registry.get(ModelA), PolicyForA)
55
+
56
+
57
+ def test_override_replaces_existing() -> None:
58
+ registry = VisibilityPolicyRegistry()
59
+ registry.register(ModelA, PolicyForA)
60
+ registry.override(ModelA, PolicyForAOverride)
61
+ assert isinstance(registry.get(ModelA), PolicyForAOverride)
62
+
63
+
64
+ def test_second_override_wins() -> None:
65
+ registry = VisibilityPolicyRegistry()
66
+ registry.override(ModelA, PolicyForA)
67
+ registry.override(ModelA, PolicyForAOverride)
68
+ assert isinstance(registry.get(ModelA), PolicyForAOverride)
69
+
70
+
71
+ def test_registry_get_raises_for_unknown_model() -> None:
72
+ registry = VisibilityPolicyRegistry()
73
+ with pytest.raises(KeyError):
74
+ registry.get(ModelB)
75
+
76
+
77
+ def test_auth_info_dataclass() -> None:
78
+ auth = AuthInfo(name="alice", roles=["Admin"], appstc=42)
79
+ assert auth.name == "alice"
80
+ assert auth.roles == ["Admin"]
81
+ assert auth.appstc == 42
82
+
83
+
84
+ def test_auth_info_optional_appstc() -> None:
85
+ auth = AuthInfo(name="bob", roles=[], appstc=None)
86
+ assert auth.appstc is None
@@ -0,0 +1,24 @@
1
+ [tox]
2
+ envlist = py3
3
+ isolated_build = true
4
+
5
+ [pytest]
6
+
7
+ [testenv]
8
+ deps =
9
+ ruff
10
+ pytest
11
+ coverage
12
+ psycopg[binary]
13
+ pytest-postgresql
14
+ pytest-cov
15
+ pytest-typing
16
+ bandit
17
+ mypy
18
+
19
+ commands =
20
+ ruff format --check
21
+ ruff check
22
+ bandit --configfile bandit.yml -r src
23
+ mypy src
24
+ # pytest --doctest-modules --cov-branch --cov=src --cov-report=term-missing {posargs:src}