milo-cli 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.
milo/config.py ADDED
@@ -0,0 +1,250 @@
1
+ """Configuration system with deep merge, environment overlays, and origin tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import glob as globmod
6
+ import os
7
+ import tomllib
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class ConfigSpec:
15
+ """Declarative configuration schema.
16
+
17
+ Describes where config comes from and how it merges::
18
+
19
+ cli.config_spec = ConfigSpec(
20
+ sources=("bengal.toml", "config/*.yaml"),
21
+ env_prefix="BENGAL_",
22
+ profiles={"writer": {"build.drafts": True}},
23
+ overlays={"production": "config/production.yaml"},
24
+ )
25
+ """
26
+
27
+ sources: tuple[str, ...] = ()
28
+ """File patterns to load (TOML via stdlib, YAML via optional pyyaml)."""
29
+
30
+ env_prefix: str = ""
31
+ """Environment variable prefix. BENGAL_BUILD_OUTPUT -> build.output."""
32
+
33
+ defaults: dict[str, Any] = field(default_factory=dict)
34
+ """Default values (lowest precedence)."""
35
+
36
+ profiles: dict[str, dict[str, Any]] = field(default_factory=dict)
37
+ """Named override sets, selected via --profile."""
38
+
39
+ overlays: dict[str, str] = field(default_factory=dict)
40
+ """Environment -> file path mapping for env-specific config."""
41
+
42
+
43
+ class Config:
44
+ """Immutable, merged configuration with origin tracking.
45
+
46
+ Usage::
47
+
48
+ config = Config.load(spec, root=Path("."), profile="dev", overlay="production")
49
+ url = config.get("site.url", "http://localhost")
50
+ print(config.origin_of("site.url")) # "file:config/site.yaml"
51
+ """
52
+
53
+ def __init__(self, data: dict[str, Any], origins: dict[str, str]) -> None:
54
+ self._data = data
55
+ self._origins = origins
56
+
57
+ @classmethod
58
+ def load(
59
+ cls,
60
+ spec: ConfigSpec,
61
+ *,
62
+ root: Path | None = None,
63
+ profile: str = "",
64
+ overlay: str = "",
65
+ ) -> Config:
66
+ """Load, merge, and freeze config from all sources.
67
+
68
+ Merge precedence (lowest to highest):
69
+ 1. spec.defaults
70
+ 2. File sources (in order)
71
+ 3. Environment variables
72
+ 4. Profile overrides
73
+ 5. Overlay file
74
+ """
75
+ root = root or Path.cwd()
76
+ data: dict[str, Any] = {}
77
+ origins: dict[str, str] = {}
78
+
79
+ # 1. Defaults
80
+ if spec.defaults:
81
+ _deep_merge(data, spec.defaults, origins, origin="defaults")
82
+
83
+ # 2. File sources
84
+ for pattern in spec.sources:
85
+ matched = sorted(globmod.glob(str(root / pattern)))
86
+ for filepath in matched:
87
+ file_data = _load_file(filepath)
88
+ _deep_merge(data, file_data, origins, origin=f"file:{filepath}")
89
+
90
+ # 3. Environment variables
91
+ if spec.env_prefix:
92
+ env_data = _load_env_vars(spec.env_prefix)
93
+ _deep_merge(data, env_data, origins, origin="env")
94
+
95
+ # 4. Profile overrides
96
+ if profile and profile in spec.profiles:
97
+ profile_data = _expand_dotted(spec.profiles[profile])
98
+ _deep_merge(data, profile_data, origins, origin=f"profile:{profile}")
99
+
100
+ # 5. Overlay file
101
+ if overlay and overlay in spec.overlays:
102
+ overlay_path = root / spec.overlays[overlay]
103
+ if overlay_path.exists():
104
+ overlay_data = _load_file(str(overlay_path))
105
+ _deep_merge(data, overlay_data, origins, origin=f"overlay:{overlay}")
106
+
107
+ return cls(data, origins)
108
+
109
+ @classmethod
110
+ def from_dict(cls, data: dict[str, Any], origin: str = "dict") -> Config:
111
+ """Create a Config from a plain dictionary."""
112
+ origins: dict[str, str] = {}
113
+ _track_origins(data, origins, origin, prefix="")
114
+ return cls(data, origins)
115
+
116
+ def get(self, key: str, default: Any = None) -> Any:
117
+ """Dot-notation access: ``config.get("site.url")``."""
118
+ parts = key.split(".")
119
+ current = self._data
120
+ for part in parts:
121
+ if not isinstance(current, dict) or part not in current:
122
+ return default
123
+ current = current[part]
124
+ return current
125
+
126
+ def origin_of(self, key: str) -> str:
127
+ """Return the source that contributed a key's value."""
128
+ return self._origins.get(key, "")
129
+
130
+ def as_dict(self) -> dict[str, Any]:
131
+ """Return a deep copy of the merged config."""
132
+ import copy
133
+
134
+ return copy.deepcopy(self._data)
135
+
136
+ def to_state(self) -> dict[str, Any]:
137
+ """Convert to a Store-compatible state dict."""
138
+ return self.as_dict()
139
+
140
+ def __contains__(self, key: str) -> bool:
141
+ return self.get(key) is not None
142
+
143
+ def __repr__(self) -> str:
144
+ return f"Config({list(self._data.keys())})"
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Internal helpers
149
+ # ---------------------------------------------------------------------------
150
+
151
+
152
+ def _load_file(filepath: str) -> dict[str, Any]:
153
+ """Load a TOML or YAML file."""
154
+ path = Path(filepath)
155
+ suffix = path.suffix.lower()
156
+
157
+ if suffix == ".toml":
158
+ with open(path, "rb") as f:
159
+ return tomllib.load(f)
160
+
161
+ if suffix in (".yaml", ".yml"):
162
+ try:
163
+ import yaml # type: ignore[import-untyped]
164
+ except ImportError as e:
165
+ msg = (
166
+ f"Cannot load {path.name}: PyYAML is required for YAML config files. "
167
+ "Install it with: pip install pyyaml"
168
+ )
169
+ raise ImportError(msg) from e
170
+ with open(path) as f:
171
+ return yaml.safe_load(f) or {}
172
+
173
+ if suffix == ".json":
174
+ import json
175
+
176
+ with open(path) as f:
177
+ return json.load(f)
178
+
179
+ msg = f"Unsupported config format: {suffix}"
180
+ raise ValueError(msg)
181
+
182
+
183
+ def _load_env_vars(prefix: str) -> dict[str, Any]:
184
+ """Load environment variables with a given prefix into nested dict.
185
+
186
+ BENGAL_SITE_URL -> {"site": {"url": "value"}}
187
+ """
188
+ result: dict[str, Any] = {}
189
+ prefix_upper = prefix.upper()
190
+ for key, value in os.environ.items():
191
+ if not key.startswith(prefix_upper):
192
+ continue
193
+ # Strip prefix and convert to nested path
194
+ remainder = key[len(prefix_upper) :].lower()
195
+ parts = remainder.split("_")
196
+ # Build nested dict
197
+ current = result
198
+ for part in parts[:-1]:
199
+ current = current.setdefault(part, {})
200
+ current[parts[-1]] = value
201
+ return result
202
+
203
+
204
+ def _deep_merge(
205
+ target: dict[str, Any],
206
+ source: dict[str, Any],
207
+ origins: dict[str, str],
208
+ origin: str,
209
+ prefix: str = "",
210
+ ) -> None:
211
+ """Deep merge source into target, tracking origins for leaf values."""
212
+ for key, value in source.items():
213
+ dotted = f"{prefix}{key}" if prefix else key
214
+ if key in target and isinstance(target[key], dict) and isinstance(value, dict):
215
+ _deep_merge(target[key], value, origins, origin, prefix=f"{dotted}.")
216
+ else:
217
+ target[key] = value
218
+ if isinstance(value, dict):
219
+ _track_origins(value, origins, origin, prefix=f"{dotted}.")
220
+ else:
221
+ origins[dotted] = origin
222
+
223
+
224
+ def _track_origins(
225
+ data: dict[str, Any],
226
+ origins: dict[str, str],
227
+ origin: str,
228
+ prefix: str,
229
+ ) -> None:
230
+ """Recursively track origins for all keys in a dict."""
231
+ for key, value in data.items():
232
+ dotted = f"{prefix}{key}" if prefix else key
233
+ origins[dotted] = origin
234
+ if isinstance(value, dict):
235
+ _track_origins(value, origins, origin, prefix=f"{dotted}.")
236
+
237
+
238
+ def _expand_dotted(flat: dict[str, Any]) -> dict[str, Any]:
239
+ """Expand dotted keys into nested dicts.
240
+
241
+ {"build.output": "_site"} -> {"build": {"output": "_site"}}
242
+ """
243
+ result: dict[str, Any] = {}
244
+ for key, value in flat.items():
245
+ parts = key.split(".")
246
+ current = result
247
+ for part in parts[:-1]:
248
+ current = current.setdefault(part, {})
249
+ current[parts[-1]] = value
250
+ return result
milo/context.py ADDED
@@ -0,0 +1,81 @@
1
+ """Execution context for CLI command handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from contextvars import ContextVar
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class Context:
13
+ """Execution context available to all command handlers.
14
+
15
+ Handlers opt in by adding a ``ctx: Context`` parameter::
16
+
17
+ @cli.command("build", description="Build the site")
18
+ def build(output: str = "_site", ctx: Context = None) -> str:
19
+ ctx.log("Starting build...", level=1) # verbose only
20
+ return f"Built to {output}"
21
+
22
+ The context is injected automatically by the CLI dispatcher and
23
+ excluded from argparse and MCP schemas.
24
+ """
25
+
26
+ verbosity: int = 0
27
+ """Verbosity level: -1=quiet, 0=normal, 1=verbose, 2=debug."""
28
+
29
+ format: str = "plain"
30
+ """Output format: plain, json, table."""
31
+
32
+ color: bool = True
33
+ """Whether color output is enabled."""
34
+
35
+ globals: dict[str, Any] = field(default_factory=dict)
36
+ """Values from user-defined global options."""
37
+
38
+ def log(self, message: str, *, level: int = 0) -> None:
39
+ """Print a message if verbosity is at or above *level*.
40
+
41
+ level 0 = normal (always shown unless quiet)
42
+ level 1 = verbose (shown with --verbose)
43
+ level 2 = debug (shown with -vv)
44
+ """
45
+ if self.verbosity >= level:
46
+ sys.stderr.write(message + "\n")
47
+ sys.stderr.flush()
48
+
49
+ @property
50
+ def quiet(self) -> bool:
51
+ """True when --quiet was passed."""
52
+ return self.verbosity < 0
53
+
54
+ @property
55
+ def verbose(self) -> bool:
56
+ """True when --verbose was passed."""
57
+ return self.verbosity >= 1
58
+
59
+ @property
60
+ def debug(self) -> bool:
61
+ """True when -vv (or higher) was passed."""
62
+ return self.verbosity >= 2
63
+
64
+
65
+ _current_context: ContextVar[Context | None] = ContextVar("milo_context", default=None)
66
+
67
+
68
+ def get_context() -> Context:
69
+ """Get the current execution context.
70
+
71
+ Returns a default Context if none has been set.
72
+ """
73
+ ctx = _current_context.get()
74
+ if ctx is None:
75
+ return Context()
76
+ return ctx
77
+
78
+
79
+ def set_context(ctx: Context) -> None:
80
+ """Set the current execution context."""
81
+ _current_context.set(ctx)
milo/dev.py ADDED
@@ -0,0 +1,238 @@
1
+ """Hot-reload dev server with smart file watching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import sys
7
+ import threading
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from milo._types import Action
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # File watcher abstraction
15
+ # ---------------------------------------------------------------------------
16
+
17
+ _STYLE_EXTENSIONS = frozenset({".css", ".scss", ".sass", ".less"})
18
+
19
+
20
+ class _FileWatcher:
21
+ """Base watcher interface."""
22
+
23
+ def __init__(
24
+ self,
25
+ dirs: tuple[Path, ...],
26
+ *,
27
+ extensions: frozenset[str] | None = None,
28
+ poll_interval: float = 0.5,
29
+ ) -> None:
30
+ self._dirs = dirs
31
+ self._extensions = extensions
32
+ self._poll_interval = poll_interval
33
+
34
+ def watch(self, callback: Any, stop_event: threading.Event) -> None:
35
+ raise NotImplementedError
36
+
37
+
38
+ class _PollingWatcher(_FileWatcher):
39
+ """Polling-based watcher (stdlib only, no dependencies)."""
40
+
41
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
42
+ super().__init__(*args, **kwargs)
43
+ self._mtimes: dict[Path, float] = {}
44
+
45
+ def watch(self, callback: Any, stop_event: threading.Event) -> None:
46
+ self._scan_mtimes()
47
+ while not stop_event.is_set():
48
+ stop_event.wait(self._poll_interval)
49
+ if stop_event.is_set():
50
+ break
51
+ changed = self._check_changes()
52
+ if changed:
53
+ callback(changed)
54
+
55
+ def _scan_mtimes(self) -> None:
56
+ for d in self._dirs:
57
+ if not d.exists():
58
+ continue
59
+ for p in d.rglob("*"):
60
+ if p.is_file() and self._matches(p):
61
+ with contextlib.suppress(OSError):
62
+ self._mtimes[p] = p.stat().st_mtime
63
+
64
+ def _check_changes(self) -> list[Path]:
65
+ changed: list[Path] = []
66
+ for d in self._dirs:
67
+ if not d.exists():
68
+ continue
69
+ for p in d.rglob("*"):
70
+ if not p.is_file() or not self._matches(p):
71
+ continue
72
+ try:
73
+ mtime = p.stat().st_mtime
74
+ except OSError:
75
+ continue
76
+ if p in self._mtimes:
77
+ if mtime > self._mtimes[p]:
78
+ changed.append(p)
79
+ self._mtimes[p] = mtime
80
+ else:
81
+ self._mtimes[p] = mtime
82
+ changed.append(p)
83
+ return changed
84
+
85
+ def _matches(self, path: Path) -> bool:
86
+ if self._extensions is None:
87
+ return True
88
+ return path.suffix in self._extensions
89
+
90
+
91
+ class _WatchfilesWatcher(_FileWatcher):
92
+ """Rust-based watcher using the ``watchfiles`` package."""
93
+
94
+ def watch(self, callback: Any, stop_event: threading.Event) -> None:
95
+ import watchfiles # type: ignore[import-untyped]
96
+
97
+ for changes in watchfiles.watch(
98
+ *self._dirs,
99
+ stop_event=stop_event,
100
+ poll_delay_ms=int(self._poll_interval * 1000),
101
+ ):
102
+ changed = [
103
+ Path(path)
104
+ for _change_type, path in changes
105
+ if self._extensions is None or Path(path).suffix in self._extensions
106
+ ]
107
+ if changed:
108
+ callback(changed)
109
+
110
+
111
+ def _make_watcher(
112
+ dirs: tuple[Path, ...],
113
+ *,
114
+ extensions: frozenset[str] | None = None,
115
+ poll_interval: float = 0.5,
116
+ ) -> _FileWatcher:
117
+ """Try watchfiles first, fall back to polling."""
118
+ try:
119
+ import watchfiles # type: ignore[import-untyped] # noqa: F401
120
+
121
+ return _WatchfilesWatcher(dirs, extensions=extensions, poll_interval=poll_interval)
122
+ except ImportError:
123
+ return _PollingWatcher(dirs, extensions=extensions, poll_interval=poll_interval)
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Change batching / debounce
128
+ # ---------------------------------------------------------------------------
129
+
130
+
131
+ class _ChangeBatcher:
132
+ """Collects file changes and flushes them after a debounce window."""
133
+
134
+ def __init__(self, debounce: float = 0.1) -> None:
135
+ self._debounce = debounce
136
+ self._pending: list[Path] = []
137
+ self._lock = threading.Lock()
138
+ self._timer: threading.Timer | None = None
139
+ self._flush_callback: Any = None
140
+
141
+ def set_callback(self, callback: Any) -> None:
142
+ self._flush_callback = callback
143
+
144
+ def add(self, paths: list[Path]) -> None:
145
+ with self._lock:
146
+ self._pending.extend(paths)
147
+ if self._timer is not None:
148
+ self._timer.cancel()
149
+ self._timer = threading.Timer(self._debounce, self._flush)
150
+ self._timer.daemon = True
151
+ self._timer.start()
152
+
153
+ def _flush(self) -> None:
154
+ with self._lock:
155
+ paths = list(self._pending)
156
+ self._pending.clear()
157
+ self._timer = None
158
+ if paths and self._flush_callback:
159
+ self._flush_callback(paths)
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # DevServer
164
+ # ---------------------------------------------------------------------------
165
+
166
+
167
+ class DevServer:
168
+ """Watches templates and content, re-renders on change.
169
+
170
+ Tries ``watchfiles`` (Rust-based) for performance, falls back to
171
+ stdlib polling. Dispatches smart reload actions:
172
+
173
+ - ``@@CSS_RELOAD`` for style-only changes
174
+ - ``@@HOT_RELOAD`` for everything else
175
+
176
+ Usage::
177
+
178
+ dev = DevServer(
179
+ app,
180
+ watch_dirs=("templates", "content", "static"),
181
+ debounce=0.15,
182
+ )
183
+ dev.run()
184
+ """
185
+
186
+ def __init__(
187
+ self,
188
+ app: Any,
189
+ *,
190
+ watch_dirs: tuple[str | Path, ...] = (),
191
+ extensions: frozenset[str] | None = None,
192
+ poll_interval: float = 0.5,
193
+ debounce: float = 0.1,
194
+ ) -> None:
195
+ self._app = app
196
+ self._watch_dirs = tuple(Path(d) for d in watch_dirs)
197
+ self._extensions = extensions
198
+ self._poll_interval = poll_interval
199
+ self._stop = threading.Event()
200
+ self._batcher = _ChangeBatcher(debounce=debounce)
201
+ self._batcher.set_callback(self._on_changes)
202
+
203
+ def run(self) -> Any:
204
+ """Run app with hot-reload enabled."""
205
+ watcher = _make_watcher(
206
+ self._watch_dirs,
207
+ extensions=self._extensions,
208
+ poll_interval=self._poll_interval,
209
+ )
210
+
211
+ thread = threading.Thread(
212
+ target=watcher.watch,
213
+ args=(self._batcher.add, self._stop),
214
+ daemon=True,
215
+ )
216
+ thread.start()
217
+
218
+ sys.stderr.write("[milo] dev server started, watching for changes\n")
219
+
220
+ try:
221
+ return self._app.run()
222
+ finally:
223
+ self._stop.set()
224
+
225
+ def _on_changes(self, paths: list[Path]) -> None:
226
+ """Dispatch appropriate reload action for changed files."""
227
+ style_only = all(p.suffix in _STYLE_EXTENSIONS for p in paths)
228
+ action_type = "@@CSS_RELOAD" if style_only else "@@HOT_RELOAD"
229
+
230
+ for path in paths:
231
+ sys.stderr.write(f"[milo] changed: {path.name}\n")
232
+
233
+ try:
234
+ if hasattr(self._app, "_store"):
235
+ filenames = [str(p) for p in paths]
236
+ self._app._store.dispatch(Action(action_type, payload=filenames))
237
+ except Exception as e:
238
+ sys.stderr.write(f"[milo] reload error: {e}\n")