oe-python-template-example 0.4.9__py3-none-any.whl → 0.4.10__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/api.py +7 -6
- oe_python_template_example/hello/_api.py +3 -3
- oe_python_template_example/hello/_gui.py +5 -3
- oe_python_template_example/system/_api.py +1 -1
- oe_python_template_example/system/_cli.py +1 -1
- oe_python_template_example/system/_gui.py +2 -2
- oe_python_template_example/utils/__init__.py +4 -1
- oe_python_template_example/utils/_api.py +53 -6
- oe_python_template_example/utils/_constants.py +8 -0
- oe_python_template_example/utils/_di.py +6 -0
- oe_python_template_example/utils/_gui.py +119 -85
- oe_python_template_example/utils/_log.py +47 -3
- oe_python_template_example/utils/_notebook.py +12 -7
- oe_python_template_example/utils/_sentry.py +102 -1
- {oe_python_template_example-0.4.9.dist-info → oe_python_template_example-0.4.10.dist-info}/METADATA +1 -1
- {oe_python_template_example-0.4.9.dist-info → oe_python_template_example-0.4.10.dist-info}/RECORD +19 -19
- {oe_python_template_example-0.4.9.dist-info → oe_python_template_example-0.4.10.dist-info}/WHEEL +0 -0
- {oe_python_template_example-0.4.9.dist-info → oe_python_template_example-0.4.10.dist-info}/entry_points.txt +0 -0
- {oe_python_template_example-0.4.9.dist-info → oe_python_template_example-0.4.10.dist-info}/licenses/LICENSE +0 -0
@@ -16,7 +16,7 @@ from .utils import (
|
|
16
16
|
__base__url__,
|
17
17
|
__documentation__url__,
|
18
18
|
__repository_url__,
|
19
|
-
|
19
|
+
load_modules,
|
20
20
|
)
|
21
21
|
|
22
22
|
TITLE = "OE Python Template Example"
|
@@ -67,12 +67,13 @@ for version, semver in API_VERSIONS.items():
|
|
67
67
|
terms_of_service=TERMS_OF_SERVICE_URL,
|
68
68
|
)
|
69
69
|
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
70
|
+
load_modules()
|
71
|
+
|
72
|
+
# Register routers with appropriate API versions using the tracked instances
|
73
|
+
for router in VersionedAPIRouter.get_instances():
|
74
|
+
version = router.version # type: ignore
|
74
75
|
if version in API_VERSIONS:
|
75
|
-
api_instances[version].include_router(
|
76
|
+
api_instances[version].include_router(router) # type: ignore
|
76
77
|
|
77
78
|
# Mount all API versions to the main app
|
78
79
|
for version in API_VERSIONS:
|
@@ -8,7 +8,7 @@ This module provides a webservice API with several operations:
|
|
8
8
|
from collections.abc import Generator
|
9
9
|
from typing import Annotated
|
10
10
|
|
11
|
-
from fastapi import Depends
|
11
|
+
from fastapi import APIRouter, Depends
|
12
12
|
from pydantic import BaseModel, Field
|
13
13
|
|
14
14
|
from oe_python_template_example.utils import VersionedAPIRouter
|
@@ -20,8 +20,8 @@ HELLO_WORLD_EXAMPLE = "Hello, world!"
|
|
20
20
|
|
21
21
|
# VersionedAPIRouters exported by modules via their __init__.py are automatically registered
|
22
22
|
# and injected into the main API app, see ../api.py.
|
23
|
-
api_v1 = VersionedAPIRouter("v1", prefix="/hello", tags=["hello"])
|
24
|
-
api_v2 = VersionedAPIRouter("v2", prefix="/hello", tags=["hello"])
|
23
|
+
api_v1: APIRouter = VersionedAPIRouter("v1", prefix="/hello", tags=["hello"]) # type: ignore
|
24
|
+
api_v2: APIRouter = VersionedAPIRouter("v2", prefix="/hello", tags=["hello"]) # type: ignore
|
25
25
|
|
26
26
|
|
27
27
|
def get_service() -> Generator[Service, None, None]:
|
@@ -2,8 +2,6 @@
|
|
2
2
|
|
3
3
|
from pathlib import Path
|
4
4
|
|
5
|
-
from nicegui import ui
|
6
|
-
|
7
5
|
from oe_python_template_example.utils import BasePageBuilder, GUILocalFilePicker
|
8
6
|
|
9
7
|
from ._service import Service
|
@@ -11,13 +9,17 @@ from ._service import Service
|
|
11
9
|
|
12
10
|
async def pick_file() -> None:
|
13
11
|
"""Open a file picker dialog and show notifier when closed again."""
|
14
|
-
|
12
|
+
from nicegui import ui # noqa: PLC0415
|
13
|
+
|
14
|
+
result = await GUILocalFilePicker(str(Path.cwd() / "examples"), multiple=True) # type: ignore
|
15
15
|
ui.notify(f"You chose {result}")
|
16
16
|
|
17
17
|
|
18
18
|
class PageBuilder(BasePageBuilder):
|
19
19
|
@staticmethod
|
20
20
|
def register_pages() -> None:
|
21
|
+
from nicegui import ui # noqa: PLC0415
|
22
|
+
|
21
23
|
@ui.page("/")
|
22
24
|
def page_index() -> None:
|
23
25
|
"""Homepage of GUI."""
|
@@ -110,7 +110,7 @@ def register_info_endpoint(router: APIRouter) -> Callable[..., dict[str, Any]]:
|
|
110
110
|
|
111
111
|
api_routers = {}
|
112
112
|
for version in API_VERSIONS:
|
113
|
-
router = VersionedAPIRouter(version, tags=["system"])
|
113
|
+
router: APIRouter = VersionedAPIRouter(version, tags=["system"]) # type: ignore
|
114
114
|
api_routers[version] = router
|
115
115
|
health = register_health_endpoint(api_routers[version])
|
116
116
|
info = register_info_endpoint(api_routers[version])
|
@@ -194,7 +194,7 @@ def fail() -> None:
|
|
194
194
|
|
195
195
|
@cli.command()
|
196
196
|
def sleep(
|
197
|
-
seconds: Annotated[int, typer.Option(help="Duration in seconds")] =
|
197
|
+
seconds: Annotated[int, typer.Option(help="Duration in seconds")] = 2,
|
198
198
|
) -> None:
|
199
199
|
"""Sleep given for given number of seconds.
|
200
200
|
|
@@ -1,7 +1,5 @@
|
|
1
1
|
"""Homepage (index) of GUI."""
|
2
2
|
|
3
|
-
from nicegui import ui
|
4
|
-
|
5
3
|
from ..utils import BasePageBuilder, __project_name__, __version__ # noqa: TID252
|
6
4
|
from ._service import Service
|
7
5
|
|
@@ -9,6 +7,8 @@ from ._service import Service
|
|
9
7
|
class PageBuilder(BasePageBuilder):
|
10
8
|
@staticmethod
|
11
9
|
def register_pages() -> None:
|
10
|
+
from nicegui import ui # noqa: PLC0415
|
11
|
+
|
12
12
|
@ui.page("/info")
|
13
13
|
def page_info() -> None:
|
14
14
|
"""Homepage of GUI."""
|
@@ -12,12 +12,13 @@ from ._constants import (
|
|
12
12
|
__env_file__,
|
13
13
|
__is_development_mode__,
|
14
14
|
__is_running_in_container__,
|
15
|
+
__is_running_in_read_only_environment__,
|
15
16
|
__project_name__,
|
16
17
|
__project_path__,
|
17
18
|
__repository_url__,
|
18
19
|
__version__,
|
19
20
|
)
|
20
|
-
from ._di import locate_implementations, locate_subclasses
|
21
|
+
from ._di import load_modules, locate_implementations, locate_subclasses
|
21
22
|
from ._health import Health
|
22
23
|
from ._log import LogSettings, get_logger
|
23
24
|
from ._logfire import LogfireSettings
|
@@ -46,6 +47,7 @@ __all__ = [
|
|
46
47
|
"__env_file__",
|
47
48
|
"__is_development_mode__",
|
48
49
|
"__is_running_in_container__",
|
50
|
+
"__is_running_in_read_only_environment__",
|
49
51
|
"__project_name__",
|
50
52
|
"__project_path__",
|
51
53
|
"__repository_url__",
|
@@ -54,6 +56,7 @@ __all__ = [
|
|
54
56
|
"console",
|
55
57
|
"get_logger",
|
56
58
|
"get_process_info",
|
59
|
+
"load_modules",
|
57
60
|
"load_settings",
|
58
61
|
"locate_implementations",
|
59
62
|
"locate_subclasses",
|
@@ -1,7 +1,9 @@
|
|
1
|
-
|
1
|
+
"""API router utilities for versioned FastAPI routers."""
|
2
2
|
|
3
|
+
from typing import ClassVar
|
3
4
|
|
4
|
-
|
5
|
+
|
6
|
+
class VersionedAPIRouter:
|
5
7
|
"""APIRouter with version attribute.
|
6
8
|
|
7
9
|
- Use this class to create versioned routers for your FastAPI application
|
@@ -11,8 +13,53 @@ class VersionedAPIRouter(APIRouter):
|
|
11
13
|
- See constants.por versions defined for this system.
|
12
14
|
"""
|
13
15
|
|
14
|
-
|
16
|
+
# Class variable to track all created instances
|
17
|
+
_instances: ClassVar[list["VersionedAPIRouter"]] = []
|
18
|
+
|
19
|
+
@classmethod
|
20
|
+
def get_instances(cls) -> list["VersionedAPIRouter"]:
|
21
|
+
"""Get all created router instances.
|
22
|
+
|
23
|
+
Returns:
|
24
|
+
A list of all router instances created.
|
25
|
+
"""
|
26
|
+
return cls._instances.copy()
|
27
|
+
|
28
|
+
def __new__(cls, version: str, *args, **kwargs) -> "VersionedAPIRouter": # type: ignore[no-untyped-def]
|
29
|
+
"""Create a new instance with lazy-loaded dependencies.
|
30
|
+
|
31
|
+
Args:
|
32
|
+
version: The API version this router belongs to.
|
33
|
+
*args: Arguments to pass to the FastAPI APIRouter.
|
34
|
+
**kwargs: Keyword arguments to pass to the FastAPI APIRouter.
|
35
|
+
|
36
|
+
Returns:
|
37
|
+
An instance of VersionedAPIRouter with lazy-loaded dependencies.
|
38
|
+
"""
|
39
|
+
from fastapi import APIRouter # Import only when creating an instance # noqa: PLC0415
|
40
|
+
|
41
|
+
# Define the actual implementation class with the imports available
|
42
|
+
class VersionedAPIRouterImpl(APIRouter):
|
43
|
+
"""Implementation of VersionedAPIRouter with lazy-loaded dependencies."""
|
44
|
+
|
45
|
+
version: str
|
46
|
+
|
47
|
+
def __init__(self, version: str, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
|
48
|
+
"""Initialize the router.
|
49
|
+
|
50
|
+
Args:
|
51
|
+
version: The API version this router belongs to.
|
52
|
+
*args: Arguments to pass to the FastAPI APIRouter.
|
53
|
+
**kwargs: Keyword arguments to pass to the FastAPI APIRouter.
|
54
|
+
"""
|
55
|
+
super().__init__(*args, **kwargs)
|
56
|
+
self.version = version
|
57
|
+
|
58
|
+
# Create an instance
|
59
|
+
instance = VersionedAPIRouterImpl(version, *args, **kwargs)
|
60
|
+
|
61
|
+
# Add to registry of instances
|
62
|
+
cls._instances.append(instance) # type: ignore
|
15
63
|
|
16
|
-
|
17
|
-
|
18
|
-
self.version = version
|
64
|
+
# Return the instance but tell mypy it's a VersionedAPIRouter
|
65
|
+
return instance # type: ignore[return-value]
|
@@ -15,6 +15,14 @@ __version__ = metadata.version(__project_name__)
|
|
15
15
|
__is_development_mode__ = "uvx" not in sys.argv[0].lower()
|
16
16
|
__is_running_in_container__ = os.getenv(f"{__project_name__.upper()}_RUNNING_IN_CONTAINER")
|
17
17
|
|
18
|
+
# Determine if we're running in a read-only runtime environment
|
19
|
+
READ_ONLY_ENV_INDICATORS = [
|
20
|
+
f"{__project_name__.upper()}_RUNNING_IN_CONTAINER",
|
21
|
+
"VERCEL_ENV",
|
22
|
+
"RAILWAY_ENVIRONMENT",
|
23
|
+
]
|
24
|
+
__is_running_in_read_only_environment__ = any(os.getenv(env_var) is not None for env_var in READ_ONLY_ENV_INDICATORS)
|
25
|
+
|
18
26
|
# Determine environment we are deployed on
|
19
27
|
ENV_VAR_MAPPINGS = {
|
20
28
|
"ENV": lambda env: env,
|
@@ -11,6 +11,12 @@ _implementation_cache: dict[Any, list[Any]] = {}
|
|
11
11
|
_subclass_cache: dict[Any, list[Any]] = {}
|
12
12
|
|
13
13
|
|
14
|
+
def load_modules() -> None:
|
15
|
+
package = importlib.import_module(__project_name__)
|
16
|
+
for _, name, _ in pkgutil.iter_modules(package.__path__):
|
17
|
+
importlib.import_module(f"{__project_name__}.{name}")
|
18
|
+
|
19
|
+
|
14
20
|
def locate_implementations(_class: type[Any]) -> list[Any]:
|
15
21
|
"""
|
16
22
|
Dynamically discover all instances of some class.
|
@@ -3,9 +3,6 @@ from abc import ABC, abstractmethod
|
|
3
3
|
from pathlib import Path
|
4
4
|
from types import EllipsisType
|
5
5
|
|
6
|
-
from nicegui import app, events, ui
|
7
|
-
from nicegui import native as native_app
|
8
|
-
|
9
6
|
from ._constants import __is_running_in_container__, __project_name__
|
10
7
|
from ._di import locate_subclasses
|
11
8
|
from ._log import get_logger
|
@@ -58,6 +55,9 @@ def gui_run( # noqa: PLR0913, PLR0917
|
|
58
55
|
ValueError: If with_notebook is True but notebook_path is None,
|
59
56
|
or trying to run native within container.
|
60
57
|
"""
|
58
|
+
from nicegui import app, ui # noqa: PLC0415
|
59
|
+
from nicegui import native as native_app # noqa: PLC0415
|
60
|
+
|
61
61
|
if __is_running_in_container__ and native:
|
62
62
|
message = "Native GUI cannot be run in a container. Please run with uvx or in browser."
|
63
63
|
raise ValueError(message)
|
@@ -81,98 +81,132 @@ def gui_run( # noqa: PLR0913, PLR0917
|
|
81
81
|
)
|
82
82
|
|
83
83
|
|
84
|
-
class GUILocalFilePicker
|
85
|
-
|
86
|
-
|
84
|
+
class GUILocalFilePicker:
|
85
|
+
"""Local File Picker dialog class that lazy-loads NiceGUI dependencies."""
|
86
|
+
|
87
|
+
def __new__( # noqa: C901
|
88
|
+
cls,
|
87
89
|
directory: str,
|
88
90
|
*,
|
89
91
|
upper_limit: str | EllipsisType | None = ...,
|
90
92
|
multiple: bool = False,
|
91
93
|
show_hidden_files: bool = False,
|
92
|
-
) ->
|
93
|
-
"""
|
94
|
-
|
95
|
-
A simple file picker that allows selecting files from the local filesystem where NiceGUI is running.
|
94
|
+
) -> "GUILocalFilePicker":
|
95
|
+
"""Create a new instance with lazy-loaded dependencies.
|
96
96
|
|
97
97
|
Args:
|
98
98
|
directory: The directory to start in.
|
99
99
|
upper_limit: The directory to stop at. None for no limit, default is same as starting directory.
|
100
100
|
multiple: Whether to allow multiple files to be selected.
|
101
101
|
show_hidden_files: Whether to show hidden files.
|
102
|
+
|
103
|
+
Returns:
|
104
|
+
An instance of the dialog with lazy-loaded dependencies.
|
102
105
|
"""
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
106
|
+
from nicegui import events, ui # noqa: PLC0415
|
107
|
+
# Lazy import ui only when actually creating an instance
|
108
|
+
|
109
|
+
# Define the actual implementation class with the imports available
|
110
|
+
class GUILocalFilePickerImpl(ui.dialog):
|
111
|
+
def __init__(
|
112
|
+
self,
|
113
|
+
directory: str,
|
114
|
+
*,
|
115
|
+
upper_limit: str | EllipsisType | None = ...,
|
116
|
+
multiple: bool = False,
|
117
|
+
show_hidden_files: bool = False,
|
118
|
+
) -> None:
|
119
|
+
"""Local File Picker.
|
120
|
+
|
121
|
+
A simple file picker that allows selecting files from the local filesystem where NiceGUI is running.
|
122
|
+
|
123
|
+
Args:
|
124
|
+
directory: The directory to start in.
|
125
|
+
upper_limit: The directory to stop at. None for no limit, default is same as starting directory.
|
126
|
+
multiple: Whether to allow multiple files to be selected.
|
127
|
+
show_hidden_files: Whether to show hidden files.
|
128
|
+
"""
|
129
|
+
super().__init__()
|
130
|
+
|
131
|
+
self.path = Path(directory).expanduser()
|
132
|
+
if upper_limit is None:
|
133
|
+
self.upper_limit = None
|
134
|
+
elif upper_limit is ...:
|
135
|
+
self.upper_limit = Path(directory).expanduser()
|
136
|
+
else:
|
137
|
+
self.upper_limit = Path(upper_limit).expanduser()
|
138
|
+
self.show_hidden_files = show_hidden_files
|
139
|
+
|
140
|
+
with self, ui.card():
|
141
|
+
self.add_drives_toggle()
|
142
|
+
self.grid = (
|
143
|
+
ui.aggrid(
|
144
|
+
{
|
145
|
+
"columnDefs": [{"field": "name", "headerName": "File"}],
|
146
|
+
"rowSelection": "multiple" if multiple else "single",
|
147
|
+
},
|
148
|
+
html_columns=[0],
|
149
|
+
)
|
150
|
+
.classes("w-96")
|
151
|
+
.on("cellDoubleClicked", self.handle_double_click)
|
152
|
+
)
|
153
|
+
with ui.row().classes("w-full justify-end"):
|
154
|
+
ui.button("Cancel", on_click=self.close).props("outline").mark("BUTTON_CANCEL")
|
155
|
+
ui.button("Ok", on_click=self._handle_ok).mark("BUTTON_OK")
|
156
|
+
self.update_grid()
|
157
|
+
|
158
|
+
def add_drives_toggle(self) -> None:
|
159
|
+
if platform.system() == "Windows":
|
160
|
+
import win32api # noqa: PLC0415
|
161
|
+
|
162
|
+
drives = win32api.GetLogicalDriveStrings().split("\000")[:-1]
|
163
|
+
self.drives_toggle = ui.toggle(drives, value=drives[0], on_change=self.update_drive)
|
164
|
+
|
165
|
+
def update_drive(self) -> None:
|
166
|
+
self.path = Path(self.drives_toggle.value).expanduser()
|
167
|
+
self.update_grid()
|
168
|
+
|
169
|
+
def update_grid(self) -> None:
|
170
|
+
paths = list(self.path.glob("*"))
|
171
|
+
if not self.show_hidden_files:
|
172
|
+
paths = [p for p in paths if not p.name.startswith(".")]
|
173
|
+
paths.sort(key=lambda p: p.name.lower())
|
174
|
+
paths.sort(key=lambda p: not p.is_dir())
|
175
|
+
|
176
|
+
self.grid.options["rowData"] = [
|
118
177
|
{
|
119
|
-
"
|
120
|
-
"
|
121
|
-
}
|
122
|
-
|
123
|
-
|
124
|
-
.
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
}
|
155
|
-
for p in paths
|
156
|
-
]
|
157
|
-
if (self.upper_limit is None and self.path != self.path.parent) or (
|
158
|
-
self.upper_limit is not None and self.path != self.upper_limit
|
159
|
-
):
|
160
|
-
self.grid.options["rowData"].insert(
|
161
|
-
0,
|
162
|
-
{
|
163
|
-
"name": "📁 <strong>..</strong>",
|
164
|
-
"path": str(self.path.parent),
|
165
|
-
},
|
166
|
-
)
|
167
|
-
self.grid.update()
|
168
|
-
|
169
|
-
def handle_double_click(self, e: events.GenericEventArguments) -> None:
|
170
|
-
self.path = Path(e.args["data"]["path"])
|
171
|
-
if self.path.is_dir():
|
172
|
-
self.update_grid()
|
173
|
-
else:
|
174
|
-
self.submit([str(self.path)])
|
175
|
-
|
176
|
-
async def _handle_ok(self) -> None:
|
177
|
-
rows = await self.grid.get_selected_rows()
|
178
|
-
self.submit([r["path"] for r in rows])
|
178
|
+
"name": f"📁 <strong>{p.name}</strong>" if p.is_dir() else p.name,
|
179
|
+
"path": str(p),
|
180
|
+
}
|
181
|
+
for p in paths
|
182
|
+
]
|
183
|
+
if (self.upper_limit is None and self.path != self.path.parent) or (
|
184
|
+
self.upper_limit is not None and self.path != self.upper_limit
|
185
|
+
):
|
186
|
+
self.grid.options["rowData"].insert(
|
187
|
+
0,
|
188
|
+
{
|
189
|
+
"name": "📁 <strong>..</strong>",
|
190
|
+
"path": str(self.path.parent),
|
191
|
+
},
|
192
|
+
)
|
193
|
+
self.grid.update()
|
194
|
+
|
195
|
+
def handle_double_click(self, e: events.GenericEventArguments) -> None:
|
196
|
+
self.path = Path(e.args["data"]["path"])
|
197
|
+
if self.path.is_dir():
|
198
|
+
self.update_grid()
|
199
|
+
else:
|
200
|
+
self.submit([str(self.path)])
|
201
|
+
|
202
|
+
async def _handle_ok(self) -> None:
|
203
|
+
rows = await self.grid.get_selected_rows()
|
204
|
+
self.submit([r["path"] for r in rows])
|
205
|
+
|
206
|
+
# Create and return an instance but tell mypy it's a GUILocalFilePicker
|
207
|
+
return GUILocalFilePickerImpl( # type: ignore[return-value]
|
208
|
+
directory=directory,
|
209
|
+
upper_limit=upper_limit,
|
210
|
+
multiple=multiple,
|
211
|
+
show_hidden_files=show_hidden_files,
|
212
|
+
)
|
@@ -1,18 +1,20 @@
|
|
1
1
|
"""Logging configuration and utilities."""
|
2
2
|
|
3
3
|
import logging as python_logging
|
4
|
+
import os
|
4
5
|
import typing as t
|
5
6
|
from logging import FileHandler
|
7
|
+
from pathlib import Path
|
6
8
|
from typing import Annotated, Literal
|
7
9
|
|
8
10
|
import click
|
9
11
|
import logfire
|
10
|
-
from pydantic import Field
|
12
|
+
from pydantic import AfterValidator, Field
|
11
13
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
12
14
|
from rich.console import Console
|
13
15
|
from rich.logging import RichHandler
|
14
16
|
|
15
|
-
from ._constants import __env_file__, __project_name__
|
17
|
+
from ._constants import __env_file__, __is_running_in_read_only_environment__, __project_name__
|
16
18
|
from ._settings import load_settings
|
17
19
|
|
18
20
|
|
@@ -31,6 +33,44 @@ def get_logger(name: str | None) -> python_logging.Logger:
|
|
31
33
|
return python_logging.getLogger(f"{__project_name__}.{name}")
|
32
34
|
|
33
35
|
|
36
|
+
def _validate_file_name(file_name: str | None) -> str | None:
|
37
|
+
"""Validate the file_name is valid and the file writeable.
|
38
|
+
|
39
|
+
- Checks file_name does not yet exist or is a file
|
40
|
+
- If not yet existing, checks it can be created
|
41
|
+
- If existing file, checks file is writeable
|
42
|
+
|
43
|
+
Args:
|
44
|
+
file_name: The file name of the log file
|
45
|
+
|
46
|
+
Returns:
|
47
|
+
str | None: The validated file name
|
48
|
+
|
49
|
+
Raises:
|
50
|
+
ValueError: If file name is not valid or the file not writeable
|
51
|
+
"""
|
52
|
+
if file_name is None:
|
53
|
+
return file_name
|
54
|
+
|
55
|
+
file_path = Path(file_name)
|
56
|
+
if file_path.exists():
|
57
|
+
if not file_path.is_file() and not file_path.is_symlink():
|
58
|
+
message = f"File name {file_path.absolute()} is not a file or symlink"
|
59
|
+
raise ValueError(message)
|
60
|
+
if not os.access(file_path, os.W_OK):
|
61
|
+
message = f"File {file_path.absolute()} is not writable"
|
62
|
+
raise ValueError(message)
|
63
|
+
else:
|
64
|
+
try:
|
65
|
+
file_path.touch(exist_ok=True)
|
66
|
+
file_path.unlink()
|
67
|
+
except OSError as e:
|
68
|
+
message = f"File {file_path.absolute()} cannot be created: {e}"
|
69
|
+
raise ValueError(message) from e
|
70
|
+
|
71
|
+
return file_name
|
72
|
+
|
73
|
+
|
34
74
|
class LogSettings(BaseSettings):
|
35
75
|
"""Settings for configuring logging behavior."""
|
36
76
|
|
@@ -51,7 +91,11 @@ class LogSettings(BaseSettings):
|
|
51
91
|
]
|
52
92
|
file_name: Annotated[
|
53
93
|
str,
|
54
|
-
|
94
|
+
AfterValidator(_validate_file_name),
|
95
|
+
Field(
|
96
|
+
description="Name of the log file",
|
97
|
+
default="/dev/stdout" if __is_running_in_read_only_environment__ else f"{__project_name__}.log",
|
98
|
+
),
|
55
99
|
]
|
56
100
|
console_enabled: Annotated[
|
57
101
|
bool,
|
@@ -1,9 +1,7 @@
|
|
1
|
-
"""
|
1
|
+
"""Notebook server utilities."""
|
2
2
|
|
3
3
|
from collections.abc import Callable
|
4
|
-
|
5
|
-
import marimo
|
6
|
-
from fastapi import APIRouter, FastAPI
|
4
|
+
from typing import Any
|
7
5
|
|
8
6
|
from ..constants import NOTEBOOK_APP, NOTEBOOK_FOLDER # noqa: TID252
|
9
7
|
from ._health import Health
|
@@ -12,7 +10,7 @@ from ._log import get_logger
|
|
12
10
|
logger = get_logger(__name__)
|
13
11
|
|
14
12
|
|
15
|
-
def register_health_endpoint(router:
|
13
|
+
def register_health_endpoint(router: Any) -> Callable[..., Health]: # noqa: ANN401
|
16
14
|
"""Register health endpoint to the given router.
|
17
15
|
|
18
16
|
Args:
|
@@ -21,6 +19,7 @@ def register_health_endpoint(router: APIRouter) -> Callable[..., Health]:
|
|
21
19
|
Returns:
|
22
20
|
Callable[..., Health]: The health endpoint function.
|
23
21
|
"""
|
22
|
+
# We accept 'Any' instead of APIRouter to avoid importing fastapi at module level
|
24
23
|
|
25
24
|
@router.get("/healthz")
|
26
25
|
def health_endpoint() -> Health:
|
@@ -31,10 +30,12 @@ def register_health_endpoint(router: APIRouter) -> Callable[..., Health]:
|
|
31
30
|
"""
|
32
31
|
return Health(status=Health.Code.UP)
|
33
32
|
|
34
|
-
return
|
33
|
+
# Explicitly type the return value to satisfy mypy
|
34
|
+
result: Callable[..., Health] = health_endpoint
|
35
|
+
return result
|
35
36
|
|
36
37
|
|
37
|
-
def create_marimo_app() ->
|
38
|
+
def create_marimo_app() -> Any: # noqa: ANN401
|
38
39
|
"""Create a FastAPI app with marimo notebook server.
|
39
40
|
|
40
41
|
Returns:
|
@@ -43,6 +44,10 @@ def create_marimo_app() -> FastAPI:
|
|
43
44
|
Raises:
|
44
45
|
ValueError: If the notebook directory does not exist.
|
45
46
|
"""
|
47
|
+
# Import dependencies only when function is called
|
48
|
+
import marimo # noqa: PLC0415
|
49
|
+
from fastapi import APIRouter, FastAPI # noqa: PLC0415
|
50
|
+
|
46
51
|
server = marimo.create_asgi_app(include_code=True)
|
47
52
|
if not NOTEBOOK_FOLDER.is_dir():
|
48
53
|
logger.critical(
|
@@ -1,15 +1,115 @@
|
|
1
1
|
"""Sentry integration for application monitoring."""
|
2
2
|
|
3
|
+
import re
|
4
|
+
import urllib.parse
|
3
5
|
from typing import Annotated
|
4
6
|
|
5
7
|
import sentry_sdk
|
6
|
-
from pydantic import BeforeValidator, Field, PlainSerializer, SecretStr
|
8
|
+
from pydantic import AfterValidator, BeforeValidator, Field, PlainSerializer, SecretStr
|
7
9
|
from pydantic_settings import SettingsConfigDict
|
8
10
|
from sentry_sdk.integrations.typer import TyperIntegration
|
9
11
|
|
10
12
|
from ._constants import __env__, __env_file__, __project_name__, __version__
|
11
13
|
from ._settings import OpaqueSettings, load_settings, strip_to_none_before_validator
|
12
14
|
|
15
|
+
_ERR_MSG_MISSING_SCHEME = "Sentry DSN is missing URL scheme (protocol)"
|
16
|
+
_ERR_MSG_MISSING_NETLOC = "Sentry DSN is missing network location (domain)"
|
17
|
+
_ERR_MSG_NON_HTTPS = "Sentry DSN must use HTTPS protocol for security"
|
18
|
+
_ERR_MSG_INVALID_DOMAIN = "Sentry DSN must use a valid Sentry domain (ingest.us.sentry.io or ingest.de.sentry.io)"
|
19
|
+
_ERR_MSG_INVALID_FORMAT = "Invalid Sentry DSN format"
|
20
|
+
_VALID_SENTRY_DOMAIN_PATTERN = r"^[a-f0-9]+@o\d+\.ingest\.(us|de)\.sentry\.io$"
|
21
|
+
|
22
|
+
|
23
|
+
def _validate_url_scheme(parsed_url: urllib.parse.ParseResult) -> None:
|
24
|
+
"""Validate that the URL has a scheme.
|
25
|
+
|
26
|
+
Args:
|
27
|
+
parsed_url: The parsed URL to validate
|
28
|
+
|
29
|
+
Raises:
|
30
|
+
ValueError: If URL is missing scheme
|
31
|
+
"""
|
32
|
+
if not parsed_url.scheme:
|
33
|
+
raise ValueError(_ERR_MSG_MISSING_SCHEME)
|
34
|
+
|
35
|
+
|
36
|
+
def _validate_url_netloc(parsed_url: urllib.parse.ParseResult) -> None:
|
37
|
+
"""Validate that the URL has a network location.
|
38
|
+
|
39
|
+
Args:
|
40
|
+
parsed_url: The parsed URL to validate
|
41
|
+
|
42
|
+
Raises:
|
43
|
+
ValueError: If URL is missing network location
|
44
|
+
"""
|
45
|
+
if not parsed_url.netloc:
|
46
|
+
raise ValueError(_ERR_MSG_MISSING_NETLOC)
|
47
|
+
|
48
|
+
|
49
|
+
def _validate_https_scheme(parsed_url: urllib.parse.ParseResult) -> None:
|
50
|
+
"""Validate that the URL uses HTTPS scheme.
|
51
|
+
|
52
|
+
Args:
|
53
|
+
parsed_url: The parsed URL to validate
|
54
|
+
|
55
|
+
Raises:
|
56
|
+
ValueError: If URL doesn't use HTTPS scheme
|
57
|
+
"""
|
58
|
+
if parsed_url.scheme != "https":
|
59
|
+
raise ValueError(_ERR_MSG_NON_HTTPS)
|
60
|
+
|
61
|
+
|
62
|
+
def _validate_sentry_domain(netloc_with_auth: str) -> None:
|
63
|
+
"""Validate that the URL uses a valid Sentry domain.
|
64
|
+
|
65
|
+
Args:
|
66
|
+
netloc_with_auth: The network location with auth part
|
67
|
+
|
68
|
+
Raises:
|
69
|
+
ValueError: If URL doesn't use a valid Sentry domain
|
70
|
+
"""
|
71
|
+
if "@" not in netloc_with_auth:
|
72
|
+
raise ValueError(_ERR_MSG_INVALID_DOMAIN)
|
73
|
+
|
74
|
+
user_pass, domain = netloc_with_auth.split("@", 1)
|
75
|
+
full_auth = f"{user_pass}@{domain}"
|
76
|
+
if not re.match(_VALID_SENTRY_DOMAIN_PATTERN, full_auth):
|
77
|
+
raise ValueError(_ERR_MSG_INVALID_DOMAIN)
|
78
|
+
|
79
|
+
|
80
|
+
def _validate_https_dsn(value: SecretStr | None) -> SecretStr | None:
|
81
|
+
"""Validate that the Sentry DSN is a valid HTTPS URL.
|
82
|
+
|
83
|
+
Args:
|
84
|
+
value: The DSN value to validate
|
85
|
+
|
86
|
+
Returns:
|
87
|
+
SecretStr | None: The validated DSN value
|
88
|
+
|
89
|
+
Raises:
|
90
|
+
ValueError: If DSN isn't a valid HTTPS URL with specific error details
|
91
|
+
"""
|
92
|
+
if value is None:
|
93
|
+
return value
|
94
|
+
|
95
|
+
dsn = value.get_secret_value()
|
96
|
+
try:
|
97
|
+
parsed_url = urllib.parse.urlparse(dsn)
|
98
|
+
|
99
|
+
# Call validation functions outside of the try block
|
100
|
+
_validate_url_scheme(parsed_url)
|
101
|
+
_validate_url_netloc(parsed_url)
|
102
|
+
_validate_https_scheme(parsed_url)
|
103
|
+
_validate_sentry_domain(parsed_url.netloc)
|
104
|
+
|
105
|
+
except ValueError as exc:
|
106
|
+
raise exc from None
|
107
|
+
except Exception as exc:
|
108
|
+
error_message = _ERR_MSG_INVALID_FORMAT
|
109
|
+
raise ValueError(error_message) from exc
|
110
|
+
|
111
|
+
return value
|
112
|
+
|
13
113
|
|
14
114
|
class SentrySettings(OpaqueSettings):
|
15
115
|
"""Configuration settings for Sentry integration."""
|
@@ -24,6 +124,7 @@ class SentrySettings(OpaqueSettings):
|
|
24
124
|
dsn: Annotated[
|
25
125
|
SecretStr | None,
|
26
126
|
BeforeValidator(strip_to_none_before_validator),
|
127
|
+
AfterValidator(_validate_https_dsn),
|
27
128
|
PlainSerializer(func=OpaqueSettings.serialize_sensitive_info, return_type=str, when_used="always"),
|
28
129
|
Field(description="Sentry DSN", examples=["https://SECRET@SECRET.ingest.de.sentry.io/SECRET"], default=None),
|
29
130
|
]
|
{oe_python_template_example-0.4.9.dist-info → oe_python_template_example-0.4.10.dist-info}/METADATA
RENAMED
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: oe-python-template-example
|
3
|
-
Version: 0.4.
|
3
|
+
Version: 0.4.10
|
4
4
|
Summary: 🧠 Example project scaffolded and kept up to date with OE Python Template (oe-python-template).
|
5
5
|
Project-URL: Homepage, https://oe-python-template-example.readthedocs.io/en/latest/
|
6
6
|
Project-URL: Documentation, https://oe-python-template-example.readthedocs.io/en/latest/
|
{oe_python_template_example-0.4.9.dist-info → oe_python_template_example-0.4.10.dist-info}/RECORD
RENAMED
@@ -1,40 +1,40 @@
|
|
1
1
|
oe_python_template_example/__init__.py,sha256=_Z3Xb-x95UODU66avOiwROVaouk_s0ZNB25KFnPoS40,226
|
2
|
-
oe_python_template_example/api.py,sha256=
|
2
|
+
oe_python_template_example/api.py,sha256=4Rja-8Zu_rHxsIVcSbgb5R1wsbWNku9K1afuoKULx2M,2197
|
3
3
|
oe_python_template_example/cli.py,sha256=hZUPTuuUkIOpgTN6dP3nUh7gflxe6pdY2trruq3Eqhs,1709
|
4
4
|
oe_python_template_example/constants.py,sha256=eRFVkR2qwafucpx5ppJSohr5wpSRm4yh1deLpOTep9A,346
|
5
5
|
oe_python_template_example/hello/__init__.py,sha256=F7aJ_uhTPPnFLT_4GjI2GzjAmKrUhfe4KxqzrljuCVo,485
|
6
|
-
oe_python_template_example/hello/_api.py,sha256=
|
6
|
+
oe_python_template_example/hello/_api.py,sha256=0woWJedvP1-PyXJruQITp4_mO37Viqiq8jTDaXqxVVo,2367
|
7
7
|
oe_python_template_example/hello/_cli.py,sha256=D1RZyz8sk7wpH1a9VDx1QtsNorOr9owxK8N7SxaaMWM,1200
|
8
8
|
oe_python_template_example/hello/_constants.py,sha256=6aRleAIcdgC13TeTzI07YwjoSwqGb2g131dw8aEoM4I,109
|
9
|
-
oe_python_template_example/hello/_gui.py,sha256=
|
9
|
+
oe_python_template_example/hello/_gui.py,sha256=l3b7dHEezyOZPNnBPRCjSlCfkGi1HcYCuPElBfDY5GQ,1556
|
10
10
|
oe_python_template_example/hello/_models.py,sha256=JtI7wGT72u23NOxFa-oeWzdyiMg7PnHL5eg22im2_yQ,574
|
11
11
|
oe_python_template_example/hello/_service.py,sha256=o9sgk-yFX5zRi06sAOdSPqkgT93naxn-JRknwK2Nnvs,3183
|
12
12
|
oe_python_template_example/hello/_settings.py,sha256=Q14SqSvBJYFuofA-tbvBwO30sVygSaXsgaYC7x1uCfo,1562
|
13
13
|
oe_python_template_example/system/__init__.py,sha256=7e2z8HATzy3dAIBXy5PM9rlCC7Rbu8m8NapROdrf3Wk,624
|
14
|
-
oe_python_template_example/system/_api.py,sha256=
|
15
|
-
oe_python_template_example/system/_cli.py,sha256=
|
16
|
-
oe_python_template_example/system/_gui.py,sha256=
|
14
|
+
oe_python_template_example/system/_api.py,sha256=pBBCL1gXM7W5dgJqBLKuNFWES0pQF8mbUgQ7H7oHwh8,3714
|
15
|
+
oe_python_template_example/system/_cli.py,sha256=0cGwSicK0j7P53SMm9nSyQqj0WYpHKvFvkISCcjeTPU,6959
|
16
|
+
oe_python_template_example/system/_gui.py,sha256=9U4QMkl1mVJargg_gQ7U8tMlvec4b1cqkTgRcmmyTTc,678
|
17
17
|
oe_python_template_example/system/_service.py,sha256=f-EtWEAanajo4S2KeGm26K9_RezYgPza1qVqPRBOFvY,6376
|
18
18
|
oe_python_template_example/system/_settings.py,sha256=MwMAJYifJ6jGImeSh4e9shmIXmiUSuQGHXz_Ts0mSdk,901
|
19
|
-
oe_python_template_example/utils/__init__.py,sha256=
|
20
|
-
oe_python_template_example/utils/_api.py,sha256=
|
19
|
+
oe_python_template_example/utils/__init__.py,sha256=oM8v1ozMOeBQG-EbBtEGbnuGk52mYNDAmNoR7vNQ7MY,2050
|
20
|
+
oe_python_template_example/utils/_api.py,sha256=GSI-HPtSdTFv0qP5aTDPa2WS3ewVa1slusG6dvxSu0Y,2474
|
21
21
|
oe_python_template_example/utils/_cli.py,sha256=J_mFtXZ1gGeovGrE5i3wlokTOBfiTTKEz5magiRP7GA,2091
|
22
22
|
oe_python_template_example/utils/_console.py,sha256=u0-utcdRmVu4rabrYUyNOx8yPxLhxB3E92m22kSCwPQ,293
|
23
|
-
oe_python_template_example/utils/_constants.py,sha256=
|
24
|
-
oe_python_template_example/utils/_di.py,sha256=
|
25
|
-
oe_python_template_example/utils/_gui.py,sha256=
|
23
|
+
oe_python_template_example/utils/_constants.py,sha256=fPS1leVqg7NRo_FFE3Xs3QI7un5_YRrY8cKQAfThwgg,3247
|
24
|
+
oe_python_template_example/utils/_di.py,sha256=J1-f6xtbnR88VfRAsRMl5m6eqt-Lf3c7ABSJOxNzlvU,2399
|
25
|
+
oe_python_template_example/utils/_gui.py,sha256=mm7-PFPZsVAYISENM91ezlefs_Ax5dEOPzT7-u5xQe0,7749
|
26
26
|
oe_python_template_example/utils/_health.py,sha256=35QOWe2r5InrEpGtuVMym9dI5aRHS0HWf4BHBRAUIj0,4102
|
27
|
-
oe_python_template_example/utils/_log.py,sha256=
|
27
|
+
oe_python_template_example/utils/_log.py,sha256=9aalm3FzkIgx38Em4a49m5SrXvg0DiTzodTPTICUNuc,5042
|
28
28
|
oe_python_template_example/utils/_logfire.py,sha256=wZYNVowQx7kh3XJoJ59FjUKdrta7tp6cXOJRUT6lDU8,2128
|
29
|
-
oe_python_template_example/utils/_notebook.py,sha256=
|
29
|
+
oe_python_template_example/utils/_notebook.py,sha256=IZOQTRKVV5htwXZIpZIZbAAOo86NK1L8vYkg1o7HW1Q,2114
|
30
30
|
oe_python_template_example/utils/_process.py,sha256=40R0NZMqJUn0iUPERzohSUpJgU1HcJApIg1HipIxFCw,941
|
31
|
-
oe_python_template_example/utils/_sentry.py,sha256=
|
31
|
+
oe_python_template_example/utils/_sentry.py,sha256=6SZknbGBpdZHYUpF-11qk6UYBM-c79U8D9sF4517kdU,6369
|
32
32
|
oe_python_template_example/utils/_service.py,sha256=atHAejvBucKXjzhsMSdOBBFa7rRD74zcV70Pp0pl0Tg,1038
|
33
33
|
oe_python_template_example/utils/_settings.py,sha256=owFoaHEzJnVD3EVyOWF4rfIY7g6eLnU6rN0m4VHhCbA,2464
|
34
34
|
oe_python_template_example/utils/boot.py,sha256=Qgq1sjT8d3fcfibnMyx5CVUJ_KKXgFI3ozZUIJbhh4I,2921
|
35
35
|
oe_python_template_example/utils/.vendored/bottle.py,sha256=kZAZmh3nRzCUf-9IKGpv0yqlMciZMA_DNaaMDdcQmt0,175437
|
36
|
-
oe_python_template_example-0.4.
|
37
|
-
oe_python_template_example-0.4.
|
38
|
-
oe_python_template_example-0.4.
|
39
|
-
oe_python_template_example-0.4.
|
40
|
-
oe_python_template_example-0.4.
|
36
|
+
oe_python_template_example-0.4.10.dist-info/METADATA,sha256=Ip4GIwCvMfKzi4c1P7QcYAmvjaCqDbd7dH049AARTWE,26187
|
37
|
+
oe_python_template_example-0.4.10.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
38
|
+
oe_python_template_example-0.4.10.dist-info/entry_points.txt,sha256=S2eCPB45b1Wgj_GsDRFAN-e4h7dBA5UPxT8od98erDE,82
|
39
|
+
oe_python_template_example-0.4.10.dist-info/licenses/LICENSE,sha256=5H409K6xzz9U5eUaoAHQExNkoWJRlU0LEj6wL2QJ34s,1113
|
40
|
+
oe_python_template_example-0.4.10.dist-info/RECORD,,
|
{oe_python_template_example-0.4.9.dist-info → oe_python_template_example-0.4.10.dist-info}/WHEEL
RENAMED
File without changes
|
File without changes
|
File without changes
|