From 6e2442b41e94d3193d15cc9b89c7f5f891dad31e Mon Sep 17 00:00:00 2001 From: Florian Date: Tue, 10 Nov 2026 10:51:21 +0100 Subject: [PATCH] First version --- .gitea/workflows/build-local.yaml | 25 +++++++ .gitea/workflows/build.yaml | 25 +++++++ pyproject.toml | 32 ++++++++ src/shared_models/__init__.py | 17 +++++ src/shared_models/message.py | 118 ++++++++++++++++++++++++++++++ src/shared_models/py.typed | 0 6 files changed, 217 insertions(+) create mode 100644 .gitea/workflows/build-local.yaml create mode 100644 .gitea/workflows/build.yaml create mode 100644 pyproject.toml create mode 100644 src/shared_models/__init__.py create mode 100644 src/shared_models/message.py create mode 100644 src/shared_models/py.typed diff --git a/.gitea/workflows/build-local.yaml b/.gitea/workflows/build-local.yaml new file mode 100644 index 0000000..6344b36 --- /dev/null +++ b/.gitea/workflows/build-local.yaml @@ -0,0 +1,25 @@ +name: Build and Publish Package via local runner + +on: + workflow_dispatch: + +jobs: + build: + runs-on: local + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Prepare environment + run: | + virtualenv -p python3 .venv + source .venv/bin/activate + pip install build twine + - name: Build package + run: .venv/bin/python -m build + - name: Publish package + run: | + .venv/bin/python -m twine upload \ + --repository-url https://git.gansejunge.com/api/packages/notifier/pypi \ + -u ${{ secrets.PACKAGE_UPLOAD_USERNAME }} \ + -p ${{ secrets.PACKAGE_UPLOAD_TOKEN }} \ + dist/* diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..7f5fb96 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,25 @@ +name: Build and Publish Package + +on: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Prepare environment + run: | + virtualenv -p python3 .venv + source .venv/bin/activate + pip install build twine + - name: Build package + run: .venv/bin/python -m build + - name: Publish package + run: | + .venv/bin/python -m twine upload \ + --repository-url https://git.gansejunge.com/api/packages/notifier/pypi \ + -u ${{ secrets.PACKAGE_UPLOAD_USERNAME }} \ + -p ${{ secrets.PACKAGE_UPLOAD_TOKEN }} \ + dist/* \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0f04bd1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "lib-shared-models" +version = "0.1.0" +description = "Shared model(s) to validate against" +requires-python = ">=3.9" +dependencies = [] + +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +shared_models = ["py.typed"] + +[tool.ruff] +line-length = 120 +target-version = "py312" +[tool.ruff.lint] +select = [ + "E", "F", # pycodestyle errors, pyflakes (unused imports/variables, undefined names) + "I", # import sorting (replaces isort) + "B", # bugbear: likely bugs + "ASYNC", # async mistakes + "RUF006", # un-referenced asyncio.create_task (tasks can be garbage-collected) +] +ignore = ["E501"] + +[tool.ruff.format] +indent-style = "tab" diff --git a/src/shared_models/__init__.py b/src/shared_models/__init__.py new file mode 100644 index 0000000..62ff08b --- /dev/null +++ b/src/shared_models/__init__.py @@ -0,0 +1,17 @@ +from .message import ( + LINK_SCHEMES, + MAX_LEN, + Category, + Message, + MessageValidationError, + ValidationIssue, +) + +__all__ = [ + "LINK_SCHEMES", + "MAX_LEN", + "Category", + "Message", + "MessageValidationError", + "ValidationIssue", +] \ No newline at end of file diff --git a/src/shared_models/message.py b/src/shared_models/message.py new file mode 100644 index 0000000..ac613c4 --- /dev/null +++ b/src/shared_models/message.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from enum import Enum +from typing import Any, Mapping + +MAX_LEN = 255 +LINK_SCHEMES = ("http://", "https://") + + +class Category(str, Enum): + ROYALROAD = "royal-road" + PODCASTS = "podcasts" + MIXTAPES = "mixtapes" + UTILITY = "utility" + + +@dataclass(frozen=True) +class ValidationIssue: + field: str + code: str # machine-readable: "required", "too_long", ... + message: str # human-readable + + def to_dict(self) -> dict[str, str]: + return asdict(self) + + +class MessageValidationError(ValueError): + """Carries every problem found, not just the first.""" + + def __init__(self, issues: list[ValidationIssue]): + self.issues = issues + super().__init__("; ".join(f"{i.field}: {i.message}" for i in issues)) + + def to_list(self) -> list[dict[str, str]]: + return [i.to_dict() for i in self.issues] + + +def _text(data, name, issues, *, required): + """Return a stripped string, or None if absent/invalid (issue recorded).""" + if name not in data or data[name] is None: + if required: + issues.append(ValidationIssue(name, "required", f"{name} is required")) + return None + value = data[name] + if not isinstance(value, str): + issues.append(ValidationIssue( + name, "type", f"{name} must be a string, got {type(value).__name__}")) + return None + value = value.strip() + if required and not value: + issues.append(ValidationIssue(name, "empty", f"{name} must not be blank")) + return None + if len(value) > MAX_LEN: + issues.append(ValidationIssue( + name, "too_long", + f"{name} is {len(value)} characters, maximum is {MAX_LEN}")) + return None + return value + + +@dataclass(frozen=True) +class Message: + title: str + body: str + link: str | None = None + category: Category = Category.UTILITY + timestamp: int | None = None + + _FIELDS = frozenset({"title", "body", "link", "category", "timestamp"}) + + @classmethod + def parse(cls, data: Any) -> "Message": + if not isinstance(data, Mapping): + raise MessageValidationError([ValidationIssue( + "message", "type", + f"message must be an object, got {type(data).__name__}")]) + + issues: list[ValidationIssue] = [] + + for key in sorted(map(str, data.keys() - cls._FIELDS)): + issues.append(ValidationIssue(key, "unknown_field", f"unexpected field {key!r}")) + + title = _text(data, "title", issues, required=True) + body = _text(data, "body", issues, required=True) + + link = _text(data, "link", issues, required=False) or None + if link and not link.lower().startswith(LINK_SCHEMES): + issues.append(ValidationIssue( + "link", "bad_scheme", "link must start with http:// or https://")) + + category = Category.UTILITY + if data.get("category") is not None: + try: + category = Category(data["category"]) + except (ValueError, TypeError): + allowed = ", ".join(c.value for c in Category) + issues.append(ValidationIssue( + "category", "invalid_choice", + f"category {data['category']!r} is not one of: {allowed}")) + + timestamp = data.get("timestamp") + if timestamp is not None: + if isinstance(timestamp, bool) or not isinstance(timestamp, int): + issues.append(ValidationIssue( + "timestamp", "type", "timestamp must be an integer (unix seconds)")) + elif timestamp < 0: + issues.append(ValidationIssue( + "timestamp", "out_of_range", "timestamp must not be negative")) + + if issues: + raise MessageValidationError(issues) + return cls(title, body, link, category, timestamp) + + def to_dict(self) -> dict[str, Any]: + d = asdict(self) + d["category"] = self.category.value + return d \ No newline at end of file diff --git a/src/shared_models/py.typed b/src/shared_models/py.typed new file mode 100644 index 0000000..e69de29