oe-python-template 0.10.1__tar.gz → 0.10.3__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 (35) hide show
  1. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/PKG-INFO +1 -1
  2. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/pyproject.toml +2 -2
  3. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/hello/_service.py +1 -1
  4. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/system/__init__.py +2 -0
  5. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/system/_api.py +41 -3
  6. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/system/_service.py +21 -2
  7. oe_python_template-0.10.3/src/oe_python_template/system/_settings.py +31 -0
  8. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/utils/__init__.py +3 -1
  9. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/utils/_constants.py +1 -1
  10. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/utils/_logfire.py +5 -4
  11. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/utils/_sentry.py +5 -4
  12. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/utils/_settings.py +13 -1
  13. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/.gitignore +0 -0
  14. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/LICENSE +0 -0
  15. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/README.md +0 -0
  16. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/__init__.py +0 -0
  17. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/api.py +0 -0
  18. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/cli.py +0 -0
  19. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/constants.py +0 -0
  20. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/hello/__init__.py +0 -0
  21. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/hello/_api.py +0 -0
  22. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/hello/_cli.py +0 -0
  23. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/hello/_constants.py +0 -0
  24. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/hello/_models.py +0 -0
  25. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/hello/_settings.py +0 -0
  26. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/system/_cli.py +0 -0
  27. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/utils/_api.py +0 -0
  28. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/utils/_cli.py +0 -0
  29. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/utils/_console.py +0 -0
  30. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/utils/_di.py +0 -0
  31. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/utils/_health.py +0 -0
  32. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/utils/_log.py +0 -0
  33. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/utils/_process.py +0 -0
  34. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/utils/_service.py +0 -0
  35. {oe_python_template-0.10.1 → oe_python_template-0.10.3}/src/oe_python_template/utils/boot.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oe-python-template
3
- Version: 0.10.1
3
+ Version: 0.10.3
4
4
  Summary: 🧠 Copier template to scaffold Python projects compliant with best practices and modern tooling.
5
5
  Project-URL: Homepage, https://oe-python-template.readthedocs.io/en/latest/
6
6
  Project-URL: Documentation, https://oe-python-template.readthedocs.io/en/latest/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "oe-python-template"
3
- version = "0.10.1"
3
+ version = "0.10.3"
4
4
  description = "🧠 Copier template to scaffold Python projects compliant with best practices and modern tooling."
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Helmut Hoffer von Ankershoffen", email = "helmuthva@gmail.com" }]
@@ -275,7 +275,7 @@ source = ["src/"]
275
275
 
276
276
 
277
277
  [tool.bumpversion]
278
- current_version = "0.10.1"
278
+ current_version = "0.10.3"
279
279
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
280
280
  serialize = ["{major}.{minor}.{patch}"]
281
281
  search = "{current_version}"
@@ -23,7 +23,7 @@ class Service(BaseService):
23
23
 
24
24
  def __init__(self) -> None:
25
25
  """Initialize service."""
26
- super().__init__(Settings)
26
+ super().__init__(Settings) # automatically loads and validates the settings
27
27
 
28
28
  def info(self) -> dict[str, Any]: # noqa: PLR6301
29
29
  """Determine info of this service.
@@ -3,9 +3,11 @@
3
3
  from ._api import api_routers
4
4
  from ._cli import cli
5
5
  from ._service import Service
6
+ from ._settings import Settings
6
7
 
7
8
  __all__ = [
8
9
  "Service",
10
+ "Settings",
9
11
  "api_routers",
10
12
  "cli",
11
13
  ]
@@ -7,7 +7,7 @@ The endpoints use Pydantic models for request and response validation.
7
7
  """
8
8
 
9
9
  from collections.abc import Callable, Generator
10
- from typing import Annotated
10
+ from typing import Annotated, Any
11
11
 
12
12
  from fastapi import APIRouter, Depends, Response, status
13
13
 
@@ -41,11 +41,11 @@ def register_health_endpoint(router: APIRouter) -> Callable[..., Health]:
41
41
  """
42
42
 
43
43
  @router.get("/healthz")
44
- @router.get("/health")
44
+ @router.get("/system/health")
45
45
  def health_endpoint(service: Annotated[Service, Depends(get_service)], response: Response) -> Health:
46
46
  """Determine aggregate health of the system.
47
47
 
48
- The health is aggregated from all modules that make
48
+ The health is aggregated from all modules making
49
49
  up this system including external dependencies.
50
50
 
51
51
  The response is to be interpreted as follows:
@@ -71,8 +71,46 @@ def register_health_endpoint(router: APIRouter) -> Callable[..., Health]:
71
71
  return health_endpoint
72
72
 
73
73
 
74
+ def register_info_endpoint(router: APIRouter) -> Callable[..., dict[str, Any]]:
75
+ """Register info endpoint to the given router.
76
+
77
+ Args:
78
+ router: The router to register the info endpoint to.
79
+
80
+ Returns:
81
+ Callable[..., Health]: The health endpoint function.
82
+ """
83
+
84
+ @router.get("/system/info")
85
+ def info_endpoint(
86
+ service: Annotated[Service, Depends(get_service)], response: Response, token: str
87
+ ) -> dict[str, Any]:
88
+ """Determine aggregate info of the system.
89
+
90
+ The info is aggregated from all modules making up this system.
91
+
92
+ If the token does not match the setting, a 403 Forbidden status code is returned.
93
+
94
+ Args:
95
+ service (Service): The service instance.
96
+ response (Response): The FastAPI response object.
97
+ token (str): Token to present.
98
+
99
+ Returns:
100
+ dict[str, Any]: The aggregate info of the system.
101
+ """
102
+ if service.is_token_valid(token):
103
+ return service.info(include_environ=True, filter_secrets=False)
104
+
105
+ response.status_code = status.HTTP_403_FORBIDDEN
106
+ return {"error": "Forbidden"}
107
+
108
+ return info_endpoint
109
+
110
+
74
111
  api_routers = {}
75
112
  for version in API_VERSIONS:
76
113
  router = VersionedAPIRouter(version, tags=["system"])
77
114
  api_routers[version] = router
78
115
  health = register_health_endpoint(api_routers[version])
116
+ info = register_info_endpoint(api_routers[version])
@@ -11,6 +11,7 @@ from typing import Any
11
11
  from pydantic_settings import BaseSettings
12
12
 
13
13
  from ..utils import ( # noqa: TID252
14
+ UNHIDE_SENSITIVE_INFO,
14
15
  BaseService,
15
16
  Health,
16
17
  __env__,
@@ -18,18 +19,24 @@ from ..utils import ( # noqa: TID252
18
19
  __project_path__,
19
20
  __repository_url__,
20
21
  __version__,
22
+ get_logger,
21
23
  get_process_info,
22
24
  load_settings,
23
25
  locate_subclasses,
24
26
  )
27
+ from ._settings import Settings
28
+
29
+ logger = get_logger(__name__)
25
30
 
26
31
 
27
32
  class Service(BaseService):
28
33
  """System service."""
29
34
 
35
+ _settings: Settings
36
+
30
37
  def __init__(self) -> None:
31
38
  """Initialize service."""
32
- super().__init__()
39
+ super().__init__(Settings)
33
40
 
34
41
  @staticmethod
35
42
  def _is_healthy() -> bool:
@@ -60,6 +67,18 @@ class Service(BaseService):
60
67
  reason = None if self._is_healthy() else "System marked as unhealthy"
61
68
  return Health(status=status, components=components, reason=reason)
62
69
 
70
+ def is_token_valid(self, token: str) -> bool:
71
+ """Check if the presented token is valid.
72
+
73
+ Returns:
74
+ bool: True if the token is valid, False otherwise.
75
+ """
76
+ logger.info(token)
77
+ if not self._settings.token:
78
+ logger.warning("Token is not set in settings.")
79
+ return False
80
+ return token == self._settings.token.get_secret_value()
81
+
63
82
  @staticmethod
64
83
  def info(include_environ: bool = False, filter_secrets: bool = True) -> dict[str, Any]:
65
84
  """
@@ -126,7 +145,7 @@ class Service(BaseService):
126
145
  for settings_class in locate_subclasses(BaseSettings):
127
146
  settings_instance = load_settings(settings_class)
128
147
  env_prefix = settings_instance.model_config.get("env_prefix", "")
129
- settings_dict = json.loads(settings_instance.model_dump_json())
148
+ settings_dict = settings_instance.model_dump(context={UNHIDE_SENSITIVE_INFO: not filter_secrets})
130
149
  for key, value in settings_dict.items():
131
150
  flat_key = f"{env_prefix}{key}".upper()
132
151
  settings[flat_key] = value
@@ -0,0 +1,31 @@
1
+ """Settings of the system module."""
2
+
3
+ from typing import Annotated
4
+
5
+ from pydantic import Field, PlainSerializer, SecretStr
6
+ from pydantic_settings import SettingsConfigDict
7
+
8
+ from ..utils import OpaqueSettings, __env_file__, __project_name__ # noqa: TID252
9
+
10
+
11
+ class Settings(OpaqueSettings):
12
+ """Settings."""
13
+
14
+ model_config = SettingsConfigDict(
15
+ env_prefix=f"{__project_name__.upper()}_SYSTEM_",
16
+ extra="ignore",
17
+ env_file=__env_file__,
18
+ env_file_encoding="utf-8",
19
+ )
20
+
21
+ token: Annotated[
22
+ SecretStr | None,
23
+ PlainSerializer(func=OpaqueSettings.serialize_sensitive_info, return_type=str, when_used="always"),
24
+ Field(
25
+ description=(
26
+ "Secret token to present when performing sensitive operations such as "
27
+ "retrieving info via webservice API"
28
+ ),
29
+ default=None,
30
+ ),
31
+ ]
@@ -23,15 +23,17 @@ from ._logfire import LogfireSettings
23
23
  from ._process import ProcessInfo, get_process_info
24
24
  from ._sentry import SentrySettings
25
25
  from ._service import BaseService
26
- from ._settings import load_settings
26
+ from ._settings import UNHIDE_SENSITIVE_INFO, OpaqueSettings, load_settings
27
27
  from .boot import boot
28
28
 
29
29
  __all__ = [
30
+ "UNHIDE_SENSITIVE_INFO",
30
31
  "BaseService",
31
32
  "Health",
32
33
  "LogSettings",
33
34
  "LogSettings",
34
35
  "LogfireSettings",
36
+ "OpaqueSettings",
35
37
  "ProcessInfo",
36
38
  "SentrySettings",
37
39
  "VersionedAPIRouter",
@@ -10,7 +10,7 @@ __project_path__ = str(Path(__file__).parent.parent.parent)
10
10
  __version__ = metadata.version(__project_name__)
11
11
  __is_development_mode__ = "uvx" not in sys.argv[0].lower()
12
12
  __is_running_in_container__ = os.getenv(f"{__project_name__.upper()}_RUNNING_IN_CONTAINER")
13
- __env__ = os.getenv("ENV", "local")
13
+ __env__ = os.getenv("ENV", os.getenv("VERCEL_ENV", "local"))
14
14
  __env_file__ = [
15
15
  Path.home() / f".{__project_name__}" / ".env",
16
16
  Path.home() / f".{__project_name__}" / f".env.{__env__}",
@@ -3,14 +3,14 @@
3
3
  from typing import Annotated
4
4
 
5
5
  import logfire
6
- from pydantic import Field, SecretStr
7
- from pydantic_settings import BaseSettings, SettingsConfigDict
6
+ from pydantic import Field, PlainSerializer, SecretStr
7
+ from pydantic_settings import SettingsConfigDict
8
8
 
9
9
  from ._constants import __env__, __env_file__, __project_name__, __repository_url__, __version__
10
- from ._settings import load_settings
10
+ from ._settings import OpaqueSettings, load_settings
11
11
 
12
12
 
13
- class LogfireSettings(BaseSettings):
13
+ class LogfireSettings(OpaqueSettings):
14
14
  """Configuration settings for Logfire integration."""
15
15
 
16
16
  model_config = SettingsConfigDict(
@@ -22,6 +22,7 @@ class LogfireSettings(BaseSettings):
22
22
 
23
23
  token: Annotated[
24
24
  SecretStr | None,
25
+ PlainSerializer(func=OpaqueSettings.serialize_sensitive_info, return_type=str, when_used="always"),
25
26
  Field(description="Logfire token. Leave empty to disable logfire.", examples=["YOUR_TOKEN"], default=None),
26
27
  ]
27
28
  instrument_system_metrics: Annotated[
@@ -3,15 +3,15 @@
3
3
  from typing import Annotated
4
4
 
5
5
  import sentry_sdk
6
- from pydantic import Field, SecretStr
7
- from pydantic_settings import BaseSettings, SettingsConfigDict
6
+ from pydantic import Field, PlainSerializer, SecretStr
7
+ from pydantic_settings import SettingsConfigDict
8
8
  from sentry_sdk.integrations.typer import TyperIntegration
9
9
 
10
10
  from ._constants import __env__, __env_file__, __project_name__, __version__
11
- from ._settings import load_settings
11
+ from ._settings import OpaqueSettings, load_settings
12
12
 
13
13
 
14
- class SentrySettings(BaseSettings):
14
+ class SentrySettings(OpaqueSettings):
15
15
  """Configuration settings for Sentry integration."""
16
16
 
17
17
  model_config = SettingsConfigDict(
@@ -23,6 +23,7 @@ class SentrySettings(BaseSettings):
23
23
 
24
24
  dsn: Annotated[
25
25
  SecretStr | None,
26
+ PlainSerializer(func=OpaqueSettings.serialize_sensitive_info, return_type=str, when_used="always"),
26
27
  Field(description="Sentry DSN", examples=["https://SECRET@SECRET.ingest.de.sentry.io/SECRET"], default=None),
27
28
  ]
28
29
 
@@ -6,7 +6,7 @@ import sys
6
6
  from pathlib import Path
7
7
  from typing import TypeVar
8
8
 
9
- from pydantic import ValidationError
9
+ from pydantic import FieldSerializationInfo, SecretStr, ValidationError
10
10
  from pydantic_settings import BaseSettings
11
11
  from rich.panel import Panel
12
12
  from rich.text import Text
@@ -17,6 +17,18 @@ T = TypeVar("T", bound=BaseSettings)
17
17
 
18
18
  logger = logging.getLogger(__name__)
19
19
 
20
+ UNHIDE_SENSITIVE_INFO = "unhide_sensitive_info"
21
+
22
+
23
+ class OpaqueSettings(BaseSettings):
24
+ @staticmethod
25
+ def serialize_sensitive_info(input_value: SecretStr, info: FieldSerializationInfo) -> str | None:
26
+ if not input_value:
27
+ return None
28
+ if info.context.get(UNHIDE_SENSITIVE_INFO, False): # type: ignore
29
+ return input_value.get_secret_value()
30
+ return str(input_value)
31
+
20
32
 
21
33
  def load_settings(settings_class: type[T]) -> T:
22
34
  """