streamlit-flexnav 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- streamlit_flexnav/__init__.py +10 -0
- streamlit_flexnav/cli.py +2 -0
- streamlit_flexnav/configs/__init__.py +2 -0
- streamlit_flexnav/core/__init__.py +2 -0
- streamlit_flexnav/core/loader.py +106 -0
- streamlit_flexnav/core/menuregistry.py +29 -0
- streamlit_flexnav/core/models/__init__.py +2 -0
- streamlit_flexnav/core/models/accesscontrol.py +37 -0
- streamlit_flexnav/core/models/linktarget.py +7 -0
- streamlit_flexnav/core/models/menugroup.py +30 -0
- streamlit_flexnav/core/models/menustruct.py +153 -0
- streamlit_flexnav/core/models/rolemode.py +25 -0
- streamlit_flexnav/core/settings.py +64 -0
- streamlit_flexnav/images/__init__.py +2 -0
- streamlit_flexnav/logging_setup.py +59 -0
- streamlit_flexnav/menupages/__menu__.py +11 -0
- streamlit_flexnav/menupages/settings/settings_admin.py +16 -0
- streamlit_flexnav/menupages/settings/settings_user.py +16 -0
- streamlit_flexnav/tools/__init__.py +2 -0
- streamlit_flexnav/tools/__main__.py +164 -0
- streamlit_flexnav/tools/cli.py +478 -0
- streamlit_flexnav/tools/debug_paths.py +54 -0
- streamlit_flexnav/tools/doctor.py +116 -0
- streamlit_flexnav/tools/fix_keys.py +114 -0
- streamlit_flexnav/tools/fix_streamlit_pages.py +115 -0
- streamlit_flexnav/tools/linter.py +150 -0
- streamlit_flexnav/tools/startup_checks.py +46 -0
- streamlit_flexnav/ui/__init__.py +2 -0
- streamlit_flexnav/ui/converter.py +46 -0
- streamlit_flexnav/ui/navigator.py +120 -0
- streamlit_flexnav/ui/session.py +5 -0
- streamlit_flexnav-0.1.0.dist-info/METADATA +131 -0
- streamlit_flexnav-0.1.0.dist-info/RECORD +37 -0
- streamlit_flexnav-0.1.0.dist-info/WHEEL +5 -0
- streamlit_flexnav-0.1.0.dist-info/entry_points.txt +2 -0
- streamlit_flexnav-0.1.0.dist-info/licenses/LICENSE +21 -0
- streamlit_flexnav-0.1.0.dist-info/top_level.txt +1 -0
streamlit_flexnav/cli.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import structlog
|
|
7
|
+
|
|
8
|
+
from streamlit_flexnav.core.settings import MENUPAGES_ROOT
|
|
9
|
+
from streamlit_flexnav.core.models.menustruct import MenuStruct
|
|
10
|
+
from streamlit_flexnav.core.models.menugroup import MenuGroup
|
|
11
|
+
|
|
12
|
+
log = structlog.get_logger().bind(component="loader")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _load_module_from_path(path: Path):
|
|
16
|
+
spec = importlib.util.spec_from_file_location(path.stem, path)
|
|
17
|
+
module = importlib.util.module_from_spec(spec)
|
|
18
|
+
sys.modules[path.stem] = module
|
|
19
|
+
spec.loader.exec_module(module)
|
|
20
|
+
return module
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_menupages() -> list[MenuStruct]:
|
|
24
|
+
"""
|
|
25
|
+
Scan MENUPAGES_ROOT for .py files and extract PAGE = MenuStruct(...) definitions.
|
|
26
|
+
Folder structure is flattened into a single group name:
|
|
27
|
+
folder1/folder2/folder3 → "folder1_folder2_folder3"
|
|
28
|
+
"""
|
|
29
|
+
results: list[MenuStruct] = []
|
|
30
|
+
|
|
31
|
+
for file in MENUPAGES_ROOT.rglob("*.py"):
|
|
32
|
+
if file.name.startswith("_"):
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
module = _load_module_from_path(file)
|
|
37
|
+
|
|
38
|
+
if not hasattr(module, "PAGE"):
|
|
39
|
+
log.warning("missing_PAGE_struct", file=str(file))
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
page_struct = module.PAGE
|
|
43
|
+
|
|
44
|
+
if not isinstance(page_struct, MenuStruct):
|
|
45
|
+
log.error("invalid_PAGE_type", file=str(file))
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
# Inject absolute path
|
|
49
|
+
page_struct.path = str(file.resolve())
|
|
50
|
+
|
|
51
|
+
# Compute flattened group name
|
|
52
|
+
relative = file.relative_to(MENUPAGES_ROOT)
|
|
53
|
+
parts = relative.parts[:-1] # folders only
|
|
54
|
+
|
|
55
|
+
group = "_".join(parts) if parts else None
|
|
56
|
+
page_struct.group = group
|
|
57
|
+
|
|
58
|
+
results.append(page_struct)
|
|
59
|
+
|
|
60
|
+
except Exception as e:
|
|
61
|
+
log.exception("failed_loading_page", file=str(file), error=str(e))
|
|
62
|
+
|
|
63
|
+
log.info("menupages_loaded", count=len(results))
|
|
64
|
+
return results
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_menugroups() -> list[MenuGroup]:
|
|
69
|
+
"""
|
|
70
|
+
Discover all MenuGroup definitions under MENUPAGES_ROOT.
|
|
71
|
+
|
|
72
|
+
Expected pattern inside each module:
|
|
73
|
+
MENU = MenuGroup(...)
|
|
74
|
+
"""
|
|
75
|
+
results: list[MenuGroup] = []
|
|
76
|
+
|
|
77
|
+
for file in MENUPAGES_ROOT.rglob("*.py"):
|
|
78
|
+
if not file.name.startswith("__menu__"):
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
module = _load_module_from_path(file)
|
|
83
|
+
|
|
84
|
+
menu_struct = getattr(module, "MENU", None)
|
|
85
|
+
if menu_struct is None:
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
if not isinstance(menu_struct, MenuGroup):
|
|
89
|
+
log.error("invalid_MENU_type", file=str(file))
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
# Inject provenance
|
|
93
|
+
menu_struct.path = str(file.resolve())
|
|
94
|
+
|
|
95
|
+
results.append(menu_struct)
|
|
96
|
+
|
|
97
|
+
except Exception as e:
|
|
98
|
+
log.exception(
|
|
99
|
+
"failed_loading_menugroup",
|
|
100
|
+
file=str(file),
|
|
101
|
+
error=str(e),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
log.info("menugroups_loaded", count=len(results))
|
|
105
|
+
return results
|
|
106
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import structlog
|
|
4
|
+
from streamlit_flexnav.core.loader import load_menupages
|
|
5
|
+
from streamlit_flexnav.ui.converter import MenuStruct
|
|
6
|
+
|
|
7
|
+
log = structlog.get_logger().bind(component="menuregistry")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MenuRegistry:
|
|
11
|
+
"""
|
|
12
|
+
Holds the loaded MenuStruct objects.
|
|
13
|
+
Reloadable, but not manually mutable.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self):
|
|
17
|
+
self._items: list[MenuStruct] = []
|
|
18
|
+
self.reload()
|
|
19
|
+
|
|
20
|
+
def reload(self):
|
|
21
|
+
self._items = load_menupages()
|
|
22
|
+
log.info("menuregistry_reloaded", count=len(self._items))
|
|
23
|
+
|
|
24
|
+
def all(self) -> list[MenuStruct]:
|
|
25
|
+
return self._items
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Singleton
|
|
29
|
+
menuregistry = MenuRegistry()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
from pydantic import BaseModel, Field, model_validator
|
|
3
|
+
from .rolemode import RoleMode
|
|
4
|
+
|
|
5
|
+
class AccessControl(BaseModel):
|
|
6
|
+
"""
|
|
7
|
+
Role-based access control for a menu item.
|
|
8
|
+
Ensures no overlap between allowed and denied roles.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
allowed_roles: List[RoleMode] = Field(
|
|
12
|
+
default_factory=list,
|
|
13
|
+
description="Roles that may see/use this menu item."
|
|
14
|
+
)
|
|
15
|
+
denied_roles: List[RoleMode] = Field(
|
|
16
|
+
default_factory=list,
|
|
17
|
+
description="Roles that must never see/use this menu item."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
@model_validator(mode="after")
|
|
21
|
+
def validate_no_overlap(self):
|
|
22
|
+
overlap = set(self.allowed_roles) & set(self.denied_roles)
|
|
23
|
+
if overlap:
|
|
24
|
+
names = ", ".join(r.display_name() for r in overlap)
|
|
25
|
+
raise ValueError(f"Role(s) cannot be both allowed and denied: {names}")
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def is_allowed(self, role: RoleMode) -> bool:
|
|
29
|
+
"""
|
|
30
|
+
Returns True if the given role is permitted.
|
|
31
|
+
Denied roles always override allowed roles.
|
|
32
|
+
"""
|
|
33
|
+
if role in self.denied_roles:
|
|
34
|
+
return False
|
|
35
|
+
if self.allowed_roles and role not in self.allowed_roles:
|
|
36
|
+
return False
|
|
37
|
+
return True
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
from typing import Optional, List
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MenuGroup(BaseModel):
|
|
7
|
+
"""
|
|
8
|
+
Represents a styled menu/submenu section.
|
|
9
|
+
This is NOT a page. It is a visual grouping.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
key: str = Field(..., description="Unique group identifier")
|
|
13
|
+
label: str = Field(..., description="Displayed group label")
|
|
14
|
+
|
|
15
|
+
order: int = Field(
|
|
16
|
+
default=100,
|
|
17
|
+
ge=-10_000,
|
|
18
|
+
le=10_000,
|
|
19
|
+
description="Ordering in of menu",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Styling
|
|
23
|
+
icon: Optional[str] = None
|
|
24
|
+
color: Optional[str] = None
|
|
25
|
+
bold: bool = False
|
|
26
|
+
italic: bool = False
|
|
27
|
+
size: Optional[int] = None # 1–3 simulated sizes
|
|
28
|
+
|
|
29
|
+
# Pages inside this group
|
|
30
|
+
pages: List[object] = Field(default_factory=list)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional, Dict, Any, Annotated
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, HttpUrl, ConfigDict, field_validator
|
|
8
|
+
from annotated_types import MinLen, MaxLen
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MenuStruct(BaseModel):
|
|
12
|
+
"""
|
|
13
|
+
Strict Pydantic v2 model for FlexNav 2.0 navigation entries.
|
|
14
|
+
Loader injects an absolute filesystem path into `.path`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
model_config = ConfigDict(
|
|
18
|
+
extra="forbid",
|
|
19
|
+
str_strip_whitespace=True,
|
|
20
|
+
validate_assignment=True,
|
|
21
|
+
arbitrary_types_allowed=True,
|
|
22
|
+
frozen=False, # loader injects .path
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# ------------------------------------------------------------------
|
|
26
|
+
# Identity
|
|
27
|
+
# ------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
key: Annotated[str, MinLen(2), MaxLen(64)] = Field(
|
|
30
|
+
...,
|
|
31
|
+
pattern=r"^[a-z0-9][a-z0-9_-]{1,63}$",
|
|
32
|
+
description="Stable, unique key used as URL path",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
label: Annotated[str, MinLen(1), MaxLen(80)] = Field(
|
|
36
|
+
...,
|
|
37
|
+
description="Human-readable label for the sidebar",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
group: Optional[Annotated[str, MinLen(1), MaxLen(64)]] = Field(
|
|
41
|
+
default=None,
|
|
42
|
+
description="Folder-based grouping injected by loader",
|
|
43
|
+
)
|
|
44
|
+
file: Path
|
|
45
|
+
order: int = Field(
|
|
46
|
+
default=100,
|
|
47
|
+
ge=-10_000,
|
|
48
|
+
le=10_000,
|
|
49
|
+
description="Ordering in the menu",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
session_keys: Dict[str, object] = Field(
|
|
53
|
+
default_factory=dict,
|
|
54
|
+
description="Default session state keys for this page",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
# Copy / UX
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
description: Optional[Annotated[str, MaxLen(280)]] = None
|
|
62
|
+
tooltip: Optional[Annotated[str, MaxLen(140)]] = None
|
|
63
|
+
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
# Navigation
|
|
66
|
+
# ------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
path: Optional[str] = Field(
|
|
69
|
+
default=None,
|
|
70
|
+
description="Absolute filesystem path injected by loader",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
url: Optional[HttpUrl] = None
|
|
74
|
+
is_default: bool = False
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# State
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
enabled: bool = True
|
|
81
|
+
visible: bool = True
|
|
82
|
+
feature_flag: Optional[Annotated[str, MinLen(1), MaxLen(64)]] = None
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
# RBAC
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
required_role: str = Field(
|
|
89
|
+
default="none",
|
|
90
|
+
description="Role required to access this page",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
# Presentation
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
icon: Optional[Annotated[str, MinLen(1), MaxLen(64)]] = None
|
|
98
|
+
|
|
99
|
+
tags: List[Annotated[str, MinLen(1), MaxLen(32)]] = Field(
|
|
100
|
+
default_factory=list,
|
|
101
|
+
description="Tags for search, filtering, or grouping",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
shortcut: Optional[Annotated[str, MinLen(1), MaxLen(40)]] = None
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
# Hierarchy
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
children: List["MenuStruct"] = Field(default_factory=list)
|
|
111
|
+
parent: Optional[Annotated[str, MinLen(1), MaxLen(64)]] = None
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
# Extra metadata
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
telemetry: Dict[str, Any] = Field(default_factory=dict)
|
|
118
|
+
extra: Dict[str, Any] = Field(default_factory=dict)
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
# Validators
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
@field_validator("path")
|
|
125
|
+
@classmethod
|
|
126
|
+
def validate_absolute_path(cls, v: Optional[str]):
|
|
127
|
+
if v is None:
|
|
128
|
+
return v
|
|
129
|
+
p = Path(v)
|
|
130
|
+
if not p.is_absolute():
|
|
131
|
+
raise ValueError("path must be an absolute filesystem path")
|
|
132
|
+
return v
|
|
133
|
+
|
|
134
|
+
@field_validator("tags")
|
|
135
|
+
@classmethod
|
|
136
|
+
def dedupe_and_trim_tags(cls, v: List[str]):
|
|
137
|
+
out, seen = [], set()
|
|
138
|
+
for item in v:
|
|
139
|
+
t = item.strip()
|
|
140
|
+
if t and t not in seen:
|
|
141
|
+
out.append(t)
|
|
142
|
+
seen.add(t)
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
@field_validator("key")
|
|
146
|
+
@classmethod
|
|
147
|
+
def enforce_key_format(cls, v: str):
|
|
148
|
+
if not re.match(r"^[a-z0-9_-]+$", v):
|
|
149
|
+
raise ValueError("key must match ^[a-z0-9_-]+$")
|
|
150
|
+
return v
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
MenuStruct.model_rebuild()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
|
|
3
|
+
class RoleMode(IntEnum):
|
|
4
|
+
NONE = 0
|
|
5
|
+
VIEWER = 1
|
|
6
|
+
EDITOR = 2
|
|
7
|
+
KEYUSER = 3
|
|
8
|
+
ADMIN = 4 # highest privilege
|
|
9
|
+
|
|
10
|
+
def display_name(self) -> str:
|
|
11
|
+
return {
|
|
12
|
+
RoleMode.NONE: "None",
|
|
13
|
+
RoleMode.VIEWER: "Viewer",
|
|
14
|
+
RoleMode.EDITOR: "Editor",
|
|
15
|
+
RoleMode.KEYUSER: "Key User",
|
|
16
|
+
RoleMode.ADMIN: "Admin",
|
|
17
|
+
}[self]
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def from_label(cls, label: str) -> "RoleMode":
|
|
21
|
+
normalized = label.strip().lower()
|
|
22
|
+
for role in cls:
|
|
23
|
+
if role.display_name().lower() == normalized:
|
|
24
|
+
return role
|
|
25
|
+
raise ValueError(f"Unknown role label: {label}")
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import yaml
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import structlog
|
|
7
|
+
|
|
8
|
+
log = structlog.get_logger().bind(component="settings")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# -------------------------------------------------------------------
|
|
12
|
+
# ENVIRONMENT
|
|
13
|
+
# -------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
ENV = os.getenv("FLEXNAV_ENV", "dev")
|
|
16
|
+
CONFIG_FILE = Path(f"settings.{ENV}.yaml")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# -------------------------------------------------------------------
|
|
20
|
+
# CONFIG LOADING (OPTIONAL)
|
|
21
|
+
# -------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
def _load_yaml_config(path: Path) -> dict:
|
|
24
|
+
"""
|
|
25
|
+
Load YAML config if it exists.
|
|
26
|
+
If missing, return {} and log a warning.
|
|
27
|
+
"""
|
|
28
|
+
if not path.exists():
|
|
29
|
+
log.warning("config_file_missing", path=str(path))
|
|
30
|
+
return {}
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
with path.open("r", encoding="utf-8") as f:
|
|
34
|
+
return yaml.safe_load(f) or {}
|
|
35
|
+
except Exception as e:
|
|
36
|
+
log.error("config_load_failed", path=str(path), error=str(e))
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
RAW_CONFIG = _load_yaml_config(CONFIG_FILE)
|
|
41
|
+
CONFIG = RAW_CONFIG.get("flexnav", {})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# -------------------------------------------------------------------
|
|
45
|
+
# SAFE DEFAULTS
|
|
46
|
+
# -------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
# Default menupages root (library-safe)
|
|
49
|
+
DEFAULT_MENUPAGES_ROOT = Path("app/menupages")
|
|
50
|
+
|
|
51
|
+
# Allow override from YAML config
|
|
52
|
+
MENUPAGES_ROOT = Path(CONFIG.get("menupages_root", DEFAULT_MENUPAGES_ROOT)).resolve()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# -------------------------------------------------------------------
|
|
56
|
+
# LOGGING
|
|
57
|
+
# -------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
log.info(
|
|
60
|
+
"settings_loaded",
|
|
61
|
+
env=ENV,
|
|
62
|
+
config_file=str(CONFIG_FILE),
|
|
63
|
+
menupages_root=str(MENUPAGES_ROOT),
|
|
64
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import structlog
|
|
5
|
+
|
|
6
|
+
from streamlit_flexnav.core.configs.admin_app_settings import load_admin_settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def configure_logging():
|
|
10
|
+
"""
|
|
11
|
+
Configure structlog with optional file logging.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
settings = load_admin_settings()
|
|
15
|
+
log_cfg = settings.flexnav.logging
|
|
16
|
+
|
|
17
|
+
handlers = []
|
|
18
|
+
|
|
19
|
+
# Console handler (always enabled)
|
|
20
|
+
console_handler = logging.StreamHandler()
|
|
21
|
+
console_handler.setFormatter(logging.Formatter("%(message)s"))
|
|
22
|
+
handlers.append(console_handler)
|
|
23
|
+
|
|
24
|
+
# Optional file handler
|
|
25
|
+
if log_cfg.to_file:
|
|
26
|
+
file_path = Path(log_cfg.file_path)
|
|
27
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
|
|
29
|
+
file_handler = logging.FileHandler(file_path, encoding="utf-8")
|
|
30
|
+
file_handler.setFormatter(logging.Formatter("%(message)s"))
|
|
31
|
+
handlers.append(file_handler)
|
|
32
|
+
|
|
33
|
+
logging.basicConfig(
|
|
34
|
+
level=getattr(logging, log_cfg.level.upper(), logging.INFO),
|
|
35
|
+
handlers=handlers,
|
|
36
|
+
format="%(message)s",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Choose renderer
|
|
40
|
+
if sys.stdout.isatty():
|
|
41
|
+
renderer = structlog.dev.ConsoleRenderer()
|
|
42
|
+
else:
|
|
43
|
+
renderer = structlog.processors.JSONRenderer()
|
|
44
|
+
|
|
45
|
+
structlog.configure(
|
|
46
|
+
processors=[
|
|
47
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
48
|
+
structlog.processors.add_log_level,
|
|
49
|
+
structlog.processors.StackInfoRenderer(),
|
|
50
|
+
structlog.processors.format_exc_info,
|
|
51
|
+
renderer,
|
|
52
|
+
],
|
|
53
|
+
wrapper_class=structlog.make_filtering_bound_logger(
|
|
54
|
+
getattr(logging, log_cfg.level.upper(), logging.INFO)
|
|
55
|
+
),
|
|
56
|
+
context_class=dict,
|
|
57
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
58
|
+
cache_logger_on_first_use=True,
|
|
59
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from streamlit_flexnav.core.models.menustruct import MenuStruct
|
|
3
|
+
import streamlit as st
|
|
4
|
+
|
|
5
|
+
PAGE = MenuStruct(
|
|
6
|
+
key="settings_settings_admin",
|
|
7
|
+
label="Settings admin",
|
|
8
|
+
icon="📄",
|
|
9
|
+
order=0,
|
|
10
|
+
file=Path(__file__),
|
|
11
|
+
group=None,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
def render():
|
|
15
|
+
st.title("Settings admin")
|
|
16
|
+
st.write("This page uses render() instead of main().")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from streamlit_flexnav.core.models.menustruct import MenuStruct
|
|
3
|
+
import streamlit as st
|
|
4
|
+
|
|
5
|
+
PAGE = MenuStruct(
|
|
6
|
+
key="settings_settings_user",
|
|
7
|
+
label="Settings user",
|
|
8
|
+
icon="📄",
|
|
9
|
+
order=0,
|
|
10
|
+
file=Path(__file__),
|
|
11
|
+
group=None,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
def render():
|
|
15
|
+
st.title("Settings user")
|
|
16
|
+
st.write("This page uses render() instead of main().")
|