nene2-python 1.0.0__py3-none-any.whl

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.
nene2/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """NENE2 Python — minimal API framework."""
nene2/auth/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """NENE2 authentication layer."""
2
+
3
+ from .api_key import ApiKeyAuthMiddleware
4
+ from .bearer_token import BearerTokenMiddleware
5
+ from .exceptions import TokenVerificationException
6
+ from .interfaces import TokenIssuerProtocol, TokenVerifierProtocol
7
+ from .local_verifier import LocalTokenVerifier
8
+
9
+ __all__ = [
10
+ "ApiKeyAuthMiddleware",
11
+ "BearerTokenMiddleware",
12
+ "LocalTokenVerifier",
13
+ "TokenIssuerProtocol",
14
+ "TokenVerificationException",
15
+ "TokenVerifierProtocol",
16
+ ]
nene2/auth/api_key.py ADDED
@@ -0,0 +1,39 @@
1
+ """API Key authentication middleware.
2
+
3
+ Validates X-Api-Key header using a TokenVerifierProtocol.
4
+ Returns 401 Problem Details when key is absent or invalid.
5
+ """
6
+
7
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
8
+ from starlette.requests import Request
9
+ from starlette.responses import Response
10
+
11
+ from nene2.http.problem_details import problem_details_response
12
+
13
+ from .exceptions import TokenVerificationException
14
+ from .interfaces import TokenVerifierProtocol
15
+
16
+ _API_KEY_HEADER = "X-Api-Key"
17
+
18
+
19
+ class ApiKeyAuthMiddleware(BaseHTTPMiddleware):
20
+ """Require a valid X-Api-Key header on every request."""
21
+
22
+ def __init__(self, app: object, *, verifier: TokenVerifierProtocol) -> None:
23
+ super().__init__(app) # type: ignore[arg-type]
24
+ self._verifier = verifier
25
+
26
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
27
+ api_key = request.headers.get(_API_KEY_HEADER, "")
28
+ try:
29
+ verified = bool(api_key) and self._verifier.verify(api_key)
30
+ except TokenVerificationException:
31
+ verified = False
32
+ if not verified:
33
+ return problem_details_response(
34
+ "unauthorized",
35
+ "Unauthorized",
36
+ 401,
37
+ "A valid X-Api-Key header is required.",
38
+ )
39
+ return await call_next(request)
@@ -0,0 +1,51 @@
1
+ """Bearer token authentication middleware.
2
+
3
+ Validates Authorization: Bearer <token> header.
4
+ Returns 401 Problem Details when token is absent or invalid.
5
+ """
6
+
7
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
8
+ from starlette.requests import Request
9
+ from starlette.responses import Response
10
+
11
+ from nene2.http.problem_details import problem_details_response
12
+
13
+ from .exceptions import TokenVerificationException
14
+ from .interfaces import TokenVerifierProtocol
15
+
16
+ _WWW_AUTH = 'Bearer realm="api"'
17
+
18
+
19
+ class BearerTokenMiddleware(BaseHTTPMiddleware):
20
+ """Require a valid Bearer token on every request."""
21
+
22
+ def __init__(self, app: object, *, verifier: TokenVerifierProtocol) -> None:
23
+ super().__init__(app) # type: ignore[arg-type]
24
+ self._verifier = verifier
25
+
26
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
27
+ auth = request.headers.get("Authorization", "")
28
+ if not auth.startswith("Bearer "):
29
+ response = problem_details_response(
30
+ "unauthorized",
31
+ "Unauthorized",
32
+ 401,
33
+ "A valid Bearer token is required.",
34
+ )
35
+ response.headers["WWW-Authenticate"] = _WWW_AUTH
36
+ return response
37
+ token = auth[len("Bearer ") :]
38
+ try:
39
+ verified = self._verifier.verify(token)
40
+ except TokenVerificationException:
41
+ verified = False
42
+ if not verified:
43
+ response = problem_details_response(
44
+ "unauthorized",
45
+ "Unauthorized",
46
+ 401,
47
+ "The provided token is invalid or expired.",
48
+ )
49
+ response.headers["WWW-Authenticate"] = _WWW_AUTH
50
+ return response
51
+ return await call_next(request)
@@ -0,0 +1,8 @@
1
+ """Authentication exceptions."""
2
+
3
+
4
+ class TokenVerificationException(Exception):
5
+ """Raised by TokenVerifierProtocol implementations when a token is invalid.
6
+
7
+ BearerTokenMiddleware maps this to a 401 Problem Details response.
8
+ """
@@ -0,0 +1,23 @@
1
+ """Authentication interfaces."""
2
+
3
+ from typing import Protocol, runtime_checkable
4
+
5
+
6
+ @runtime_checkable
7
+ class TokenVerifierProtocol(Protocol):
8
+ """Verify an authentication token.
9
+
10
+ Implementations may raise TokenVerificationException instead of returning False.
11
+ """
12
+
13
+ def verify(self, token: str) -> bool: ...
14
+
15
+
16
+ @runtime_checkable
17
+ class TokenIssuerProtocol(Protocol):
18
+ """Issue a signed bearer token from the given claims.
19
+
20
+ Production implementations wrap a JWT library (e.g. PyJWT).
21
+ """
22
+
23
+ def issue(self, claims: dict[str, object]) -> str: ...
@@ -0,0 +1,17 @@
1
+ """Local token verifier — compares against a fixed set of allowed tokens.
2
+
3
+ For development and testing only. In production, implement TokenVerifierProtocol
4
+ against your actual auth backend (database, external IdP, JWT, etc.).
5
+ """
6
+
7
+ import secrets
8
+
9
+
10
+ class LocalTokenVerifier:
11
+ """Verify tokens against a fixed allowlist using constant-time comparison."""
12
+
13
+ def __init__(self, allowed_tokens: list[str]) -> None:
14
+ self._allowed = allowed_tokens
15
+
16
+ def verify(self, token: str) -> bool:
17
+ return any(secrets.compare_digest(token, allowed) for allowed in self._allowed)
@@ -0,0 +1,5 @@
1
+ """Typed config objects — equivalent to PHP AppConfig / DatabaseConfig."""
2
+
3
+ from .settings import AppSettings
4
+
5
+ __all__ = ["AppSettings"]
@@ -0,0 +1,72 @@
1
+ """Typed application settings loaded from environment variables."""
2
+
3
+ from pydantic import SecretStr, field_validator
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+
7
+ class AppSettings(BaseSettings):
8
+ """Application configuration loaded from environment variables."""
9
+
10
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
11
+
12
+ app_env: str = "local"
13
+ app_debug: bool = False
14
+ app_name: str = "nene2-python"
15
+ security_headers_enabled: bool = True
16
+ max_body_size: int = 1_048_576 # 1 MiB
17
+ throttle_enabled: bool = True
18
+ throttle_limit: int = 60
19
+ throttle_window: int = 60 # seconds
20
+
21
+ cors_enabled: bool = False
22
+ cors_origins: list[str] = []
23
+ cors_allow_credentials: bool = False
24
+ cors_allow_methods: list[str] = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
25
+ cors_allow_headers: list[str] = [
26
+ "Content-Type",
27
+ "Authorization",
28
+ "X-Api-Key",
29
+ "X-Request-Id",
30
+ ]
31
+
32
+ bearer_token_enabled: bool = False
33
+ bearer_tokens: list[str] = []
34
+
35
+ api_key_enabled: bool = False
36
+ api_keys: list[str] = []
37
+
38
+ db_adapter: str = "sqlite"
39
+ db_name: str = ":memory:"
40
+ db_host: str = "localhost"
41
+ db_port: int = 3306
42
+ db_user: str = ""
43
+ db_password: SecretStr = SecretStr("")
44
+
45
+ @field_validator("app_env")
46
+ @classmethod
47
+ def validate_app_env(cls, v: str) -> str:
48
+ allowed = {"local", "test", "production"}
49
+ if v not in allowed:
50
+ raise ValueError(f"app_env must be one of {allowed}")
51
+ return v
52
+
53
+ @field_validator("db_adapter")
54
+ @classmethod
55
+ def validate_adapter(cls, v: str) -> str:
56
+ allowed = {"sqlite", "mysql", "pgsql"}
57
+ if v not in allowed:
58
+ raise ValueError(f"db_adapter must be one of {allowed}")
59
+ return v
60
+
61
+ @property
62
+ def db_url(self) -> str:
63
+ """Build a SQLAlchemy connection URL from adapter + credentials."""
64
+ if self.db_adapter == "sqlite":
65
+ return f"sqlite:///{self.db_name}"
66
+ password = self.db_password.get_secret_value()
67
+ port = self.db_port
68
+ if self.db_adapter == "mysql":
69
+ return f"mysql+pymysql://{self.db_user}:{password}@{self.db_host}:{port}/{self.db_name}"
70
+ return (
71
+ f"postgresql+psycopg2://{self.db_user}:{password}@{self.db_host}:{port}/{self.db_name}"
72
+ )
@@ -0,0 +1,15 @@
1
+ """NENE2 database abstraction layer."""
2
+
3
+ from .exceptions import DatabaseConnectionException
4
+ from .health import DatabaseHealthCheck
5
+ from .interfaces import DatabaseQueryExecutorInterface, DatabaseTransactionManagerInterface
6
+ from .sqlalchemy_executor import SqlAlchemyQueryExecutor, SqlAlchemyTransactionManager
7
+
8
+ __all__ = [
9
+ "DatabaseConnectionException",
10
+ "DatabaseHealthCheck",
11
+ "DatabaseQueryExecutorInterface",
12
+ "DatabaseTransactionManagerInterface",
13
+ "SqlAlchemyQueryExecutor",
14
+ "SqlAlchemyTransactionManager",
15
+ ]
@@ -0,0 +1,5 @@
1
+ """Database exceptions."""
2
+
3
+
4
+ class DatabaseConnectionException(Exception):
5
+ """Raised when a database connection cannot be established."""
@@ -0,0 +1,24 @@
1
+ """Database health check — verifies DB connectivity for /health endpoint."""
2
+
3
+ import logging
4
+
5
+ from nene2.http import HealthStatus
6
+
7
+ from .interfaces import DatabaseQueryExecutorInterface
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class DatabaseHealthCheck:
13
+ """Check database connectivity by executing a lightweight query."""
14
+
15
+ def __init__(self, executor: DatabaseQueryExecutorInterface) -> None:
16
+ self._executor = executor
17
+
18
+ def check(self) -> HealthStatus:
19
+ try:
20
+ self._executor.fetch_one("SELECT 1 AS ok")
21
+ return HealthStatus(status="ok", checks={"database": "ok"})
22
+ except Exception as exc:
23
+ logger.warning("database health check failed: %s", exc)
24
+ return HealthStatus(status="error", checks={"database": "error"})
@@ -0,0 +1,51 @@
1
+ """Database abstraction interfaces.
2
+
3
+ Equivalent to PHP Nene2\\Database\\DatabaseQueryExecutorInterface
4
+ and DatabaseTransactionManagerInterface.
5
+ """
6
+
7
+ from abc import ABC, abstractmethod
8
+ from collections.abc import Callable
9
+ from typing import Any
10
+
11
+
12
+ class DatabaseQueryExecutorInterface(ABC):
13
+ """Execute parameterised SQL queries against a database."""
14
+
15
+ @abstractmethod
16
+ def fetch_all(self, sql: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]: ...
17
+
18
+ @abstractmethod
19
+ def fetch_one(
20
+ self, sql: str, params: dict[str, Any] | None = None
21
+ ) -> dict[str, Any] | None: ...
22
+
23
+ @abstractmethod
24
+ def write(self, sql: str, params: dict[str, Any] | None = None) -> int:
25
+ """Execute INSERT / UPDATE / DELETE.
26
+
27
+ Returns lastrowid for INSERT, affected rowcount for UPDATE/DELETE.
28
+ """
29
+ ...
30
+
31
+
32
+ class DatabaseTransactionManagerInterface(ABC):
33
+ """Manage database transactions.
34
+
35
+ High-level API: use transactional() — it commits on success and rolls back on exception.
36
+ Low-level API: begin() / commit() / rollback() for manual control.
37
+ """
38
+
39
+ @abstractmethod
40
+ def transactional[T](self, callback: Callable[[DatabaseQueryExecutorInterface], T]) -> T:
41
+ """Run callback inside a transaction; commit on success, rollback on exception."""
42
+ ...
43
+
44
+ @abstractmethod
45
+ def begin(self) -> None: ...
46
+
47
+ @abstractmethod
48
+ def commit(self) -> None: ...
49
+
50
+ @abstractmethod
51
+ def rollback(self) -> None: ...
@@ -0,0 +1,128 @@
1
+ """SQLAlchemy Core implementation of database interfaces.
2
+
3
+ Supports SQLite, MySQL, and PostgreSQL via SQLAlchemy's engine URL.
4
+ """
5
+
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from sqlalchemy import Connection, Engine, text
10
+ from sqlalchemy.exc import OperationalError
11
+
12
+ from .exceptions import DatabaseConnectionException
13
+ from .interfaces import DatabaseQueryExecutorInterface, DatabaseTransactionManagerInterface
14
+
15
+
16
+ class SqlAlchemyQueryExecutor(DatabaseQueryExecutorInterface):
17
+ """Execute queries using SQLAlchemy Core (connection-per-call, no ORM)."""
18
+
19
+ def __init__(self, engine: Engine) -> None:
20
+ self._engine = engine
21
+
22
+ def fetch_all(self, sql: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
23
+ try:
24
+ with self._engine.connect() as conn:
25
+ result = conn.execute(text(sql), params or {})
26
+ return [dict(row._mapping) for row in result]
27
+ except OperationalError as exc:
28
+ raise DatabaseConnectionException(str(exc)) from exc
29
+
30
+ def fetch_one(self, sql: str, params: dict[str, Any] | None = None) -> dict[str, Any] | None:
31
+ try:
32
+ with self._engine.connect() as conn:
33
+ result = conn.execute(text(sql), params or {})
34
+ row = result.fetchone()
35
+ return dict(row._mapping) if row else None
36
+ except OperationalError as exc:
37
+ raise DatabaseConnectionException(str(exc)) from exc
38
+
39
+ def write(self, sql: str, params: dict[str, Any] | None = None) -> int:
40
+ """Execute INSERT / UPDATE / DELETE and return a meaningful int.
41
+
42
+ Return value semantics:
43
+ - INSERT with AUTOINCREMENT/SERIAL column → ``lastrowid`` (the new row's PK, always > 0)
44
+ - INSERT without auto-PK, or multi-row INSERT → falls back to ``rowcount``
45
+ - UPDATE / DELETE → ``rowcount`` (number of rows affected; 0 means nothing matched)
46
+
47
+ Use the return value to detect missing rows::
48
+
49
+ affected = executor.write("UPDATE ... WHERE id = :id", {"id": pk})
50
+ if affected == 0:
51
+ raise NotFoundException(pk)
52
+ """
53
+ try:
54
+ with self._engine.begin() as conn:
55
+ result = conn.execute(text(sql), params or {})
56
+ return result.lastrowid or result.rowcount
57
+ except OperationalError as exc:
58
+ raise DatabaseConnectionException(str(exc)) from exc
59
+
60
+
61
+ class _BoundQueryExecutor(DatabaseQueryExecutorInterface):
62
+ """Query executor bound to an existing connection (within a transaction)."""
63
+
64
+ def __init__(self, conn: Connection) -> None:
65
+ self._conn = conn
66
+
67
+ def fetch_all(self, sql: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
68
+ try:
69
+ result = self._conn.execute(text(sql), params or {})
70
+ return [dict(row._mapping) for row in result]
71
+ except OperationalError as exc:
72
+ raise DatabaseConnectionException(str(exc)) from exc
73
+
74
+ def fetch_one(self, sql: str, params: dict[str, Any] | None = None) -> dict[str, Any] | None:
75
+ try:
76
+ result = self._conn.execute(text(sql), params or {})
77
+ row = result.fetchone()
78
+ return dict(row._mapping) if row else None
79
+ except OperationalError as exc:
80
+ raise DatabaseConnectionException(str(exc)) from exc
81
+
82
+ def write(self, sql: str, params: dict[str, Any] | None = None) -> int:
83
+ try:
84
+ result = self._conn.execute(text(sql), params or {})
85
+ except OperationalError as exc:
86
+ raise DatabaseConnectionException(str(exc)) from exc
87
+ return result.lastrowid or result.rowcount
88
+
89
+
90
+ class SqlAlchemyTransactionManager(DatabaseTransactionManagerInterface):
91
+ """Manage database transactions using SQLAlchemy.
92
+
93
+ Use transactional() for the recommended callback-based API.
94
+ Use begin() / commit() / rollback() for explicit transaction control.
95
+ """
96
+
97
+ def __init__(self, engine: Engine) -> None:
98
+ self._engine = engine
99
+ self._conn: Connection | None = None
100
+ # reason: RootTransaction is an internal SQLAlchemy type not exported in the public API
101
+ self._tx: Any = None
102
+
103
+ def transactional[T](self, callback: Callable[[DatabaseQueryExecutorInterface], T]) -> T:
104
+ try:
105
+ with self._engine.begin() as conn:
106
+ return callback(_BoundQueryExecutor(conn))
107
+ except OperationalError as exc:
108
+ raise DatabaseConnectionException(str(exc)) from exc
109
+
110
+ def begin(self) -> None:
111
+ self._conn = self._engine.connect()
112
+ self._tx = self._conn.begin()
113
+
114
+ def commit(self) -> None:
115
+ if self._tx is not None:
116
+ self._tx.commit()
117
+ self._tx = None
118
+ if self._conn is not None:
119
+ self._conn.close()
120
+ self._conn = None
121
+
122
+ def rollback(self) -> None:
123
+ if self._tx is not None:
124
+ self._tx.rollback()
125
+ self._tx = None
126
+ if self._conn is not None:
127
+ self._conn.close()
128
+ self._conn = None
nene2/http/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """HTTP helpers — JSON responses, pagination, problem details, health."""
2
+
3
+ from .health import HealthCheckProtocol, HealthStatus
4
+ from .pagination import PaginationQuery, PaginationQueryParser, PaginationResponse
5
+ from .problem_details import problem_details_response
6
+
7
+ __all__ = [
8
+ "HealthCheckProtocol",
9
+ "HealthStatus",
10
+ "PaginationQuery",
11
+ "PaginationQueryParser",
12
+ "PaginationResponse",
13
+ "problem_details_response",
14
+ ]
nene2/http/health.py ADDED
@@ -0,0 +1,20 @@
1
+ """HealthCheckProtocol and HealthStatus — framework health check contract."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Protocol
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class HealthStatus:
9
+ status: str
10
+ checks: dict[str, str] = field(default_factory=dict)
11
+
12
+ @property
13
+ def is_healthy(self) -> bool:
14
+ return self.status == "ok"
15
+
16
+
17
+ class HealthCheckProtocol(Protocol):
18
+ """Contract for application health checks."""
19
+
20
+ def check(self) -> HealthStatus: ...
@@ -0,0 +1,93 @@
1
+ """Pagination helpers.
2
+
3
+ Equivalent to PHP Nene2\\Http\\PaginationQueryParser, PaginationQuery, PaginationResponse.
4
+ """
5
+
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from fastapi import Request
10
+
11
+ from nene2.validation.exceptions import ValidationError, ValidationException
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class PaginationQuery:
16
+ """Parsed and validated ?limit= / ?offset= parameters."""
17
+
18
+ limit: int
19
+ offset: int
20
+
21
+
22
+ class PaginationQueryParser:
23
+ """Parses and validates pagination query parameters from a FastAPI Request."""
24
+
25
+ @staticmethod
26
+ def parse(
27
+ request: Request,
28
+ *,
29
+ default_limit: int = 20,
30
+ max_limit: int = 100,
31
+ ) -> PaginationQuery:
32
+ """Parse ?limit= and ?offset= from the request.
33
+
34
+ Raises ValidationException (→ 422) when values are out of range.
35
+ """
36
+ params = request.query_params
37
+
38
+ errors: list[ValidationError] = []
39
+ try:
40
+ limit = int(params.get("limit", default_limit))
41
+ except ValueError:
42
+ errors.append(ValidationError("limit", "limit must be an integer.", "invalid"))
43
+ limit = default_limit
44
+ try:
45
+ offset = int(params.get("offset", 0))
46
+ except ValueError:
47
+ errors.append(ValidationError("offset", "offset must be an integer.", "invalid"))
48
+ offset = 0
49
+
50
+ if limit < 1 or limit > max_limit:
51
+ errors.append(
52
+ ValidationError(
53
+ field="limit",
54
+ message=f"limit must be between 1 and {max_limit}.",
55
+ code="out_of_range",
56
+ )
57
+ )
58
+ if offset < 0:
59
+ errors.append(
60
+ ValidationError(
61
+ field="offset",
62
+ message="offset must be 0 or greater.",
63
+ code="out_of_range",
64
+ )
65
+ )
66
+ if errors:
67
+ raise ValidationException(errors)
68
+
69
+ return PaginationQuery(limit=limit, offset=offset)
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class PaginationResponse:
74
+ """Standard list-endpoint response envelope.
75
+
76
+ Equivalent to PHP Nene2\\Http\\PaginationResponse.
77
+ The ``total`` key is omitted from the output when not provided.
78
+ """
79
+
80
+ items: list[Any]
81
+ limit: int
82
+ offset: int
83
+ total: int | None = field(default=None)
84
+
85
+ def to_dict(self) -> dict[str, Any]:
86
+ data: dict[str, Any] = {
87
+ "items": self.items,
88
+ "limit": self.limit,
89
+ "offset": self.offset,
90
+ }
91
+ if self.total is not None:
92
+ data["total"] = self.total
93
+ return data
@@ -0,0 +1,37 @@
1
+ """RFC 9457 Problem Details response factory.
2
+
3
+ Equivalent to PHP Nene2\\Error\\ProblemDetailsResponseFactory.
4
+ """
5
+
6
+ from typing import Any
7
+
8
+ from fastapi.responses import JSONResponse
9
+
10
+ PROBLEM_DETAILS_BASE_URL = "https://nene2.dev/problems/"
11
+
12
+
13
+ def problem_details_response(
14
+ problem_type: str,
15
+ title: str,
16
+ status: int,
17
+ detail: str | None = None,
18
+ extra: dict[str, Any] | None = None,
19
+ *,
20
+ base_url: str = PROBLEM_DETAILS_BASE_URL,
21
+ ) -> JSONResponse:
22
+ """Build an RFC 9457 Problem Details JSON response."""
23
+ body: dict[str, Any] = {
24
+ "type": base_url + problem_type,
25
+ "title": title,
26
+ "status": status,
27
+ }
28
+ if detail:
29
+ body["detail"] = detail
30
+ if extra:
31
+ body.update(extra)
32
+
33
+ return JSONResponse(
34
+ content=body,
35
+ status_code=status,
36
+ media_type="application/problem+json",
37
+ )
nene2/log/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """NENE2 structured logging setup (structlog)."""
2
+
3
+ from .setup import setup_logging
4
+
5
+ __all__ = ["setup_logging"]