perfact-api-base 0.6__tar.gz → 0.7__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.
Files changed (27) hide show
  1. perfact_api_base-0.7/.gitea/workflows/check.yml +17 -0
  2. {perfact_api_base-0.6 → perfact_api_base-0.7}/PKG-INFO +1 -1
  3. {perfact_api_base-0.6 → perfact_api_base-0.7}/pyproject.toml +1 -1
  4. {perfact_api_base-0.6 → perfact_api_base-0.7}/src/perfact/api/base/__init__.py +2 -0
  5. perfact_api_base-0.7/src/perfact/api/base/discovery.py +33 -0
  6. {perfact_api_base-0.6 → perfact_api_base-0.7}/src/perfact_api_base.egg-info/PKG-INFO +1 -1
  7. {perfact_api_base-0.6 → perfact_api_base-0.7}/src/perfact_api_base.egg-info/SOURCES.txt +5 -0
  8. perfact_api_base-0.7/tests/conftest.py +61 -0
  9. perfact_api_base-0.7/tests/test_authinfo.py +8 -0
  10. perfact_api_base-0.7/tests/test_discovery.py +55 -0
  11. perfact_api_base-0.7/tests/test_model.py +276 -0
  12. {perfact_api_base-0.6 → perfact_api_base-0.7}/tox.ini +2 -3
  13. perfact_api_base-0.6/.gitea/workflows/check.yml +0 -26
  14. {perfact_api_base-0.6 → perfact_api_base-0.7}/.gitea/workflows/publish.yml +0 -0
  15. {perfact_api_base-0.6 → perfact_api_base-0.7}/.gitignore +0 -0
  16. {perfact_api_base-0.6 → perfact_api_base-0.7}/.vscode/settings.json +0 -0
  17. {perfact_api_base-0.6 → perfact_api_base-0.7}/README.md +0 -0
  18. {perfact_api_base-0.6 → perfact_api_base-0.7}/bandit.yml +0 -0
  19. {perfact_api_base-0.6 → perfact_api_base-0.7}/setup.cfg +0 -0
  20. {perfact_api_base-0.6 → perfact_api_base-0.7}/src/perfact/api/base/authinfo.py +0 -0
  21. {perfact_api_base-0.6 → perfact_api_base-0.7}/src/perfact/api/base/model.py +0 -0
  22. {perfact_api_base-0.6 → perfact_api_base-0.7}/src/perfact/api/base/py.typed +0 -0
  23. {perfact_api_base-0.6 → perfact_api_base-0.7}/src/perfact/api/base/visibility.py +0 -0
  24. {perfact_api_base-0.6 → perfact_api_base-0.7}/src/perfact_api_base.egg-info/dependency_links.txt +0 -0
  25. {perfact_api_base-0.6 → perfact_api_base-0.7}/src/perfact_api_base.egg-info/requires.txt +0 -0
  26. {perfact_api_base-0.6 → perfact_api_base-0.7}/src/perfact_api_base.egg-info/top_level.txt +0 -0
  27. {perfact_api_base-0.6 → perfact_api_base-0.7}/tests/test_visibility_policy.py +0 -0
@@ -0,0 +1,17 @@
1
+ name: Tox tests
2
+ on:
3
+ push:
4
+ branches:
5
+ - 'main'
6
+ pull_request: {}
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: perfact-build-trixie
11
+
12
+ steps:
13
+ - uses: actions/checkout@v3
14
+ with:
15
+ fetch-depth: 0
16
+ - name: Test with tox
17
+ run: tox
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: perfact-api-base
3
- Version: 0.6
3
+ Version: 0.7
4
4
  Summary: PerFact API - base package for common infrastructure
5
5
  Author-email: Viktor Dick <viktor.dick@perfact.de>
6
6
  License: GPL-2.0-or-later
@@ -1,5 +1,5 @@
1
1
  [build-system]
2
- requires = ["setuptools>=61.2", "setuptools-scm>=8.0"]
2
+ requires = ["setuptools>=78", "setuptools-scm>=8.0"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
@@ -1,4 +1,5 @@
1
1
  from .authinfo import AuthInfo
2
+ from .discovery import generic_discover
2
3
  from .model import Base, PydanticBase, View
3
4
  from .visibility import (
4
5
  VisibilityAwareModel,
@@ -14,4 +15,5 @@ __all__ = [
14
15
  "VisibilityAwareModel",
15
16
  "VisibilityPolicy",
16
17
  "VisibilityPolicyRegistry",
18
+ "generic_discover",
17
19
  ]
@@ -0,0 +1,33 @@
1
+ import logging
2
+ from importlib.metadata import entry_points
3
+ from typing import TypeVar
4
+
5
+ log = logging.getLogger("perfact.api.base.discovery")
6
+
7
+ T = TypeVar("T")
8
+
9
+
10
+ def generic_discover(registry: T, entrypoint_group: str) -> T:
11
+ """
12
+ generic discovery loader. Finds all modules with the given entrypoint_group.
13
+ Every module is loaded and the load()-method is called with the given loader.
14
+ """
15
+
16
+ if not entrypoint_group:
17
+ raise RuntimeError("No entrypoint_group defined, discovery not possible.")
18
+
19
+ modules_count = 0
20
+ log.info(f"start discovery for {entrypoint_group}")
21
+ for ep in entry_points(group=entrypoint_group):
22
+ log.info(f" try to include {ep.value}")
23
+
24
+ try:
25
+ ep.load()(registry)
26
+ modules_count += 1
27
+ except Exception:
28
+ log.exception(f" failed to include plugin {ep.value}")
29
+ log.info(
30
+ f"finished discovery, included {modules_count} plugins for {entrypoint_group}"
31
+ )
32
+
33
+ return registry
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: perfact-api-base
3
- Version: 0.6
3
+ Version: 0.7
4
4
  Summary: PerFact API - base package for common infrastructure
5
5
  Author-email: Viktor Dick <viktor.dick@perfact.de>
6
6
  License: GPL-2.0-or-later
@@ -8,6 +8,7 @@ tox.ini
8
8
  .vscode/settings.json
9
9
  src/perfact/api/base/__init__.py
10
10
  src/perfact/api/base/authinfo.py
11
+ src/perfact/api/base/discovery.py
11
12
  src/perfact/api/base/model.py
12
13
  src/perfact/api/base/py.typed
13
14
  src/perfact/api/base/visibility.py
@@ -16,4 +17,8 @@ src/perfact_api_base.egg-info/SOURCES.txt
16
17
  src/perfact_api_base.egg-info/dependency_links.txt
17
18
  src/perfact_api_base.egg-info/requires.txt
18
19
  src/perfact_api_base.egg-info/top_level.txt
20
+ tests/conftest.py
21
+ tests/test_authinfo.py
22
+ tests/test_discovery.py
23
+ tests/test_model.py
19
24
  tests/test_visibility_policy.py
@@ -0,0 +1,61 @@
1
+ import pytest
2
+ from pytest_postgresql import factories
3
+ from sqlalchemy import URL, create_engine, text
4
+ from sqlalchemy.orm import Session
5
+
6
+ from perfact.api.base.model import Base
7
+
8
+ postgresql_proc = factories.postgresql_proc(port=None)
9
+
10
+
11
+ @pytest.fixture(scope="module")
12
+ def engine(postgresql_proc):
13
+ """Module-scoped engine: creates DB, db_username() function, and all tables once."""
14
+ admin_url = URL.create(
15
+ "postgresql+psycopg",
16
+ username=postgresql_proc.user,
17
+ host=postgresql_proc.host,
18
+ port=postgresql_proc.port,
19
+ database="postgres",
20
+ )
21
+ db_name = "test_perfact_base"
22
+ admin_engine = create_engine(admin_url, isolation_level="AUTOCOMMIT")
23
+ with admin_engine.connect() as conn:
24
+ conn.execute(text(f"DROP DATABASE IF EXISTS {db_name}"))
25
+ conn.execute(text(f"CREATE DATABASE {db_name}"))
26
+ admin_engine.dispose()
27
+
28
+ test_url = URL.create(
29
+ "postgresql+psycopg",
30
+ username=postgresql_proc.user,
31
+ host=postgresql_proc.host,
32
+ port=postgresql_proc.port,
33
+ database=db_name,
34
+ )
35
+ eng = create_engine(test_url)
36
+ with eng.connect() as conn:
37
+ conn.execute(
38
+ text(
39
+ "CREATE OR REPLACE FUNCTION db_username() "
40
+ "RETURNS text AS $$ SELECT current_user $$ LANGUAGE SQL"
41
+ )
42
+ )
43
+ conn.commit()
44
+ Base.metadata.create_all(eng)
45
+
46
+ yield eng
47
+
48
+ Base.metadata.drop_all(eng)
49
+ eng.dispose()
50
+ admin_engine2 = create_engine(admin_url, isolation_level="AUTOCOMMIT")
51
+ with admin_engine2.connect() as conn:
52
+ conn.execute(text(f"DROP DATABASE IF EXISTS {db_name}"))
53
+ admin_engine2.dispose()
54
+
55
+
56
+ @pytest.fixture
57
+ def session(engine):
58
+ """Function-scoped session that rolls back after each test for isolation."""
59
+ with Session(engine) as sess:
60
+ yield sess
61
+ sess.rollback()
@@ -0,0 +1,8 @@
1
+ from perfact.api.base.authinfo import AuthInfo
2
+
3
+
4
+ def test_get_unauthorized_authinfo():
5
+ info = AuthInfo.get_unauthorized_authinfo()
6
+ assert info.name is None
7
+ assert info.roles == []
8
+ assert info.appstc is None
@@ -0,0 +1,55 @@
1
+ from importlib.metadata import EntryPoint
2
+ from unittest.mock import patch
3
+
4
+ import pytest
5
+
6
+ from perfact.api.base.discovery import generic_discover
7
+
8
+
9
+ class _Registry:
10
+ def __init__(self):
11
+ self.calls = []
12
+
13
+
14
+ def _loader_ok(registry: _Registry) -> None:
15
+ registry.calls.append("ok")
16
+
17
+
18
+ def _loader_fail(registry: _Registry) -> None:
19
+ raise ValueError("plugin broken")
20
+
21
+
22
+ def _ep(name: str) -> EntryPoint:
23
+ return EntryPoint(name=name, group="test.group", value=f"{__name__}:{name}")
24
+
25
+
26
+ def test_empty_entrypoint_group_raises():
27
+ with pytest.raises(RuntimeError, match="No entrypoint_group defined"):
28
+ generic_discover(_Registry(), "")
29
+
30
+
31
+ def test_no_plugins_returns_registry():
32
+ registry = _Registry()
33
+ with patch("perfact.api.base.discovery.entry_points", return_value=[]):
34
+ result = generic_discover(registry, "test.group")
35
+ assert result is registry
36
+ assert result.calls == []
37
+
38
+
39
+ def test_plugin_loaded_and_called():
40
+ registry = _Registry()
41
+ with patch(
42
+ "perfact.api.base.discovery.entry_points", return_value=[_ep("_loader_ok")]
43
+ ):
44
+ result = generic_discover(registry, "test.group")
45
+ assert result.calls == ["ok"]
46
+
47
+
48
+ def test_plugin_load_exception_continues():
49
+ registry = _Registry()
50
+ with patch(
51
+ "perfact.api.base.discovery.entry_points", return_value=[_ep("_loader_fail")]
52
+ ):
53
+ result = generic_discover(registry, "test.group")
54
+ assert result is registry
55
+ assert result.calls == []
@@ -0,0 +1,276 @@
1
+ from datetime import datetime
2
+
3
+ from sqlalchemy import BigInteger, String
4
+ from sqlalchemy.orm import Mapped, mapped_column
5
+
6
+ from perfact.api.base.model import Base, ForeignKey, View, relationship
7
+
8
+ # ---------------------------------------------------------------------------
9
+ # Test models (registered in Base.metadata at import time)
10
+ # ---------------------------------------------------------------------------
11
+
12
+
13
+ class Article(Base):
14
+ title: Mapped[str]
15
+ word_count: Mapped[int]
16
+
17
+
18
+ class Tag(Base):
19
+ __tablename__ = "tag"
20
+ label: Mapped[str]
21
+
22
+
23
+ class ExplicitColModel(Base):
24
+ # Column with an explicit DB name — should not be prefixed
25
+ raw: Mapped[str] = mapped_column("explicit_raw")
26
+
27
+
28
+ class Category(Base):
29
+ name: Mapped[str]
30
+ posts: Mapped[list["Post"]] = relationship(back_populates="category")
31
+
32
+
33
+ class Post(Base):
34
+ headline: Mapped[str]
35
+ # Cross-model FK: use object reference so it resolves after Category's columns
36
+ # are already prefixed (string "category.id" would fail at that point)
37
+ category_id: Mapped[int] = mapped_column(ForeignKey(Category.id))
38
+ category: Mapped[Category] = relationship(back_populates="posts")
39
+
40
+
41
+ class TreeNode(Base):
42
+ label: Mapped[str]
43
+ # Self-referential FK: string form works because it resolves during
44
+ # super().__init_subclass__(), before the prefix step runs
45
+ parent_treenode_id: Mapped[int | None] = mapped_column(ForeignKey("treenode.id"))
46
+
47
+
48
+ class DiffKeyModel(Base):
49
+ # key differs from name: the prefix guard (col.name == col.key) is False,
50
+ # so this column is intentionally left unprefixed
51
+ value: Mapped[str] = mapped_column("explicit_db_name", key="different_key")
52
+
53
+
54
+ class ReportView(View):
55
+ __tablename__ = "v_report"
56
+ # View columns carry explicit names so they match the SQL view definition
57
+ id: Mapped[int] = mapped_column("id", primary_key=True)
58
+ summary: Mapped[str] = mapped_column("summary")
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Unit tests — no database required
63
+ # ---------------------------------------------------------------------------
64
+
65
+
66
+ class TestTableName:
67
+ def test_auto_lowercase(self):
68
+ assert Article.__tablename__ == "article"
69
+
70
+ def test_explicit_preserved(self):
71
+ assert Tag.__tablename__ == "tag"
72
+
73
+
74
+ class TestColumnPrefixing:
75
+ def test_own_columns_prefixed(self):
76
+ names = {c.name for c in Article.__table__.columns}
77
+ assert "article_title" in names
78
+ assert "article_word_count" in names
79
+
80
+ def test_inherited_columns_prefixed(self):
81
+ names = {c.name for c in Article.__table__.columns}
82
+ assert "article_id" in names
83
+ assert "article_modtime" in names
84
+ assert "article_author" in names
85
+
86
+ def test_unprefixed_names_absent(self):
87
+ names = {c.name for c in Article.__table__.columns}
88
+ assert "title" not in names
89
+ assert "id" not in names
90
+
91
+ def test_explicit_tablename_used_as_prefix(self):
92
+ names = {c.name for c in Tag.__table__.columns}
93
+ assert "tag_label" in names
94
+
95
+ def test_explicit_column_name_used_as_base(self):
96
+ # mapped_column("explicit_raw") → name=key="explicit_raw" → prefixed using
97
+ # the explicit name, not the Python attribute name "raw"
98
+ names = {c.name for c in ExplicitColModel.__table__.columns}
99
+ assert "explicitcolmodel_explicit_raw" in names
100
+ assert "explicitcolmodel_raw" not in names
101
+
102
+ def test_fk_column_prefixed(self):
103
+ names = {c.name for c in Post.__table__.columns}
104
+ assert "post_category_id" in names
105
+
106
+ def test_self_ref_fk_column_prefixed(self):
107
+ names = {c.name for c in TreeNode.__table__.columns}
108
+ assert "treenode_parent_treenode_id" in names
109
+
110
+ def test_column_with_different_key_not_prefixed(self):
111
+ # col.name != col.key → guard skips it; DB name stays as declared
112
+ names = {c.name for c in DiffKeyModel.__table__.columns}
113
+ assert "explicit_db_name" in names
114
+ assert "diffkeymodel_explicit_db_name" not in names
115
+
116
+ def test_abstract_subclass_skips_prefix_step(self):
117
+ # Abstract subclasses never get __table__, so __init_subclass__ exits
118
+ # at the hasattr guard without attempting any column renaming
119
+ class InlineAbstract(Base):
120
+ __abstract__ = True
121
+
122
+ assert not hasattr(InlineAbstract, "__table__")
123
+
124
+
125
+ class TestColumnProperties:
126
+ def test_id_is_primary_key(self):
127
+ pk = {c.name for c in Article.__table__.primary_key.columns}
128
+ assert "article_id" in pk
129
+
130
+ def test_modtime_has_server_default(self):
131
+ # Table.c index uses original keys; find by name via iteration
132
+ col = next(c for c in Article.__table__.columns if c.name == "article_modtime")
133
+ assert col.server_default is not None
134
+
135
+ def test_author_is_nullable(self):
136
+ col = next(c for c in Article.__table__.columns if c.name == "article_author")
137
+ assert col.nullable is True
138
+
139
+ def test_author_has_server_default(self):
140
+ col = next(c for c in Article.__table__.columns if c.name == "article_author")
141
+ assert col.server_default is not None
142
+
143
+
144
+ class TestTypeAnnotationMap:
145
+ def test_base_str_maps_to_string(self):
146
+ assert isinstance(Base.type_annotation_map[str], String)
147
+
148
+ def test_base_int_maps_to_biginteger(self):
149
+ assert isinstance(Base.type_annotation_map[int], BigInteger)
150
+
151
+ def test_view_str_maps_to_string(self):
152
+ assert isinstance(View.type_annotation_map[str], String)
153
+
154
+ def test_view_int_maps_to_biginteger(self):
155
+ assert isinstance(View.type_annotation_map[int], BigInteger)
156
+
157
+
158
+ class TestViewClass:
159
+ def test_columns_not_prefixed(self):
160
+ names = {c.name for c in ReportView.__table__.columns}
161
+ assert "id" in names
162
+ assert "summary" in names
163
+ assert "v_report_id" not in names
164
+
165
+ def test_not_in_base_metadata(self):
166
+ assert "v_report" not in Base.metadata.tables
167
+
168
+ def test_in_view_metadata(self):
169
+ assert "v_report" in View.metadata.tables
170
+
171
+
172
+ class TestReexports:
173
+ def test_foreignkey_is_sqlalchemy_class(self):
174
+ from sqlalchemy import ForeignKey as SAFK
175
+
176
+ assert ForeignKey is SAFK
177
+
178
+ def test_relationship_is_sqlalchemy_function(self):
179
+ from sqlalchemy.orm import relationship as sa_rel
180
+
181
+ assert relationship is sa_rel
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Integration tests — require the `engine` / `session` fixtures (PostgreSQL)
186
+ # ---------------------------------------------------------------------------
187
+
188
+
189
+ class TestDatabaseOperations:
190
+ def test_tables_created(self, engine):
191
+ from sqlalchemy import inspect as sa_inspect
192
+
193
+ tables = sa_inspect(engine).get_table_names()
194
+ assert "article" in tables
195
+ assert "tag" in tables
196
+ assert "category" in tables
197
+ assert "post" in tables
198
+ assert "treenode" in tables
199
+
200
+ def test_view_not_created_as_table(self, engine):
201
+ from sqlalchemy import inspect as sa_inspect
202
+
203
+ tables = sa_inspect(engine).get_table_names()
204
+ assert "v_report" not in tables
205
+
206
+ def test_insert_and_retrieve(self, session):
207
+ article = Article(title="Hello", word_count=500)
208
+ session.add(article)
209
+ session.flush()
210
+ result = session.get(Article, article.id)
211
+ assert result is not None
212
+ assert result.title == "Hello"
213
+ assert result.word_count == 500
214
+
215
+ def test_id_auto_assigned(self, session):
216
+ article = Article(title="Numbered", word_count=1)
217
+ session.add(article)
218
+ session.flush()
219
+ assert isinstance(article.id, int)
220
+ assert article.id > 0
221
+
222
+ def test_modtime_auto_set_by_server(self, session):
223
+ article = Article(title="Timed", word_count=1)
224
+ session.add(article)
225
+ session.flush()
226
+ session.refresh(article)
227
+ assert isinstance(article.modtime, datetime)
228
+
229
+ def test_author_auto_set_by_server(self, session):
230
+ article = Article(title="Authored", word_count=1)
231
+ session.add(article)
232
+ session.flush()
233
+ session.refresh(article)
234
+ assert article.author is not None
235
+
236
+ def test_author_can_be_explicitly_set(self, session):
237
+ # author=None omits the column → server default fires; an explicit string
238
+ # value takes precedence over the server default
239
+ article = Article(title="Override", word_count=1, author="custom_user")
240
+ session.add(article)
241
+ session.flush()
242
+ session.refresh(article)
243
+ assert article.author == "custom_user"
244
+
245
+ def test_fk_relationship(self, session):
246
+ cat = Category(name="Science")
247
+ post = Post(headline="Big Discovery", category=cat)
248
+ session.add_all([cat, post])
249
+ session.flush()
250
+ session.refresh(cat)
251
+ assert len(cat.posts) == 1
252
+ assert cat.posts[0].headline == "Big Discovery"
253
+
254
+ def test_fk_back_reference(self, session):
255
+ cat = Category(name="Tech")
256
+ post = Post(headline="AI News", category=cat)
257
+ session.add_all([cat, post])
258
+ session.flush()
259
+ session.refresh(post)
260
+ assert post.category.name == "Tech"
261
+
262
+ def test_self_referential_fk(self, session):
263
+ root = TreeNode(label="root")
264
+ session.add(root)
265
+ session.flush()
266
+ child = TreeNode(label="child", parent_treenode_id=root.id)
267
+ session.add(child)
268
+ session.flush()
269
+ assert child.parent_treenode_id == root.id
270
+
271
+ def test_session_isolation_via_rollback(self, session):
272
+ # Data inserted here is rolled back by the fixture — other tests are unaffected
273
+ article = Article(title="Ephemeral", word_count=0)
274
+ session.add(article)
275
+ session.flush()
276
+ assert session.get(Article, article.id) is not None
@@ -2,9 +2,8 @@
2
2
  envlist = py3
3
3
  isolated_build = true
4
4
 
5
- [pytest]
6
-
7
5
  [testenv]
6
+ pythonpath = {toxinidir}
8
7
  deps =
9
8
  ruff
10
9
  pytest
@@ -21,4 +20,4 @@ commands =
21
20
  ruff check
22
21
  bandit --configfile bandit.yml -r src
23
22
  mypy src
24
- # pytest --doctest-modules --cov-branch --cov=src --cov-report=term-missing {posargs:src}
23
+ pytest --cov-branch --cov=perfact.api.base --cov-report=term-missing --cov-fail-under=100 {posargs:tests}
@@ -1,26 +0,0 @@
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
File without changes
File without changes