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.
Files changed (37) hide show
  1. streamlit_flexnav/__init__.py +10 -0
  2. streamlit_flexnav/cli.py +2 -0
  3. streamlit_flexnav/configs/__init__.py +2 -0
  4. streamlit_flexnav/core/__init__.py +2 -0
  5. streamlit_flexnav/core/loader.py +106 -0
  6. streamlit_flexnav/core/menuregistry.py +29 -0
  7. streamlit_flexnav/core/models/__init__.py +2 -0
  8. streamlit_flexnav/core/models/accesscontrol.py +37 -0
  9. streamlit_flexnav/core/models/linktarget.py +7 -0
  10. streamlit_flexnav/core/models/menugroup.py +30 -0
  11. streamlit_flexnav/core/models/menustruct.py +153 -0
  12. streamlit_flexnav/core/models/rolemode.py +25 -0
  13. streamlit_flexnav/core/settings.py +64 -0
  14. streamlit_flexnav/images/__init__.py +2 -0
  15. streamlit_flexnav/logging_setup.py +59 -0
  16. streamlit_flexnav/menupages/__menu__.py +11 -0
  17. streamlit_flexnav/menupages/settings/settings_admin.py +16 -0
  18. streamlit_flexnav/menupages/settings/settings_user.py +16 -0
  19. streamlit_flexnav/tools/__init__.py +2 -0
  20. streamlit_flexnav/tools/__main__.py +164 -0
  21. streamlit_flexnav/tools/cli.py +478 -0
  22. streamlit_flexnav/tools/debug_paths.py +54 -0
  23. streamlit_flexnav/tools/doctor.py +116 -0
  24. streamlit_flexnav/tools/fix_keys.py +114 -0
  25. streamlit_flexnav/tools/fix_streamlit_pages.py +115 -0
  26. streamlit_flexnav/tools/linter.py +150 -0
  27. streamlit_flexnav/tools/startup_checks.py +46 -0
  28. streamlit_flexnav/ui/__init__.py +2 -0
  29. streamlit_flexnav/ui/converter.py +46 -0
  30. streamlit_flexnav/ui/navigator.py +120 -0
  31. streamlit_flexnav/ui/session.py +5 -0
  32. streamlit_flexnav-0.1.0.dist-info/METADATA +131 -0
  33. streamlit_flexnav-0.1.0.dist-info/RECORD +37 -0
  34. streamlit_flexnav-0.1.0.dist-info/WHEEL +5 -0
  35. streamlit_flexnav-0.1.0.dist-info/entry_points.txt +2 -0
  36. streamlit_flexnav-0.1.0.dist-info/licenses/LICENSE +21 -0
  37. streamlit_flexnav-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,10 @@
1
+ """
2
+ streamlit-flexnav
3
+ -----------------
4
+ A navigation toolkit, menu schema, and Streamlit integration layer.
5
+ """
6
+ from .ui.navigator import render_navigation
7
+
8
+ __all__ = [
9
+ "render_navigation",
10
+ ]
@@ -0,0 +1,2 @@
1
+ def main():
2
+ print("FlexNav CLI is running")
@@ -0,0 +1,2 @@
1
+ # flexnav/configs/__init__.py
2
+ # Generated: 2026-01-31 • Version 0.1.0
@@ -0,0 +1,2 @@
1
+ # flexnav/core/__init__.py
2
+ # Generated: 2026-01-31 • Version 0.1.0
@@ -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,2 @@
1
+ # flexnav/core/__init__.py
2
+ # Generated: 2026-01-31 • Version 0.1.0
@@ -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,7 @@
1
+ from enum import Enum
2
+
3
+ class LinkTarget(str, Enum):
4
+ self_ = "_self"
5
+ blank = "_blank"
6
+ parent = "_parent"
7
+ top = "_top"
@@ -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,2 @@
1
+ # flexnav/images/__init__.py
2
+ # Generated: 2026-01-31 • Version 0.1.0
@@ -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,11 @@
1
+ from streamlit_flexnav.core.models.menugroup import MenuGroup
2
+
3
+ MENU = MenuGroup(
4
+ key="settings_settings_user",
5
+ label="Settings user",
6
+ order=0,
7
+ icon="📄",
8
+ color = "blue",
9
+ bold = False
10
+ italic = False,
11
+ )
@@ -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().")
@@ -0,0 +1,2 @@
1
+ # flexnav/core/__init__.py
2
+ # Generated: 2026-01-31 • Version 0.1.0