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/__init__.py +183 -0
- milo/_child.py +141 -0
- milo/_errors.py +189 -0
- milo/_protocols.py +37 -0
- milo/_types.py +234 -0
- milo/app.py +353 -0
- milo/cli.py +134 -0
- milo/commands.py +951 -0
- milo/config.py +250 -0
- milo/context.py +81 -0
- milo/dev.py +238 -0
- milo/flow.py +146 -0
- milo/form.py +277 -0
- milo/gateway.py +393 -0
- milo/groups.py +194 -0
- milo/help.py +84 -0
- milo/input/__init__.py +6 -0
- milo/input/_platform.py +81 -0
- milo/input/_reader.py +93 -0
- milo/input/_sequences.py +63 -0
- milo/llms.py +172 -0
- milo/mcp.py +299 -0
- milo/middleware.py +67 -0
- milo/observability.py +111 -0
- milo/output.py +106 -0
- milo/pipeline.py +276 -0
- milo/plugins.py +168 -0
- milo/py.typed +0 -0
- milo/registry.py +213 -0
- milo/schema.py +214 -0
- milo/state.py +229 -0
- milo/streaming.py +41 -0
- milo/templates/__init__.py +38 -0
- milo/templates/error.kida +5 -0
- milo/templates/field_confirm.kida +1 -0
- milo/templates/field_select.kida +3 -0
- milo/templates/field_text.kida +1 -0
- milo/templates/form.kida +8 -0
- milo/templates/help.kida +7 -0
- milo/templates/progress.kida +1 -0
- milo/testing/__init__.py +27 -0
- milo/testing/_mcp.py +87 -0
- milo/testing/_record.py +125 -0
- milo/testing/_replay.py +68 -0
- milo/testing/_snapshot.py +96 -0
- milo_cli-0.1.0.dist-info/METADATA +441 -0
- milo_cli-0.1.0.dist-info/RECORD +50 -0
- milo_cli-0.1.0.dist-info/WHEEL +5 -0
- milo_cli-0.1.0.dist-info/entry_points.txt +2 -0
- milo_cli-0.1.0.dist-info/top_level.txt +1 -0
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")
|