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.
@@ -16,7 +16,7 @@ from .utils import (
16
16
  __base__url__,
17
17
  __documentation__url__,
18
18
  __repository_url__,
19
- locate_implementations,
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
- # Register routers with appropriate API versions
71
- for router in locate_implementations(VersionedAPIRouter):
72
- router_instance: VersionedAPIRouter = router
73
- version = router_instance.version
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(router_instance)
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
- result = await GUILocalFilePicker(str(Path.cwd() / "examples"), multiple=True)
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")] = 10,
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
- from fastapi import APIRouter
1
+ """API router utilities for versioned FastAPI routers."""
2
2
 
3
+ from typing import ClassVar
3
4
 
4
- class VersionedAPIRouter(APIRouter):
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
- version: str
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
- def __init__(self, version: str, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
17
- super().__init__(*args, **kwargs)
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(ui.dialog):
85
- def __init__(
86
- self,
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
- ) -> None:
93
- """Local File Picker.
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
- super().__init__()
104
-
105
- self.path = Path(directory).expanduser()
106
- if upper_limit is None:
107
- self.upper_limit = None
108
- elif upper_limit is ...:
109
- self.upper_limit = Path(directory).expanduser()
110
- else:
111
- self.upper_limit = Path(upper_limit).expanduser()
112
- self.show_hidden_files = show_hidden_files
113
-
114
- with self, ui.card():
115
- self.add_drives_toggle()
116
- self.grid = (
117
- ui.aggrid(
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
- "columnDefs": [{"field": "name", "headerName": "File"}],
120
- "rowSelection": "multiple" if multiple else "single",
121
- },
122
- html_columns=[0],
123
- )
124
- .classes("w-96")
125
- .on("cellDoubleClicked", self.handle_double_click)
126
- )
127
- with ui.row().classes("w-full justify-end"):
128
- ui.button("Cancel", on_click=self.close).props("outline").mark("BUTTON_CANCEL")
129
- ui.button("Ok", on_click=self._handle_ok).mark("BUTTON_OK")
130
- self.update_grid()
131
-
132
- def add_drives_toggle(self) -> None:
133
- if platform.system() == "Windows":
134
- import win32api # noqa: PLC0415
135
-
136
- drives = win32api.GetLogicalDriveStrings().split("\000")[:-1]
137
- self.drives_toggle = ui.toggle(drives, value=drives[0], on_change=self.update_drive)
138
-
139
- def update_drive(self) -> None:
140
- self.path = Path(self.drives_toggle.value).expanduser()
141
- self.update_grid()
142
-
143
- def update_grid(self) -> None:
144
- paths = list(self.path.glob("*"))
145
- if not self.show_hidden_files:
146
- paths = [p for p in paths if not p.name.startswith(".")]
147
- paths.sort(key=lambda p: p.name.lower())
148
- paths.sort(key=lambda p: not p.is_dir())
149
-
150
- self.grid.options["rowData"] = [
151
- {
152
- "name": f"📁 <strong>{p.name}</strong>" if p.is_dir() else p.name,
153
- "path": str(p),
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
- Field(description="Name of the log file", default=f"{__project_name__}.log"),
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
- """System service."""
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: APIRouter) -> Callable[..., Health]:
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 health_endpoint
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() -> FastAPI:
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
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oe-python-template-example
3
- Version: 0.4.9
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/
@@ -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=gwUAW_pWUABFtAAiuIg3-X-dG25m1l0rqpnMqlaGEJo,2206
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=B4gCojmEkvh-ScKPz0rXW70r4gVvg7SX2dfbZpUd2vU,2302
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=cYzag6lLD-zQGgTBcgXRDfbuxjtF927OVrvLAcFfpHs,1470
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=rE9Aau3IIHXdEkOBUXOwJ7SxN3cZpgtYEuojnSWfT_4,3687
15
- oe_python_template_example/system/_cli.py,sha256=D19TuXtGGmxzQ-VkmCD7fmuqKQ5TQoJq9O0VzJKvWVE,6960
16
- oe_python_template_example/system/_gui.py,sha256=uKI-tlBSJXMaxY79wgdYtMttEyu8BLQC1UQLEPOcoZg,653
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=ggcc7xvH6MJ3kP45lfjmp8yZSPDTeQRaQYbZbG0M7yM,1924
20
- oe_python_template_example/utils/_api.py,sha256=w3hPQK1pL2gBI4_1qNWNa2b4S_oH-8mY-ckRX0KrCWM,617
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=FRe5ZNaBwpBPwOHZVWYOlI-ijamfzdBVr8gl7gHMGT0,2932
24
- oe_python_template_example/utils/_di.py,sha256=KdjiD4xZ_QSfbddkKWwsPJmG5YrIg6dzuBrlsd-FhxA,2189
25
- oe_python_template_example/utils/_gui.py,sha256=PmMTSmUOxbf6pmYCIgKrK5wNheh35aGd90I12y3L7Uc,5720
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=ZW4gs540SdjVK-2KeheLfDY15d_3xpO5FyGn7wTXyaM,3592
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=oBQw9IBcXjuhzd1ECfOEPN4WJHGOm9xiPtrs11GtWG4,1777
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=2sXrDSZSYoDEM87v7CakJ6eGBtcIhDI48PsQCLwOHgg,3319
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.9.dist-info/METADATA,sha256=PjMZgt6PGQaKG4xrZy7iHacjvWmDaNoiYol3lLoagIk,26186
37
- oe_python_template_example-0.4.9.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
38
- oe_python_template_example-0.4.9.dist-info/entry_points.txt,sha256=S2eCPB45b1Wgj_GsDRFAN-e4h7dBA5UPxT8od98erDE,82
39
- oe_python_template_example-0.4.9.dist-info/licenses/LICENSE,sha256=5H409K6xzz9U5eUaoAHQExNkoWJRlU0LEj6wL2QJ34s,1113
40
- oe_python_template_example-0.4.9.dist-info/RECORD,,
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,,