oe-python-template-example 0.3.5__py3-none-any.whl → 0.3.6__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.
- oe_python_template_example/__init__.py +4 -18
- oe_python_template_example/api.py +42 -148
- oe_python_template_example/cli.py +13 -141
- oe_python_template_example/constants.py +6 -9
- oe_python_template_example/hello/__init__.py +17 -0
- oe_python_template_example/hello/_api.py +94 -0
- oe_python_template_example/hello/_cli.py +47 -0
- oe_python_template_example/hello/_constants.py +4 -0
- oe_python_template_example/hello/_models.py +28 -0
- oe_python_template_example/hello/_service.py +96 -0
- oe_python_template_example/{settings.py → hello/_settings.py} +6 -4
- oe_python_template_example/system/__init__.py +19 -0
- oe_python_template_example/system/_api.py +116 -0
- oe_python_template_example/system/_cli.py +165 -0
- oe_python_template_example/system/_service.py +182 -0
- oe_python_template_example/system/_settings.py +31 -0
- oe_python_template_example/utils/__init__.py +59 -0
- oe_python_template_example/utils/_api.py +18 -0
- oe_python_template_example/utils/_cli.py +68 -0
- oe_python_template_example/utils/_console.py +14 -0
- oe_python_template_example/utils/_constants.py +48 -0
- oe_python_template_example/utils/_di.py +70 -0
- oe_python_template_example/utils/_health.py +107 -0
- oe_python_template_example/utils/_log.py +122 -0
- oe_python_template_example/utils/_logfire.py +68 -0
- oe_python_template_example/utils/_process.py +41 -0
- oe_python_template_example/utils/_sentry.py +97 -0
- oe_python_template_example/utils/_service.py +39 -0
- oe_python_template_example/utils/_settings.py +80 -0
- oe_python_template_example/utils/boot.py +86 -0
- {oe_python_template_example-0.3.5.dist-info → oe_python_template_example-0.3.6.dist-info}/METADATA +77 -51
- oe_python_template_example-0.3.6.dist-info/RECORD +35 -0
- oe_python_template_example/models.py +0 -44
- oe_python_template_example/service.py +0 -68
- oe_python_template_example-0.3.5.dist-info/RECORD +0 -12
- {oe_python_template_example-0.3.5.dist-info → oe_python_template_example-0.3.6.dist-info}/WHEEL +0 -0
- {oe_python_template_example-0.3.5.dist-info → oe_python_template_example-0.3.6.dist-info}/entry_points.txt +0 -0
- {oe_python_template_example-0.3.5.dist-info → oe_python_template_example-0.3.6.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,59 @@
|
|
1
|
+
"""Utilities module."""
|
2
|
+
|
3
|
+
from ._api import VersionedAPIRouter
|
4
|
+
from ._cli import prepare_cli
|
5
|
+
from ._console import console
|
6
|
+
from ._constants import (
|
7
|
+
__author_email__,
|
8
|
+
__author_name__,
|
9
|
+
__documentation__url__,
|
10
|
+
__env__,
|
11
|
+
__env_file__,
|
12
|
+
__is_development_mode__,
|
13
|
+
__is_running_in_container__,
|
14
|
+
__project_name__,
|
15
|
+
__project_path__,
|
16
|
+
__repository_url__,
|
17
|
+
__version__,
|
18
|
+
)
|
19
|
+
from ._di import locate_implementations, locate_subclasses
|
20
|
+
from ._health import Health
|
21
|
+
from ._log import LogSettings, get_logger
|
22
|
+
from ._logfire import LogfireSettings
|
23
|
+
from ._process import ProcessInfo, get_process_info
|
24
|
+
from ._sentry import SentrySettings
|
25
|
+
from ._service import BaseService
|
26
|
+
from ._settings import UNHIDE_SENSITIVE_INFO, OpaqueSettings, load_settings
|
27
|
+
from .boot import boot
|
28
|
+
|
29
|
+
__all__ = [
|
30
|
+
"UNHIDE_SENSITIVE_INFO",
|
31
|
+
"BaseService",
|
32
|
+
"Health",
|
33
|
+
"LogSettings",
|
34
|
+
"LogSettings",
|
35
|
+
"LogfireSettings",
|
36
|
+
"OpaqueSettings",
|
37
|
+
"ProcessInfo",
|
38
|
+
"SentrySettings",
|
39
|
+
"VersionedAPIRouter",
|
40
|
+
"__author_email__",
|
41
|
+
"__author_name__",
|
42
|
+
"__documentation__url__",
|
43
|
+
"__env__",
|
44
|
+
"__env_file__",
|
45
|
+
"__is_development_mode__",
|
46
|
+
"__is_running_in_container__",
|
47
|
+
"__project_name__",
|
48
|
+
"__project_path__",
|
49
|
+
"__repository_url__",
|
50
|
+
"__version__",
|
51
|
+
"boot",
|
52
|
+
"console",
|
53
|
+
"get_logger",
|
54
|
+
"get_process_info",
|
55
|
+
"load_settings",
|
56
|
+
"locate_implementations",
|
57
|
+
"locate_subclasses",
|
58
|
+
"prepare_cli",
|
59
|
+
]
|
@@ -0,0 +1,18 @@
|
|
1
|
+
from fastapi import APIRouter
|
2
|
+
|
3
|
+
|
4
|
+
class VersionedAPIRouter(APIRouter):
|
5
|
+
"""APIRouter with version attribute.
|
6
|
+
|
7
|
+
- Use this class to create versioned routers for your FastAPI application
|
8
|
+
that are automatically registered into the FastAPI app.
|
9
|
+
- The version attribute is used to identify the version of the API
|
10
|
+
that the router corresponds to.
|
11
|
+
- See constants.por versions defined for this system.
|
12
|
+
"""
|
13
|
+
|
14
|
+
version: str
|
15
|
+
|
16
|
+
def __init__(self, version: str, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
|
17
|
+
super().__init__(*args, **kwargs)
|
18
|
+
self.version = version
|
@@ -0,0 +1,68 @@
|
|
1
|
+
"""Command-line interface utilities."""
|
2
|
+
|
3
|
+
import sys
|
4
|
+
from pathlib import Path
|
5
|
+
|
6
|
+
import typer
|
7
|
+
|
8
|
+
from ._di import locate_implementations
|
9
|
+
|
10
|
+
|
11
|
+
def prepare_cli(cli: typer.Typer, epilog: str) -> None:
|
12
|
+
"""
|
13
|
+
Dynamically locate, register and prepare subcommands.
|
14
|
+
|
15
|
+
Args:
|
16
|
+
cli (typer.Typer): Typer instance
|
17
|
+
epilog (str): Epilog to add
|
18
|
+
"""
|
19
|
+
for _cli in locate_implementations(typer.Typer):
|
20
|
+
if _cli != cli:
|
21
|
+
cli.add_typer(_cli)
|
22
|
+
|
23
|
+
cli.info.epilog = epilog
|
24
|
+
cli.info.no_args_is_help = True
|
25
|
+
if not any(arg.endswith("typer") for arg in Path(sys.argv[0]).parts):
|
26
|
+
for command in cli.registered_commands:
|
27
|
+
command.epilog = cli.info.epilog
|
28
|
+
|
29
|
+
# add epilog for all subcommands
|
30
|
+
if not any(arg.endswith("typer") for arg in Path(sys.argv[0]).parts):
|
31
|
+
_add_epilog_recursively(cli, epilog)
|
32
|
+
|
33
|
+
# add no_args_is_help for all subcommands
|
34
|
+
_no_args_is_help_recursively(cli)
|
35
|
+
|
36
|
+
|
37
|
+
def _add_epilog_recursively(cli: typer.Typer, epilog: str) -> None:
|
38
|
+
"""
|
39
|
+
Add epilog to all typers in the tree.
|
40
|
+
|
41
|
+
Args:
|
42
|
+
cli (typer.Typer): Typer instance
|
43
|
+
epilog (str): Epilog to add
|
44
|
+
"""
|
45
|
+
cli.info.epilog = epilog
|
46
|
+
for group in cli.registered_groups:
|
47
|
+
if isinstance(group, typer.models.TyperInfo):
|
48
|
+
typer_instance = group.typer_instance
|
49
|
+
if (typer_instance is not cli) and typer_instance:
|
50
|
+
_add_epilog_recursively(typer_instance, epilog)
|
51
|
+
for command in cli.registered_commands:
|
52
|
+
if isinstance(command, typer.models.CommandInfo):
|
53
|
+
command.epilog = cli.info.epilog
|
54
|
+
|
55
|
+
|
56
|
+
def _no_args_is_help_recursively(cli: typer.Typer) -> None:
|
57
|
+
"""
|
58
|
+
Show help if no command is given by the user.
|
59
|
+
|
60
|
+
Args:
|
61
|
+
cli (typer.Typer): Typer instance
|
62
|
+
"""
|
63
|
+
for group in cli.registered_groups:
|
64
|
+
if isinstance(group, typer.models.TyperInfo):
|
65
|
+
group.no_args_is_help = True
|
66
|
+
typer_instance = group.typer_instance
|
67
|
+
if (typer_instance is not cli) and typer_instance:
|
68
|
+
_no_args_is_help_recursively(typer_instance)
|
@@ -0,0 +1,14 @@
|
|
1
|
+
"""Themed rich console."""
|
2
|
+
|
3
|
+
from rich.console import Console
|
4
|
+
from rich.theme import Theme
|
5
|
+
|
6
|
+
console = Console(
|
7
|
+
theme=Theme({
|
8
|
+
"logging.level.info": "purple4",
|
9
|
+
"debug": "light_cyan3",
|
10
|
+
"info": "purple4",
|
11
|
+
"warning": "yellow1",
|
12
|
+
"error": "red1",
|
13
|
+
}),
|
14
|
+
)
|
@@ -0,0 +1,48 @@
|
|
1
|
+
"""Constants used throughout."""
|
2
|
+
|
3
|
+
import os
|
4
|
+
import sys
|
5
|
+
from importlib import metadata
|
6
|
+
from pathlib import Path
|
7
|
+
|
8
|
+
__project_name__ = __name__.split(".")[0]
|
9
|
+
__project_path__ = str(Path(__file__).parent.parent.parent)
|
10
|
+
__version__ = metadata.version(__project_name__)
|
11
|
+
__is_development_mode__ = "uvx" not in sys.argv[0].lower()
|
12
|
+
__is_running_in_container__ = os.getenv(f"{__project_name__.upper()}_RUNNING_IN_CONTAINER")
|
13
|
+
__env__ = os.getenv("ENV", os.getenv("VERCEL_ENV", "local"))
|
14
|
+
__env_file__ = [
|
15
|
+
Path.home() / f".{__project_name__}" / ".env",
|
16
|
+
Path.home() / f".{__project_name__}" / f".env.{__env__}",
|
17
|
+
Path(".env"),
|
18
|
+
Path(f".env.{__env__}"),
|
19
|
+
]
|
20
|
+
env_file_path = os.getenv(f"{__project_name__.upper()}_ENV_FILE")
|
21
|
+
if env_file_path:
|
22
|
+
__env_file__.insert(2, Path(env_file_path))
|
23
|
+
|
24
|
+
|
25
|
+
def get_project_url_by_label(prefix: str) -> str:
|
26
|
+
"""Get labeled Project-URL.
|
27
|
+
|
28
|
+
See https://packaging.python.org/en/latest/specifications/core-metadata/#core-metadata-project-url
|
29
|
+
|
30
|
+
Args:
|
31
|
+
prefix(str): The prefix to match at the beginning of URL entries.
|
32
|
+
|
33
|
+
Returns:
|
34
|
+
The extracted URL string if found, or an empty string if not found.
|
35
|
+
"""
|
36
|
+
for url_entry in metadata.metadata(__project_name__).get_all("Project-URL", []):
|
37
|
+
if url_entry.startswith(prefix):
|
38
|
+
return str(url_entry.split(", ", 1)[1])
|
39
|
+
|
40
|
+
return ""
|
41
|
+
|
42
|
+
|
43
|
+
_authors = metadata.metadata(__project_name__).get_all("Author-email", [])
|
44
|
+
_author = _authors[0] if _authors else None
|
45
|
+
__author_name__ = _author.split("<")[0].strip() if _author else None
|
46
|
+
__author_email__ = _author.split("<")[1].strip(" >") if _author else None
|
47
|
+
__repository_url__ = get_project_url_by_label("Source")
|
48
|
+
__documentation__url__ = get_project_url_by_label("Documentation")
|
@@ -0,0 +1,70 @@
|
|
1
|
+
"""Module for dynamic import and discovery of implementations and subclasses."""
|
2
|
+
|
3
|
+
import importlib
|
4
|
+
import pkgutil
|
5
|
+
from inspect import isclass
|
6
|
+
from typing import Any
|
7
|
+
|
8
|
+
from ._constants import __project_name__
|
9
|
+
|
10
|
+
_implementation_cache: dict[Any, list[Any]] = {}
|
11
|
+
_subclass_cache: dict[Any, list[Any]] = {}
|
12
|
+
|
13
|
+
|
14
|
+
def locate_implementations(_class: type[Any]) -> list[Any]:
|
15
|
+
"""
|
16
|
+
Dynamically discover all instances of some class.
|
17
|
+
|
18
|
+
Args:
|
19
|
+
_class (type[Any]): Class to search for.
|
20
|
+
|
21
|
+
Returns:
|
22
|
+
list[Any]: List of discovered implementations of the given class.
|
23
|
+
"""
|
24
|
+
if _class in _implementation_cache:
|
25
|
+
return _implementation_cache[_class]
|
26
|
+
|
27
|
+
implementations = []
|
28
|
+
package = importlib.import_module(__project_name__)
|
29
|
+
|
30
|
+
for _, name, _ in pkgutil.iter_modules(package.__path__):
|
31
|
+
module = importlib.import_module(f"{__project_name__}.{name}")
|
32
|
+
# Check all members of the module
|
33
|
+
for member_name in dir(module):
|
34
|
+
member = getattr(module, member_name)
|
35
|
+
if isinstance(member, _class):
|
36
|
+
implementations.append(member)
|
37
|
+
|
38
|
+
_implementation_cache[_class] = implementations
|
39
|
+
return implementations
|
40
|
+
|
41
|
+
|
42
|
+
def locate_subclasses(_class: type[Any]) -> list[Any]:
|
43
|
+
"""
|
44
|
+
Dynamically discover all classes that are subclasses of some type.
|
45
|
+
|
46
|
+
Args:
|
47
|
+
_class (type[Any]): Parent class of subclasses to search for.
|
48
|
+
|
49
|
+
Returns:
|
50
|
+
list[type[Any]]: List of discovered subclasses of the given class.
|
51
|
+
"""
|
52
|
+
if _class in _subclass_cache:
|
53
|
+
return _subclass_cache[_class]
|
54
|
+
|
55
|
+
subclasses = []
|
56
|
+
package = importlib.import_module(__project_name__)
|
57
|
+
|
58
|
+
for _, name, _ in pkgutil.iter_modules(package.__path__):
|
59
|
+
try:
|
60
|
+
module = importlib.import_module(f"{__project_name__}.{name}")
|
61
|
+
# Check all members of the module
|
62
|
+
for member_name in dir(module):
|
63
|
+
member = getattr(module, member_name)
|
64
|
+
if isclass(member) and issubclass(member, _class) and member != _class:
|
65
|
+
subclasses.append(member)
|
66
|
+
except ImportError:
|
67
|
+
continue
|
68
|
+
|
69
|
+
_subclass_cache[_class] = subclasses
|
70
|
+
return subclasses
|
@@ -0,0 +1,107 @@
|
|
1
|
+
"""Health models and status definitions for service health checks."""
|
2
|
+
|
3
|
+
from enum import StrEnum
|
4
|
+
from typing import ClassVar, Self
|
5
|
+
|
6
|
+
from pydantic import BaseModel, Field, model_validator
|
7
|
+
|
8
|
+
|
9
|
+
class _HealthStatus(StrEnum):
|
10
|
+
UP = "UP"
|
11
|
+
DOWN = "DOWN"
|
12
|
+
|
13
|
+
|
14
|
+
class Health(BaseModel):
|
15
|
+
"""Represents the health status of a service with optional components and failure reasons.
|
16
|
+
|
17
|
+
- A health object can have child components, i.e. health forms a tree.
|
18
|
+
- Any node in the tree can set itself to DOWN. In this case the node is required
|
19
|
+
to set the reason attribute. If reason is not set when DOWN,
|
20
|
+
automatic model validation of the tree will fail.
|
21
|
+
- DOWN'ness is propagated to parent health objects. I.e. the health of a parent
|
22
|
+
node is automatically set to DOWN if any of its child components are DOWN. The
|
23
|
+
child components leading to this will be listed in the reason.
|
24
|
+
- The root of the health tree is computed in the system module. The health of other
|
25
|
+
modules is automatically picked up by the system module.
|
26
|
+
"""
|
27
|
+
|
28
|
+
Code: ClassVar[type[_HealthStatus]] = _HealthStatus
|
29
|
+
status: _HealthStatus
|
30
|
+
reason: str | None = None
|
31
|
+
components: dict[str, "Health"] = Field(default_factory=dict)
|
32
|
+
|
33
|
+
def compute_health_from_components(self) -> Self:
|
34
|
+
"""Recursively compute health status from components.
|
35
|
+
|
36
|
+
- If health is already DOWN, it remains DOWN with its original reason.
|
37
|
+
- If health is UP but any component is DOWN, health becomes DOWN with
|
38
|
+
a reason listing all failed components.
|
39
|
+
|
40
|
+
Returns:
|
41
|
+
Self: The updated health instance with computed status.
|
42
|
+
"""
|
43
|
+
# Skip recomputation if already known to be DOWN
|
44
|
+
if self.status == _HealthStatus.DOWN:
|
45
|
+
return self
|
46
|
+
|
47
|
+
# No components means we keep the existing status
|
48
|
+
if not self.components:
|
49
|
+
return self
|
50
|
+
|
51
|
+
# Find all DOWN components
|
52
|
+
down_components = []
|
53
|
+
for component_name, component in self.components.items():
|
54
|
+
# Recursively compute health for each component
|
55
|
+
component.compute_health_from_components()
|
56
|
+
if component.status == _HealthStatus.DOWN:
|
57
|
+
down_components.append(component_name)
|
58
|
+
|
59
|
+
# If any components are DOWN, mark the parent as DOWN
|
60
|
+
if down_components:
|
61
|
+
self.status = _HealthStatus.DOWN
|
62
|
+
if len(down_components) == 1:
|
63
|
+
self.reason = f"Component '{down_components[0]}' is DOWN"
|
64
|
+
else:
|
65
|
+
component_list = "', '".join(down_components)
|
66
|
+
self.reason = f"Components '{component_list}' are DOWN"
|
67
|
+
|
68
|
+
return self
|
69
|
+
|
70
|
+
@model_validator(mode="after")
|
71
|
+
def validate_health_state(self) -> Self:
|
72
|
+
"""Validate the health state and ensure consistency.
|
73
|
+
|
74
|
+
- Compute overall health based on component health
|
75
|
+
- Ensure UP status has no associated reason
|
76
|
+
- Ensure DOWN status always has a reason
|
77
|
+
|
78
|
+
Returns:
|
79
|
+
Self: The validated model instance with correct health status.
|
80
|
+
|
81
|
+
Raises:
|
82
|
+
ValueError: If validation fails due to inconsistency.
|
83
|
+
"""
|
84
|
+
# First compute health from components
|
85
|
+
self.compute_health_from_components()
|
86
|
+
|
87
|
+
# Validate that UP status has no reason
|
88
|
+
if (self.status == _HealthStatus.UP) and self.reason:
|
89
|
+
msg = f"Health {self.status} must not have reason"
|
90
|
+
raise ValueError(msg)
|
91
|
+
|
92
|
+
# Validate that DOWN status always has a reason
|
93
|
+
if (self.status == _HealthStatus.DOWN) and not self.reason:
|
94
|
+
msg = "Health DOWN must have a reason"
|
95
|
+
raise ValueError(msg)
|
96
|
+
|
97
|
+
return self
|
98
|
+
|
99
|
+
def __str__(self) -> str:
|
100
|
+
"""Return string representation of health status with optional reason for DOWN state.
|
101
|
+
|
102
|
+
Returns:
|
103
|
+
str: The health status value, with reason appended if status is DOWN.
|
104
|
+
"""
|
105
|
+
if self.status == _HealthStatus.DOWN and self.reason:
|
106
|
+
return f"{self.status.value}: {self.reason}"
|
107
|
+
return self.status.value
|
@@ -0,0 +1,122 @@
|
|
1
|
+
"""Logging configuration and utilities."""
|
2
|
+
|
3
|
+
import logging as python_logging
|
4
|
+
import typing as t
|
5
|
+
from logging import FileHandler
|
6
|
+
from typing import Annotated, Literal
|
7
|
+
|
8
|
+
import click
|
9
|
+
import logfire
|
10
|
+
from pydantic import Field
|
11
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
12
|
+
from rich.console import Console
|
13
|
+
from rich.logging import RichHandler
|
14
|
+
|
15
|
+
from ._constants import __env_file__, __project_name__
|
16
|
+
from ._settings import load_settings
|
17
|
+
|
18
|
+
|
19
|
+
def get_logger(name: str | None) -> python_logging.Logger:
|
20
|
+
"""
|
21
|
+
Get a logger instance with the given name or project name as default.
|
22
|
+
|
23
|
+
Args:
|
24
|
+
name(str): The name for the logger. If None, uses project name.
|
25
|
+
|
26
|
+
Returns:
|
27
|
+
Logger: Configured logger instance.
|
28
|
+
"""
|
29
|
+
if (name is None) or (name == __project_name__):
|
30
|
+
return python_logging.getLogger(__project_name__)
|
31
|
+
return python_logging.getLogger(f"{__project_name__}.{name}")
|
32
|
+
|
33
|
+
|
34
|
+
class LogSettings(BaseSettings):
|
35
|
+
"""Settings for configuring logging behavior."""
|
36
|
+
|
37
|
+
model_config = SettingsConfigDict(
|
38
|
+
env_prefix=f"{__project_name__.upper()}_LOG_",
|
39
|
+
extra="ignore",
|
40
|
+
env_file=__env_file__,
|
41
|
+
env_file_encoding="utf-8",
|
42
|
+
)
|
43
|
+
|
44
|
+
level: Annotated[
|
45
|
+
Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"],
|
46
|
+
Field(description="Logging level", default="INFO"),
|
47
|
+
]
|
48
|
+
file_enabled: Annotated[
|
49
|
+
bool,
|
50
|
+
Field(description="Enable logging to file", default=False),
|
51
|
+
]
|
52
|
+
file_name: Annotated[
|
53
|
+
str,
|
54
|
+
Field(description="Name of the log file", default=f"{__project_name__}.log"),
|
55
|
+
]
|
56
|
+
console_enabled: Annotated[
|
57
|
+
bool,
|
58
|
+
Field(description="Enable logging to console", default=False),
|
59
|
+
]
|
60
|
+
|
61
|
+
|
62
|
+
class CustomFilter(python_logging.Filter):
|
63
|
+
"""Custom filter for log records."""
|
64
|
+
|
65
|
+
@staticmethod
|
66
|
+
def filter(_record: python_logging.LogRecord) -> bool:
|
67
|
+
"""
|
68
|
+
Filter log records based on custom criteria.
|
69
|
+
|
70
|
+
Args:
|
71
|
+
record: The log record to filter.
|
72
|
+
|
73
|
+
Returns:
|
74
|
+
bool: True if record should be logged, False otherwise.
|
75
|
+
"""
|
76
|
+
return True
|
77
|
+
|
78
|
+
|
79
|
+
def logging_initialize(log_to_logfire: bool = False) -> None:
|
80
|
+
"""Initialize logging configuration."""
|
81
|
+
log_filter = CustomFilter()
|
82
|
+
|
83
|
+
handlers = []
|
84
|
+
|
85
|
+
settings = load_settings(LogSettings)
|
86
|
+
|
87
|
+
if settings.file_enabled:
|
88
|
+
file_handler = python_logging.FileHandler(settings.file_name)
|
89
|
+
file_formatter = python_logging.Formatter(
|
90
|
+
fmt="%(asctime)s %(process)d %(levelname)s %(name)s %(message)s",
|
91
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
92
|
+
)
|
93
|
+
file_handler.setFormatter(file_formatter)
|
94
|
+
file_handler.addFilter(log_filter)
|
95
|
+
handlers.append(file_handler)
|
96
|
+
|
97
|
+
if settings.console_enabled:
|
98
|
+
rich_handler = RichHandler(
|
99
|
+
console=Console(stderr=True),
|
100
|
+
markup=True,
|
101
|
+
rich_tracebacks=True,
|
102
|
+
tracebacks_suppress=[click],
|
103
|
+
show_time=True,
|
104
|
+
omit_repeated_times=True,
|
105
|
+
show_path=True,
|
106
|
+
show_level=True,
|
107
|
+
enable_link_path=True,
|
108
|
+
)
|
109
|
+
rich_handler.addFilter(log_filter)
|
110
|
+
handlers.append(t.cast("FileHandler", rich_handler))
|
111
|
+
|
112
|
+
if log_to_logfire:
|
113
|
+
logfire_handler = logfire.LogfireLoggingHandler()
|
114
|
+
logfire_handler.addFilter(log_filter)
|
115
|
+
handlers.append(t.cast("FileHandler", logfire_handler))
|
116
|
+
|
117
|
+
python_logging.basicConfig(
|
118
|
+
level=settings.level,
|
119
|
+
format="%(name)s %(message)s",
|
120
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
121
|
+
handlers=handlers,
|
122
|
+
)
|
@@ -0,0 +1,68 @@
|
|
1
|
+
"""Logfire integration for logging and instrumentation."""
|
2
|
+
|
3
|
+
from typing import Annotated
|
4
|
+
|
5
|
+
import logfire
|
6
|
+
from pydantic import Field, PlainSerializer, SecretStr
|
7
|
+
from pydantic_settings import SettingsConfigDict
|
8
|
+
|
9
|
+
from ._constants import __env__, __env_file__, __project_name__, __repository_url__, __version__
|
10
|
+
from ._settings import OpaqueSettings, load_settings
|
11
|
+
|
12
|
+
|
13
|
+
class LogfireSettings(OpaqueSettings):
|
14
|
+
"""Configuration settings for Logfire integration."""
|
15
|
+
|
16
|
+
model_config = SettingsConfigDict(
|
17
|
+
env_prefix=f"{__project_name__.upper()}_LOGFIRE_",
|
18
|
+
env_file=__env_file__,
|
19
|
+
env_file_encoding="utf-8",
|
20
|
+
extra="ignore",
|
21
|
+
)
|
22
|
+
|
23
|
+
token: Annotated[
|
24
|
+
SecretStr | None,
|
25
|
+
PlainSerializer(func=OpaqueSettings.serialize_sensitive_info, return_type=str, when_used="always"),
|
26
|
+
Field(description="Logfire token. Leave empty to disable logfire.", examples=["YOUR_TOKEN"], default=None),
|
27
|
+
]
|
28
|
+
instrument_system_metrics: Annotated[
|
29
|
+
bool,
|
30
|
+
Field(description="Enable system metrics instrumentation", default=False),
|
31
|
+
]
|
32
|
+
|
33
|
+
|
34
|
+
def logfire_initialize(modules: list["str"]) -> bool:
|
35
|
+
"""Initialize Logfire integration.
|
36
|
+
|
37
|
+
Args:
|
38
|
+
modules(list["str"]): List of modules to be instrumented.
|
39
|
+
|
40
|
+
Returns:
|
41
|
+
bool: True if initialized successfully False otherwise
|
42
|
+
"""
|
43
|
+
settings = load_settings(LogfireSettings)
|
44
|
+
|
45
|
+
if settings.token is None:
|
46
|
+
return False
|
47
|
+
|
48
|
+
logfire.configure(
|
49
|
+
send_to_logfire="if-token-present",
|
50
|
+
token=settings.token.get_secret_value(),
|
51
|
+
environment=__env__,
|
52
|
+
service_name=__project_name__,
|
53
|
+
console=False,
|
54
|
+
code_source=logfire.CodeSource(
|
55
|
+
repository=__repository_url__,
|
56
|
+
revision=__version__,
|
57
|
+
root_path="",
|
58
|
+
),
|
59
|
+
)
|
60
|
+
|
61
|
+
if settings.instrument_system_metrics:
|
62
|
+
logfire.instrument_system_metrics(base="full")
|
63
|
+
|
64
|
+
logfire.instrument_pydantic()
|
65
|
+
|
66
|
+
logfire.install_auto_tracing(modules=modules, min_duration=0.0)
|
67
|
+
|
68
|
+
return True
|
@@ -0,0 +1,41 @@
|
|
1
|
+
"""Process related utilities."""
|
2
|
+
|
3
|
+
from pathlib import Path
|
4
|
+
|
5
|
+
import psutil
|
6
|
+
from pydantic import BaseModel
|
7
|
+
|
8
|
+
|
9
|
+
class ParentProcessInfo(BaseModel):
|
10
|
+
"""Information about a parent process."""
|
11
|
+
|
12
|
+
name: str | None = None
|
13
|
+
pid: int | None = None
|
14
|
+
|
15
|
+
|
16
|
+
class ProcessInfo(BaseModel):
|
17
|
+
"""Information about the current process."""
|
18
|
+
|
19
|
+
project_root: str
|
20
|
+
pid: int
|
21
|
+
parent: ParentProcessInfo
|
22
|
+
|
23
|
+
|
24
|
+
def get_process_info() -> ProcessInfo:
|
25
|
+
"""
|
26
|
+
Get information about the current process and its parent.
|
27
|
+
|
28
|
+
Returns:
|
29
|
+
ProcessInfo: Object containing process information.
|
30
|
+
"""
|
31
|
+
current_process = psutil.Process()
|
32
|
+
parent = current_process.parent()
|
33
|
+
|
34
|
+
return ProcessInfo(
|
35
|
+
project_root=str(Path(__file__).parent.parent.parent.parent),
|
36
|
+
pid=current_process.pid,
|
37
|
+
parent=ParentProcessInfo(
|
38
|
+
name=parent.name() if parent else None,
|
39
|
+
pid=parent.pid if parent else None,
|
40
|
+
),
|
41
|
+
)
|