First version

This commit is contained in:
florian committed 2026-11-10 10:51:21 +01:00
1 parent 9c5b8ab38c
commit 6e2442b41e
6 files changed
+217

No files matched your search

+25
View File
@@ -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/*
+25
View File
@@ -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/*
+32
View File
@@ -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"
+17
View File
@@ -0,0 +1,17 @@
from .message import (
LINK_SCHEMES,
MAX_LEN,
Category,
Message,
MessageValidationError,
ValidationIssue,
)
__all__ = [
"LINK_SCHEMES",
"MAX_LEN",
"Category",
"Message",
"MessageValidationError",
"ValidationIssue",
]
+118
View File
@@ -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
View File
Whitespace-only changes.