stacklink 0.1.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.
- stacklink/__init__.py +16 -0
- stacklink/config.py +162 -0
- stacklink/health.py +103 -0
- stacklink/logger.py +168 -0
- stacklink-0.1.0.dist-info/METADATA +149 -0
- stacklink-0.1.0.dist-info/RECORD +8 -0
- stacklink-0.1.0.dist-info/WHEEL +4 -0
- stacklink-0.1.0.dist-info/licenses/LICENSE +21 -0
stacklink/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""stacklink — health checks, config validation, and structured logging for FastAPI."""
|
|
2
|
+
|
|
3
|
+
from stacklink.config import Config, ConfigError, Field, StacklinkError
|
|
4
|
+
from stacklink.health import HealthError, health
|
|
5
|
+
from stacklink.logger import LoggerError, logger
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Config",
|
|
9
|
+
"ConfigError",
|
|
10
|
+
"Field",
|
|
11
|
+
"HealthError",
|
|
12
|
+
"LoggerError",
|
|
13
|
+
"StacklinkError",
|
|
14
|
+
"health",
|
|
15
|
+
"logger",
|
|
16
|
+
]
|
stacklink/config.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Configuration loading from environment variables and .env files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, cast, get_type_hints
|
|
8
|
+
|
|
9
|
+
from dotenv import find_dotenv, load_dotenv
|
|
10
|
+
|
|
11
|
+
# Sentinel to distinguish "no default" from None/False/0
|
|
12
|
+
_MISSING: Any = object() # Any: sentinel needs to be assignable anywhere
|
|
13
|
+
|
|
14
|
+
_BOOL_TRUE = frozenset({"true", "1", "yes"})
|
|
15
|
+
_BOOL_FALSE = frozenset({"false", "0", "no"})
|
|
16
|
+
_COERCIBLE_TYPES: dict[type, str] = {
|
|
17
|
+
str: "string",
|
|
18
|
+
int: "integer",
|
|
19
|
+
float: "float",
|
|
20
|
+
bool: "boolean",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StacklinkError(Exception):
|
|
25
|
+
"""Base exception for all stacklink errors."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConfigError(StacklinkError):
|
|
29
|
+
"""Raised when configuration loading or validation fails."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class _FieldMeta:
|
|
34
|
+
"""Internal metadata for a config field."""
|
|
35
|
+
|
|
36
|
+
required: bool
|
|
37
|
+
default: Any # Any: default can be any type matching the field
|
|
38
|
+
secret: bool
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def Field(
|
|
42
|
+
*,
|
|
43
|
+
required: bool = False,
|
|
44
|
+
default: Any = _MISSING, # Any: default can be any type matching the field
|
|
45
|
+
secret: bool = False,
|
|
46
|
+
) -> Any: # Any: return is assigned as a class-level default annotation
|
|
47
|
+
"""Declare metadata for a config field."""
|
|
48
|
+
return _FieldMeta(required=required, default=default, secret=secret)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Config:
|
|
52
|
+
"""Base class for typed configuration loaded from environment variables."""
|
|
53
|
+
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
"""Load .env file and populate fields from environment variables."""
|
|
56
|
+
load_dotenv(find_dotenv(usecwd=True), override=True)
|
|
57
|
+
hints = get_type_hints(type(self))
|
|
58
|
+
for name, field_type in hints.items():
|
|
59
|
+
meta = _extract_meta(type(self), name)
|
|
60
|
+
env_var = name.upper()
|
|
61
|
+
raw = os.environ.get(env_var)
|
|
62
|
+
value = _resolve_value(name, env_var, raw, meta, field_type)
|
|
63
|
+
object.__setattr__(self, name, value)
|
|
64
|
+
|
|
65
|
+
def secret_field_names(self) -> frozenset[str]:
|
|
66
|
+
"""Return names of all fields marked as secret."""
|
|
67
|
+
hints = get_type_hints(type(self))
|
|
68
|
+
return frozenset(
|
|
69
|
+
name for name in hints if _extract_meta(type(self), name).secret
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def as_dict(self) -> dict[str, Any]: # Any: field values can be any coercible type
|
|
73
|
+
"""Return all fields as a dictionary, redacting secret values."""
|
|
74
|
+
hints = get_type_hints(type(self))
|
|
75
|
+
result: dict[str, Any] = {}
|
|
76
|
+
for name in hints:
|
|
77
|
+
meta = _extract_meta(type(self), name)
|
|
78
|
+
value = getattr(self, name)
|
|
79
|
+
result[name] = "***" if meta.secret else value
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _extract_meta(cls: type, name: str) -> _FieldMeta:
|
|
84
|
+
"""Extract field metadata from a class attribute."""
|
|
85
|
+
raw = cls.__dict__.get(name, _MISSING)
|
|
86
|
+
if isinstance(raw, _FieldMeta):
|
|
87
|
+
return raw
|
|
88
|
+
return _FieldMeta(
|
|
89
|
+
required=False, default=raw if raw is not _MISSING else _MISSING, secret=False
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _resolve_value(
|
|
94
|
+
name: str,
|
|
95
|
+
env_var: str,
|
|
96
|
+
raw: str | None,
|
|
97
|
+
meta: _FieldMeta,
|
|
98
|
+
field_type: type,
|
|
99
|
+
) -> Any: # Any: return type varies by field_type
|
|
100
|
+
"""Resolve a field's value from env, default, or raise on missing required."""
|
|
101
|
+
if raw is not None:
|
|
102
|
+
return _coerce(name, env_var, raw, field_type, meta.secret)
|
|
103
|
+
if meta.required:
|
|
104
|
+
_raise_missing(name, env_var)
|
|
105
|
+
if meta.default is not _MISSING:
|
|
106
|
+
return meta.default
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _coerce(
|
|
111
|
+
name: str, env_var: str, raw: str, field_type: type, secret: bool
|
|
112
|
+
) -> Any: # Any: varies by field_type
|
|
113
|
+
"""Coerce a raw string to the target type."""
|
|
114
|
+
if field_type is str:
|
|
115
|
+
return raw
|
|
116
|
+
if field_type is bool:
|
|
117
|
+
return _coerce_bool(name, env_var, raw, secret)
|
|
118
|
+
return _coerce_numeric(name, env_var, raw, field_type, secret)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _coerce_bool(name: str, env_var: str, raw: str, secret: bool) -> bool:
|
|
122
|
+
"""Coerce a raw string to a boolean value."""
|
|
123
|
+
lower = raw.lower()
|
|
124
|
+
if lower in _BOOL_TRUE:
|
|
125
|
+
return True
|
|
126
|
+
if lower in _BOOL_FALSE:
|
|
127
|
+
return False
|
|
128
|
+
display = "***" if secret else repr(raw)
|
|
129
|
+
raise ConfigError(
|
|
130
|
+
f"Invalid boolean value for '{name}'.\n"
|
|
131
|
+
f"{env_var}={display} is not a recognized boolean.\n"
|
|
132
|
+
f"Use one of: true, false, 1, 0, yes, no."
|
|
133
|
+
) from None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _coerce_numeric(
|
|
137
|
+
name: str,
|
|
138
|
+
env_var: str,
|
|
139
|
+
raw: str,
|
|
140
|
+
field_type: type,
|
|
141
|
+
secret: bool,
|
|
142
|
+
) -> int | float:
|
|
143
|
+
"""Coerce a raw string to int or float."""
|
|
144
|
+
try:
|
|
145
|
+
return cast(int | float, field_type(raw))
|
|
146
|
+
except (ValueError, TypeError):
|
|
147
|
+
display = "***" if secret else repr(raw)
|
|
148
|
+
type_name = _COERCIBLE_TYPES.get(field_type, field_type.__name__)
|
|
149
|
+
raise ConfigError(
|
|
150
|
+
f"Invalid {type_name} value for '{name}'.\n"
|
|
151
|
+
f"{env_var}={display} cannot be converted to {type_name}.\n"
|
|
152
|
+
f"Set {env_var} to a valid {type_name}."
|
|
153
|
+
) from None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _raise_missing(name: str, env_var: str) -> None:
|
|
157
|
+
"""Raise ConfigError for a missing required field."""
|
|
158
|
+
raise ConfigError(
|
|
159
|
+
f"Required config field '{name}' is missing.\n"
|
|
160
|
+
f"Environment variable {env_var} is not set.\n"
|
|
161
|
+
f"Set {env_var} in your .env file or environment."
|
|
162
|
+
) from None
|
stacklink/health.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Health check endpoints for FastAPI applications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
|
|
11
|
+
from stacklink.config import Config, StacklinkError
|
|
12
|
+
|
|
13
|
+
CheckFn = Callable[[], Awaitable[bool]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HealthError(StacklinkError):
|
|
17
|
+
"""Raised when the health module encounters an error."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HealthRegistry:
|
|
21
|
+
"""Registry for health check endpoints and custom readiness checks."""
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
"""Initialize an empty health registry."""
|
|
25
|
+
self._checks: dict[str, CheckFn] = {}
|
|
26
|
+
self._start_time: float | None = None
|
|
27
|
+
self._service: str | None = None
|
|
28
|
+
self._env: str | None = None
|
|
29
|
+
|
|
30
|
+
def register(self, app: FastAPI, config: Config | None = None) -> None:
|
|
31
|
+
"""Attach /healthz and /readyz health check endpoints to a FastAPI app."""
|
|
32
|
+
self._start_time = time.monotonic()
|
|
33
|
+
self._extract_config(config)
|
|
34
|
+
app.add_api_route("/healthz", self._healthz, methods=["GET"])
|
|
35
|
+
app.add_api_route("/readyz", self._readyz, methods=["GET"])
|
|
36
|
+
|
|
37
|
+
def add_check(self, name: str, fn: CheckFn) -> None:
|
|
38
|
+
"""Register a custom async check function for readiness."""
|
|
39
|
+
self._checks[name] = fn
|
|
40
|
+
|
|
41
|
+
def _extract_config(self, config: Config | None) -> None:
|
|
42
|
+
"""Pull service and env from config if available."""
|
|
43
|
+
if config is None:
|
|
44
|
+
return
|
|
45
|
+
service = getattr(config, "service", None)
|
|
46
|
+
env = getattr(config, "env", None)
|
|
47
|
+
if isinstance(service, str):
|
|
48
|
+
self._service = service
|
|
49
|
+
if isinstance(env, str):
|
|
50
|
+
self._env = env
|
|
51
|
+
|
|
52
|
+
def _uptime(self) -> float:
|
|
53
|
+
"""Calculate seconds since register() was called."""
|
|
54
|
+
if self._start_time is None:
|
|
55
|
+
return 0.0
|
|
56
|
+
return round(time.monotonic() - self._start_time, 1)
|
|
57
|
+
|
|
58
|
+
def _base_response(self) -> dict[str, object]:
|
|
59
|
+
"""Build the common response fields."""
|
|
60
|
+
data: dict[str, object] = {}
|
|
61
|
+
if self._service is not None:
|
|
62
|
+
data["service"] = self._service
|
|
63
|
+
if self._env is not None:
|
|
64
|
+
data["env"] = self._env
|
|
65
|
+
data["uptime_seconds"] = self._uptime()
|
|
66
|
+
return data
|
|
67
|
+
|
|
68
|
+
async def _healthz(self) -> JSONResponse:
|
|
69
|
+
"""Handle GET /healthz -- liveness check."""
|
|
70
|
+
data = self._base_response()
|
|
71
|
+
data["status"] = "ok"
|
|
72
|
+
return JSONResponse(content=data, status_code=200)
|
|
73
|
+
|
|
74
|
+
async def _readyz(self) -> JSONResponse:
|
|
75
|
+
"""Handle GET /readyz -- readiness check."""
|
|
76
|
+
check_results = await self._run_checks()
|
|
77
|
+
all_ok = all(v == "ok" for v in check_results.values())
|
|
78
|
+
status = "ok" if all_ok else "degraded"
|
|
79
|
+
status_code = 200 if all_ok else 503
|
|
80
|
+
|
|
81
|
+
data = self._base_response()
|
|
82
|
+
data["status"] = status
|
|
83
|
+
data["checks"] = check_results
|
|
84
|
+
return JSONResponse(content=data, status_code=status_code)
|
|
85
|
+
|
|
86
|
+
async def _run_checks(self) -> dict[str, str]:
|
|
87
|
+
"""Execute all registered checks and collect results."""
|
|
88
|
+
results: dict[str, str] = {}
|
|
89
|
+
for name, fn in self._checks.items():
|
|
90
|
+
results[name] = await _execute_check(fn)
|
|
91
|
+
return results
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _execute_check(fn: CheckFn) -> str:
|
|
95
|
+
"""Run a single check function, returning 'ok' or 'failing'."""
|
|
96
|
+
try:
|
|
97
|
+
result = await fn()
|
|
98
|
+
return "ok" if result else "failing"
|
|
99
|
+
except Exception:
|
|
100
|
+
return "failing"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
health = HealthRegistry()
|
stacklink/logger.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Structured JSON logging with optional config-based tagging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from stacklink.config import Config, StacklinkError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LoggerError(StacklinkError):
|
|
16
|
+
"""Raised when the logger encounters an error."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _LoggerState:
|
|
20
|
+
"""Mutable state shared between the logger and its formatter."""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
"""Initialize empty logger state."""
|
|
24
|
+
self.service: str | None = None
|
|
25
|
+
self.env: str | None = None
|
|
26
|
+
self.secret_names: frozenset[str] = frozenset()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _JsonFormatter(logging.Formatter):
|
|
30
|
+
"""Formats log records as single-line JSON objects."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, logger_state: _LoggerState) -> None:
|
|
33
|
+
"""Bind formatter to shared logger state."""
|
|
34
|
+
super().__init__()
|
|
35
|
+
self._state = logger_state
|
|
36
|
+
|
|
37
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
38
|
+
"""Serialize a log record to a JSON string."""
|
|
39
|
+
entry = _build_entry(record, self._state)
|
|
40
|
+
return json.dumps(entry, default=str)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _build_entry(
|
|
44
|
+
record: logging.LogRecord,
|
|
45
|
+
state: _LoggerState,
|
|
46
|
+
) -> dict[str, Any]: # Any: log values can be any JSON-serializable type
|
|
47
|
+
"""Assemble the JSON dict for one log line."""
|
|
48
|
+
entry: dict[str, Any] = { # Any: mixed value types in log output
|
|
49
|
+
"timestamp": _utc_timestamp(),
|
|
50
|
+
"level": record.levelname,
|
|
51
|
+
}
|
|
52
|
+
if state.service is not None:
|
|
53
|
+
entry["service"] = state.service
|
|
54
|
+
if state.env is not None:
|
|
55
|
+
entry["env"] = state.env
|
|
56
|
+
entry["message"] = record.getMessage()
|
|
57
|
+
extras: dict[str, Any] = getattr(
|
|
58
|
+
record, "_extra_kwargs", {}
|
|
59
|
+
) # Any: user-supplied kwargs
|
|
60
|
+
entry.update(_filter_secrets(extras, state.secret_names))
|
|
61
|
+
return entry
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _utc_timestamp() -> str:
|
|
65
|
+
"""Return the current UTC time in ISO 8601 format."""
|
|
66
|
+
now = datetime.now(timezone.utc)
|
|
67
|
+
return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _filter_secrets(
|
|
71
|
+
data: dict[str, Any], # Any: user-supplied kwargs
|
|
72
|
+
secret_names: frozenset[str],
|
|
73
|
+
) -> dict[str, Any]: # Any: filtered kwargs
|
|
74
|
+
"""Remove any keys whose names match secret config fields."""
|
|
75
|
+
return {k: v for k, v in data.items() if k not in secret_names}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _get_log_level() -> int:
|
|
79
|
+
"""Read LOG_LEVEL from env and map to a logging constant."""
|
|
80
|
+
level_name = os.environ.get("LOG_LEVEL", "INFO").upper()
|
|
81
|
+
level_map: dict[str, int] = {
|
|
82
|
+
"DEBUG": logging.DEBUG,
|
|
83
|
+
"INFO": logging.INFO,
|
|
84
|
+
"WARNING": logging.WARNING,
|
|
85
|
+
"ERROR": logging.ERROR,
|
|
86
|
+
}
|
|
87
|
+
level = level_map.get(level_name)
|
|
88
|
+
if level is None:
|
|
89
|
+
raise LoggerError(
|
|
90
|
+
f"Invalid LOG_LEVEL '{level_name}'.\n"
|
|
91
|
+
f"LOG_LEVEL must be one of: DEBUG, INFO, WARNING, ERROR.\n"
|
|
92
|
+
f"Set LOG_LEVEL to a valid level or remove it to default to INFO."
|
|
93
|
+
) from None
|
|
94
|
+
return level
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class StacklinkLogger:
|
|
98
|
+
"""Singleton structured JSON logger for stacklink."""
|
|
99
|
+
|
|
100
|
+
def __init__(self) -> None:
|
|
101
|
+
"""Set up the underlying Python logger with a JSON handler."""
|
|
102
|
+
self._state = _LoggerState()
|
|
103
|
+
self._logger = logging.getLogger("stacklink")
|
|
104
|
+
self._logger.handlers.clear()
|
|
105
|
+
self._logger.propagate = False
|
|
106
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
107
|
+
handler.setFormatter(_JsonFormatter(self._state))
|
|
108
|
+
self._logger.addHandler(handler)
|
|
109
|
+
self._logger.setLevel(_get_log_level())
|
|
110
|
+
|
|
111
|
+
def configure(self, config: Config) -> None:
|
|
112
|
+
"""Attach service and env from a Config instance to all future log lines."""
|
|
113
|
+
self._state.secret_names = config.secret_field_names()
|
|
114
|
+
service = getattr(config, "service", None)
|
|
115
|
+
env = getattr(config, "env", None)
|
|
116
|
+
if isinstance(service, str):
|
|
117
|
+
self._state.service = service
|
|
118
|
+
if isinstance(env, str):
|
|
119
|
+
self._state.env = env
|
|
120
|
+
|
|
121
|
+
def info(
|
|
122
|
+
self, message: str, **kwargs: Any
|
|
123
|
+
) -> None: # Any: arbitrary structured data
|
|
124
|
+
"""Log a message at INFO level with optional structured fields."""
|
|
125
|
+
self._log(logging.INFO, message, kwargs)
|
|
126
|
+
|
|
127
|
+
def warning(
|
|
128
|
+
self, message: str, **kwargs: Any
|
|
129
|
+
) -> None: # Any: arbitrary structured data
|
|
130
|
+
"""Log a message at WARNING level with optional structured fields."""
|
|
131
|
+
self._log(logging.WARNING, message, kwargs)
|
|
132
|
+
|
|
133
|
+
def error(
|
|
134
|
+
self, message: str, **kwargs: Any
|
|
135
|
+
) -> None: # Any: arbitrary structured data
|
|
136
|
+
"""Log a message at ERROR level with optional structured fields."""
|
|
137
|
+
self._log(logging.ERROR, message, kwargs)
|
|
138
|
+
|
|
139
|
+
def debug(
|
|
140
|
+
self, message: str, **kwargs: Any
|
|
141
|
+
) -> None: # Any: arbitrary structured data
|
|
142
|
+
"""Log a message at DEBUG level with optional structured fields."""
|
|
143
|
+
self._log(logging.DEBUG, message, kwargs)
|
|
144
|
+
|
|
145
|
+
def _log(
|
|
146
|
+
self,
|
|
147
|
+
level: int,
|
|
148
|
+
message: str,
|
|
149
|
+
extra_kwargs: dict[str, Any], # Any: user-supplied structured data
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Dispatch a log record with extra kwargs attached."""
|
|
152
|
+
self._logger.setLevel(_get_log_level())
|
|
153
|
+
if not self._logger.isEnabledFor(level):
|
|
154
|
+
return
|
|
155
|
+
record = self._logger.makeRecord(
|
|
156
|
+
name="stacklink",
|
|
157
|
+
level=level,
|
|
158
|
+
fn="",
|
|
159
|
+
lno=0,
|
|
160
|
+
msg=message,
|
|
161
|
+
args=(),
|
|
162
|
+
exc_info=None,
|
|
163
|
+
)
|
|
164
|
+
record._extra_kwargs = extra_kwargs # dynamic attr for formatter
|
|
165
|
+
self._logger.handle(record)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
logger = StacklinkLogger()
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stacklink
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Health checks, config validation, and structured logging for FastAPI
|
|
5
|
+
Project-URL: Homepage, https://github.com/dressupdarling/stacklink
|
|
6
|
+
Project-URL: Repository, https://github.com/dressupdarling/stacklink
|
|
7
|
+
Project-URL: Issues, https://github.com/dressupdarling/stacklink/issues
|
|
8
|
+
Author-email: Yevgeny <yevgenyokun2@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: config,fastapi,health-check,logging,structured-logging
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: fastapi>=0.100.0
|
|
23
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: black>=23.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: build>=1.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: httpx>=0.24.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# stacklink
|
|
36
|
+
|
|
37
|
+
Health checks, config validation, and structured logging for FastAPI — in three lines of code.
|
|
38
|
+
|
|
39
|
+
Built for solo devs and small teams who want production-grade infrastructure without the setup overhead.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install stacklink
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from fastapi import FastAPI
|
|
51
|
+
from stacklink import Config, Field, health, logger
|
|
52
|
+
|
|
53
|
+
app = FastAPI()
|
|
54
|
+
|
|
55
|
+
class AppConfig(Config):
|
|
56
|
+
env: str = Field(required=True)
|
|
57
|
+
service: str = Field(default="my-api")
|
|
58
|
+
port: int = Field(default=8080)
|
|
59
|
+
db_url: str = Field(required=True, secret=True)
|
|
60
|
+
|
|
61
|
+
config = AppConfig()
|
|
62
|
+
|
|
63
|
+
logger.configure(config)
|
|
64
|
+
logger.info("server starting", port=config.port)
|
|
65
|
+
|
|
66
|
+
health.register(app, config=config)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
That's it. You now have:
|
|
70
|
+
|
|
71
|
+
- Validated, typed config from `.env` and environment variables
|
|
72
|
+
- Structured JSON logging to stdout
|
|
73
|
+
- `/healthz` and `/readyz` endpoints on your FastAPI app
|
|
74
|
+
|
|
75
|
+
## Modules
|
|
76
|
+
|
|
77
|
+
### Config
|
|
78
|
+
|
|
79
|
+
Reads `.env` files and environment variables. Validates types. Fails loudly at startup if anything is wrong. Redacts secrets from output.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from stacklink import Config, Field
|
|
83
|
+
|
|
84
|
+
class AppConfig(Config):
|
|
85
|
+
env: str = Field(required=True)
|
|
86
|
+
port: int = Field(default=8080)
|
|
87
|
+
db_url: str = Field(required=True, secret=True)
|
|
88
|
+
|
|
89
|
+
config = AppConfig()
|
|
90
|
+
config.as_dict() # {"env": "production", "port": 8080, "db_url": "***"}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Supported types: `str`, `int`, `float`, `bool`. Bool coerces from `true/false/1/0/yes/no`.
|
|
94
|
+
|
|
95
|
+
### Logger
|
|
96
|
+
|
|
97
|
+
Structured JSON logging. Every line is machine-parseable. Auto-tags with service name and environment when configured.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from stacklink import logger
|
|
101
|
+
|
|
102
|
+
logger.info("request handled", method="GET", path="/users", status=200)
|
|
103
|
+
# {"timestamp": "...", "level": "INFO", "message": "request handled", "method": "GET", "path": "/users", "status": 200}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Set `LOG_LEVEL` env var to control filtering (default: `INFO`).
|
|
107
|
+
|
|
108
|
+
### Health
|
|
109
|
+
|
|
110
|
+
Registers `/healthz` (liveness) and `/readyz` (readiness) endpoints on your FastAPI app.
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from stacklink import health
|
|
114
|
+
|
|
115
|
+
health.register(app, config=config)
|
|
116
|
+
|
|
117
|
+
async def check_database():
|
|
118
|
+
try:
|
|
119
|
+
await db.execute("SELECT 1")
|
|
120
|
+
return True
|
|
121
|
+
except Exception:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
health.add_check("database", check_database)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`/healthz` always returns `200`. `/readyz` returns `200` when all checks pass, `503` when any check fails.
|
|
128
|
+
|
|
129
|
+
## Documentation
|
|
130
|
+
|
|
131
|
+
- [Config](docs/config.md)
|
|
132
|
+
- [Logger](docs/logger.md)
|
|
133
|
+
- [Health](docs/health.md)
|
|
134
|
+
|
|
135
|
+
## Requirements
|
|
136
|
+
|
|
137
|
+
- Python 3.10+
|
|
138
|
+
- FastAPI 0.100+
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
pip install -e ".[dev]"
|
|
144
|
+
pytest
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
stacklink/__init__.py,sha256=tliqTCFN_2mB64vG3h9PF_0sHiqEeO6KtkTUGwoUxw4,410
|
|
2
|
+
stacklink/config.py,sha256=EcyBvqftADYsCxoME-N5unAiMPtkRYDNoF4CiQDe_Vc,5272
|
|
3
|
+
stacklink/health.py,sha256=KXkO6gnXMPCF4tdWLBuNmHWwK1A_gQrk7pFI2BNl6Fw,3532
|
|
4
|
+
stacklink/logger.py,sha256=qIW91YGf7D9yjr5A2nnn7Pj3FNbFZ4Qdeq81mjx-W4c,5643
|
|
5
|
+
stacklink-0.1.0.dist-info/METADATA,sha256=FDokkwrbnQ3lTqfbSeouLEIIQEaRDu5padXBJA6PpzM,3980
|
|
6
|
+
stacklink-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
stacklink-0.1.0.dist-info/licenses/LICENSE,sha256=6buXR5E8cfifGgwqI3PACDnT--AC0obp0c_VdsQ15ZI,1070
|
|
8
|
+
stacklink-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cookies_lover
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|