avalon-cli 0.2.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.
avalon_cli/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """avalon-cli: command-line companion for the Avalon real-time web framework.
2
+
3
+ The package is self-contained (standard library only) and does not import
4
+ ``avalon`` itself. The public API mirrors the CLI commands:
5
+
6
+ * :func:`create_project` - ``avalon new``
7
+ * :class:`DevServer` / :func:`build_command` - ``avalon dev``
8
+ * :func:`load_config` - reads ``avalon.toml``
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __version__ = "0.2.0"
14
+
15
+ from .config import ConfigError, DevConfig, ProjectConfig, find_project_root, load_config # noqa: E402
16
+ from .reloader import DevServer, build_command, diff_snapshots, take_snapshot # noqa: E402
17
+ from .scaffold import ScaffoldError, ScaffoldResult, create_project # noqa: E402
18
+ from .templates import TEMPLATES, ProjectTemplate, get_template # noqa: E402
19
+
20
+ __all__ = [
21
+ "__version__",
22
+ "ConfigError",
23
+ "DevConfig",
24
+ "DevServer",
25
+ "ProjectConfig",
26
+ "ProjectTemplate",
27
+ "ScaffoldError",
28
+ "ScaffoldResult",
29
+ "TEMPLATES",
30
+ "build_command",
31
+ "create_project",
32
+ "diff_snapshots",
33
+ "find_project_root",
34
+ "get_template",
35
+ "load_config",
36
+ "take_snapshot",
37
+ ]
avalon_cli/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow ``python -m avalon_cli``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
avalon_cli/cli.py ADDED
@@ -0,0 +1,163 @@
1
+ """The ``avalon`` command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import platform
8
+ import shlex
9
+ import signal
10
+ import sys
11
+ import threading
12
+ from collections.abc import Sequence
13
+ from typing import TextIO
14
+
15
+ from . import __version__
16
+ from .config import ConfigError, load_config
17
+ from .reloader import DevServer, build_command
18
+ from .scaffold import ScaffoldError, create_project
19
+ from .templates import DEFAULT_TEMPLATE, TEMPLATES
20
+
21
+ __all__ = ["build_parser", "main"]
22
+
23
+
24
+ class CLIError(Exception):
25
+ """An error reported to the user as ``error: <message>`` with exit code 1."""
26
+
27
+
28
+ def _cmd_new(args: argparse.Namespace, out: TextIO) -> int:
29
+ result = create_project(args.name, args.template, args.directory, force=args.force)
30
+ out.write(f"Created {result.template!r} project in {result.root}\n")
31
+ for path in result.files:
32
+ out.write(f" {path.relative_to(result.root).as_posix()}\n")
33
+ out.write(f"\nNext steps:\n cd {shlex.quote(str(result.root))}\n avalon dev\n")
34
+ return 0
35
+
36
+
37
+ def _cmd_templates(args: argparse.Namespace, out: TextIO) -> int:
38
+ width = max(len(name) for name in TEMPLATES)
39
+ for name in sorted(TEMPLATES):
40
+ marker = " (default)" if name == DEFAULT_TEMPLATE else ""
41
+ out.write(f"{name:<{width}} {TEMPLATES[name].description}{marker}\n")
42
+ return 0
43
+
44
+
45
+ def _cmd_info(args: argparse.Namespace, out: TextIO) -> int:
46
+ out.write(f"avalon-cli {__version__}\n")
47
+ out.write(f"python {platform.python_version()} ({sys.executable})\n")
48
+ try:
49
+ config = load_config(args.project)
50
+ except ConfigError as exc:
51
+ out.write(f"project (none: {exc})\n")
52
+ return 0
53
+ dev = config.dev
54
+ argv = build_command(dev.command, host=dev.host, port=dev.port)
55
+ out.write(f"project {config.name}\n")
56
+ out.write(f"root {config.root}\n")
57
+ out.write(f"command {shlex.join(argv)}\n")
58
+ out.write(f"address http://{dev.host}:{dev.port}/\n")
59
+ out.write(f"watch {', '.join(dev.watch)}\n")
60
+ out.write(f"extensions {', '.join(dev.extensions) or '(all files)'}\n")
61
+ return 0
62
+
63
+
64
+ def _cmd_dev(args: argparse.Namespace, out: TextIO) -> int:
65
+ config = load_config(args.project)
66
+ dev = config.dev
67
+ host = dev.host if args.host is None else args.host
68
+ port = dev.port if args.port is None else args.port
69
+ interval = dev.interval if args.interval is None else args.interval
70
+ if not 0 < port < 65536:
71
+ raise CLIError(f"port must be between 1 and 65535 (got {port})")
72
+ if interval <= 0:
73
+ raise CLIError("interval must be positive")
74
+ argv = build_command(dev.command, host=host, port=port)
75
+
76
+ env = dict(os.environ)
77
+ env.update(AVALON_HOST=host, AVALON_PORT=str(port), AVALON_ENV="development")
78
+ server = DevServer(
79
+ argv,
80
+ cwd=config.root,
81
+ env=env,
82
+ watch=dev.watch,
83
+ extensions=dev.extensions,
84
+ ignore=dev.ignore,
85
+ interval=interval,
86
+ reload=not args.no_reload,
87
+ )
88
+ out.write(f"Serving {config.name} at http://{host}:{port}/ (Ctrl-C to stop)\n")
89
+ out.flush()
90
+
91
+ # Treat SIGTERM like a clean shutdown so the child is never orphaned.
92
+ stop_event = threading.Event()
93
+ previous = None
94
+ if threading.current_thread() is threading.main_thread():
95
+ previous = signal.signal(signal.SIGTERM, lambda signum, frame: stop_event.set())
96
+ try:
97
+ return server.run(stop_event)
98
+ except KeyboardInterrupt:
99
+ out.write("\nStopped.\n")
100
+ return 130
101
+ finally:
102
+ if previous is not None:
103
+ signal.signal(signal.SIGTERM, previous)
104
+
105
+
106
+ def build_parser() -> argparse.ArgumentParser:
107
+ """Construct the argument parser for the ``avalon`` command."""
108
+ parser = argparse.ArgumentParser(
109
+ prog="avalon",
110
+ description="Command-line companion for the Avalon real-time web framework.",
111
+ )
112
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
113
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND", required=True)
114
+
115
+ new = sub.add_parser("new", help="create a new project from a template")
116
+ new.add_argument("name", help="project name (also the directory name)")
117
+ new.add_argument(
118
+ "-t", "--template", default=DEFAULT_TEMPLATE, choices=sorted(TEMPLATES),
119
+ help=f"template to use (default: {DEFAULT_TEMPLATE})",
120
+ )
121
+ new.add_argument(
122
+ "-d", "--directory", default=".",
123
+ help="parent directory to create the project in (default: current directory)",
124
+ )
125
+ new.add_argument("--force", action="store_true", help="write into a non-empty directory")
126
+ new.set_defaults(handler=_cmd_new)
127
+
128
+ dev = sub.add_parser("dev", help="run the dev command with auto-reload")
129
+ dev.add_argument("-C", "--project", default=".", help="project directory (default: current directory)")
130
+ dev.add_argument("--host", help="host to bind (overrides avalon.toml)")
131
+ dev.add_argument("-p", "--port", type=int, help="port to bind (overrides avalon.toml)")
132
+ dev.add_argument("--interval", type=float, help="seconds between file-change polls")
133
+ dev.add_argument("--no-reload", action="store_true", help="run once without watching files")
134
+ dev.set_defaults(handler=_cmd_dev)
135
+
136
+ templates = sub.add_parser("templates", help="list available project templates")
137
+ templates.set_defaults(handler=_cmd_templates)
138
+
139
+ info = sub.add_parser("info", help="show environment and resolved project settings")
140
+ info.add_argument("-C", "--project", default=".", help="project directory (default: current directory)")
141
+ info.set_defaults(handler=_cmd_info)
142
+
143
+ return parser
144
+
145
+
146
+ def main(argv: Sequence[str] | None = None, out: TextIO | None = None) -> int:
147
+ """Entry point for the ``avalon`` console script.
148
+
149
+ :param argv: arguments excluding the program name (defaults to ``sys.argv[1:]``).
150
+ :param out: stream for normal output (defaults to ``sys.stdout``).
151
+ :returns: process exit code. Errors print ``error: ...`` to stderr and return 1.
152
+ """
153
+ out = out or sys.stdout
154
+ args = build_parser().parse_args(argv)
155
+ try:
156
+ return args.handler(args, out)
157
+ except (CLIError, ConfigError, ScaffoldError, ValueError) as exc:
158
+ print(f"error: {exc}", file=sys.stderr)
159
+ return 1
160
+
161
+
162
+ if __name__ == "__main__": # pragma: no cover
163
+ sys.exit(main())
avalon_cli/config.py ADDED
@@ -0,0 +1,128 @@
1
+ """Load and validate ``avalon.toml`` project configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ __all__ = [
11
+ "CONFIG_FILENAME",
12
+ "ConfigError",
13
+ "DevConfig",
14
+ "ProjectConfig",
15
+ "find_project_root",
16
+ "load_config",
17
+ ]
18
+
19
+ CONFIG_FILENAME = "avalon.toml"
20
+
21
+
22
+ class ConfigError(Exception):
23
+ """Raised when ``avalon.toml`` is missing, unreadable or invalid."""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class DevConfig:
28
+ """Settings for ``avalon dev`` (the ``[dev]`` table)."""
29
+
30
+ command: str = "{python} app.py"
31
+ host: str = "127.0.0.1"
32
+ port: int = 8000
33
+ watch: tuple[str, ...] = (".",)
34
+ extensions: tuple[str, ...] = (".py", ".html", ".css", ".js", ".toml")
35
+ ignore: tuple[str, ...] = (".venv", ".git", "__pycache__", "node_modules")
36
+ interval: float = 0.5
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ProjectConfig:
41
+ """The fully-resolved contents of an ``avalon.toml`` file."""
42
+
43
+ root: Path
44
+ name: str
45
+ dev: DevConfig = field(default_factory=DevConfig)
46
+
47
+
48
+ def find_project_root(start: str | Path = ".") -> Path:
49
+ """Walk upward from *start* and return the first directory with ``avalon.toml``.
50
+
51
+ Raises :class:`ConfigError` if no such directory exists.
52
+ """
53
+ current = Path(start).expanduser().resolve()
54
+ for candidate in (current, *current.parents):
55
+ if (candidate / CONFIG_FILENAME).is_file():
56
+ return candidate
57
+ raise ConfigError(f"no {CONFIG_FILENAME} found in {current} or any parent directory")
58
+
59
+
60
+ def _expect(value: Any, kind: type | tuple[type, ...], key: str) -> Any:
61
+ # bool is a subclass of int; reject it where a number is expected.
62
+ if isinstance(value, bool) and bool not in (kind if isinstance(kind, tuple) else (kind,)):
63
+ raise ConfigError(f"[dev].{key} has the wrong type (got bool)")
64
+ if not isinstance(value, kind):
65
+ raise ConfigError(f"[dev].{key} has the wrong type (got {type(value).__name__})")
66
+ return value
67
+
68
+
69
+ def _str_list(value: Any, key: str) -> tuple[str, ...]:
70
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
71
+ raise ConfigError(f"[dev].{key} must be a list of strings")
72
+ return tuple(value)
73
+
74
+
75
+ def _parse_dev(table: Any) -> DevConfig:
76
+ if not isinstance(table, dict):
77
+ raise ConfigError("[dev] must be a table")
78
+ defaults = DevConfig()
79
+ known = {"command", "host", "port", "watch", "extensions", "ignore", "interval"}
80
+ unknown = sorted(set(table) - known)
81
+ if unknown:
82
+ raise ConfigError(f"unknown key(s) in [dev]: {', '.join(unknown)}")
83
+
84
+ port = _expect(table.get("port", defaults.port), int, "port")
85
+ if not 0 < port < 65536:
86
+ raise ConfigError(f"[dev].port must be between 1 and 65535 (got {port})")
87
+ interval = float(_expect(table.get("interval", defaults.interval), (int, float), "interval"))
88
+ if interval <= 0:
89
+ raise ConfigError("[dev].interval must be positive")
90
+ command = _expect(table.get("command", defaults.command), str, "command")
91
+ if not command.strip():
92
+ raise ConfigError("[dev].command must not be empty")
93
+
94
+ extensions = _str_list(table.get("extensions", list(defaults.extensions)), "extensions")
95
+ return DevConfig(
96
+ command=command,
97
+ host=_expect(table.get("host", defaults.host), str, "host"),
98
+ port=port,
99
+ watch=_str_list(table.get("watch", list(defaults.watch)), "watch"),
100
+ extensions=tuple(e if e.startswith(".") else f".{e}" for e in extensions),
101
+ ignore=_str_list(table.get("ignore", list(defaults.ignore)), "ignore"),
102
+ interval=interval,
103
+ )
104
+
105
+
106
+ def load_config(start: str | Path = ".") -> ProjectConfig:
107
+ """Find and parse the ``avalon.toml`` governing *start*.
108
+
109
+ Missing keys fall back to :class:`DevConfig` defaults; the project name
110
+ defaults to the directory name. Raises :class:`ConfigError` on any problem.
111
+ """
112
+ root = find_project_root(start)
113
+ path = root / CONFIG_FILENAME
114
+ try:
115
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
116
+ except tomllib.TOMLDecodeError as exc:
117
+ raise ConfigError(f"{path}: invalid TOML: {exc}") from None
118
+ except OSError as exc:
119
+ raise ConfigError(f"{path}: {exc.strerror}") from None
120
+
121
+ project = data.get("project", {})
122
+ if not isinstance(project, dict):
123
+ raise ConfigError("[project] must be a table")
124
+ name = project.get("name", root.name)
125
+ if not isinstance(name, str) or not name:
126
+ raise ConfigError("[project].name must be a non-empty string")
127
+
128
+ return ProjectConfig(root=root, name=name, dev=_parse_dev(data.get("dev", {})))
avalon_cli/py.typed ADDED
File without changes
avalon_cli/reloader.py ADDED
@@ -0,0 +1,218 @@
1
+ """A dependency-free, polling auto-reloader used by ``avalon dev``.
2
+
3
+ The reloader runs the project's dev command as a child process, takes
4
+ periodic snapshots of file modification times under the watched
5
+ directories, and restarts the child whenever something changes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import fnmatch
11
+ import os
12
+ import shlex
13
+ import subprocess
14
+ import sys
15
+ import threading
16
+ from collections.abc import Callable, Iterable, Mapping, Sequence
17
+ from pathlib import Path
18
+
19
+ __all__ = ["DevServer", "Snapshot", "build_command", "diff_snapshots", "take_snapshot"]
20
+
21
+ Snapshot = dict[Path, int]
22
+ """Mapping of file path to ``st_mtime_ns``."""
23
+
24
+
25
+ def build_command(template: str, *, host: str, port: int, python: str | None = None) -> list[str]:
26
+ """Split a dev command template into argv and fill in its placeholders.
27
+
28
+ Supported placeholders are ``{host}``, ``{port}`` and ``{python}`` (the
29
+ current interpreter). Splitting happens *before* substitution so values
30
+ containing spaces stay a single argument.
31
+
32
+ >>> build_command("{python} -m http.server {port}", host="h", port=1, python="py")
33
+ ['py', '-m', 'http.server', '1']
34
+
35
+ Raises :class:`ValueError` for an empty command or an unknown placeholder.
36
+ """
37
+ values = {"host": host, "port": port, "python": python or sys.executable}
38
+ tokens = shlex.split(template)
39
+ if not tokens:
40
+ raise ValueError("dev command is empty")
41
+ try:
42
+ return [token.format(**values) for token in tokens]
43
+ except (KeyError, IndexError) as exc:
44
+ raise ValueError(
45
+ f"unknown placeholder {exc} in dev command {template!r} "
46
+ "(supported: {host}, {port}, {python})"
47
+ ) from None
48
+
49
+
50
+ def _is_ignored(name: str, patterns: Iterable[str]) -> bool:
51
+ return any(fnmatch.fnmatch(name, pattern) for pattern in patterns)
52
+
53
+
54
+ def take_snapshot(
55
+ roots: Iterable[str | Path],
56
+ extensions: Iterable[str] = (),
57
+ ignore: Iterable[str] = (),
58
+ ) -> Snapshot:
59
+ """Record the mtime of every watched file under *roots*.
60
+
61
+ :param roots: files or directories to watch (missing paths are skipped).
62
+ :param extensions: file suffixes to include, e.g. ``(".py", ".html")``;
63
+ empty means every file.
64
+ :param ignore: glob patterns matched against each file or directory
65
+ *name*; matching directories are not descended into.
66
+ """
67
+ exts = tuple(extensions)
68
+ patterns = tuple(ignore)
69
+ snapshot: Snapshot = {}
70
+
71
+ def record(path: Path) -> None:
72
+ if exts and path.suffix not in exts:
73
+ return
74
+ try:
75
+ snapshot[path] = path.stat().st_mtime_ns
76
+ except OSError: # deleted between listing and stat
77
+ pass
78
+
79
+ for root in roots:
80
+ root_path = Path(root).resolve()
81
+ if root_path.is_file():
82
+ if not _is_ignored(root_path.name, patterns):
83
+ record(root_path)
84
+ continue
85
+ for dirpath, dirnames, filenames in os.walk(root_path):
86
+ dirnames[:] = sorted(d for d in dirnames if not _is_ignored(d, patterns))
87
+ for filename in filenames:
88
+ if not _is_ignored(filename, patterns):
89
+ record(Path(dirpath, filename))
90
+ return snapshot
91
+
92
+
93
+ def diff_snapshots(old: Snapshot, new: Snapshot) -> set[Path]:
94
+ """Return paths that were added, removed or modified between two snapshots."""
95
+ changed = set(old.keys() ^ new.keys())
96
+ changed.update(path for path in old.keys() & new.keys() if old[path] != new[path])
97
+ return changed
98
+
99
+
100
+ def _default_log(message: str) -> None:
101
+ print(f"[avalon] {message}", file=sys.stderr, flush=True)
102
+
103
+
104
+ class DevServer:
105
+ """Run a command and restart it whenever watched files change.
106
+
107
+ Example::
108
+
109
+ server = DevServer(["python", "app.py"], cwd="myproj", watch=["."])
110
+ server.run() # blocks; Ctrl-C to stop
111
+ """
112
+
113
+ def __init__(
114
+ self,
115
+ argv: Sequence[str],
116
+ *,
117
+ cwd: str | Path = ".",
118
+ env: Mapping[str, str] | None = None,
119
+ watch: Iterable[str | Path] = (".",),
120
+ extensions: Iterable[str] = (),
121
+ ignore: Iterable[str] = (),
122
+ interval: float = 0.5,
123
+ reload: bool = True,
124
+ log: Callable[[str], None] = _default_log,
125
+ ) -> None:
126
+ if not argv:
127
+ raise ValueError("argv must not be empty")
128
+ if interval <= 0:
129
+ raise ValueError("interval must be positive")
130
+ self.argv = list(argv)
131
+ self.cwd = Path(cwd).resolve()
132
+ self.env = dict(os.environ if env is None else env)
133
+ self.watch = [p if Path(p).is_absolute() else self.cwd / p for p in watch]
134
+ self.extensions = tuple(extensions)
135
+ self.ignore = tuple(ignore)
136
+ self.interval = interval
137
+ self.reload = reload
138
+ self.log = log
139
+ self.process: subprocess.Popen[bytes] | None = None
140
+ self.restarts = 0
141
+ self._snapshot: Snapshot = {}
142
+
143
+ # -- process control -------------------------------------------------
144
+ def start(self) -> None:
145
+ """Start the child process (no-op if it is already running)."""
146
+ if self.process is not None and self.process.poll() is None:
147
+ return
148
+ self.log(f"starting: {shlex.join(self.argv)}")
149
+ self.process = subprocess.Popen(self.argv, cwd=self.cwd, env=self.env)
150
+
151
+ def stop(self, timeout: float = 5.0) -> int | None:
152
+ """Terminate the child (killing it after *timeout* seconds) and return its exit code."""
153
+ proc = self.process
154
+ if proc is None:
155
+ return None
156
+ if proc.poll() is None:
157
+ proc.terminate()
158
+ try:
159
+ proc.wait(timeout=timeout)
160
+ except subprocess.TimeoutExpired:
161
+ proc.kill()
162
+ proc.wait()
163
+ return proc.returncode
164
+
165
+ def restart(self) -> None:
166
+ """Stop the child if needed and start a fresh one."""
167
+ self.stop()
168
+ self.restarts += 1
169
+ self.start()
170
+
171
+ # -- file watching ---------------------------------------------------
172
+ def poll_changes(self) -> set[Path]:
173
+ """Take a new snapshot and return the paths that changed since the last one."""
174
+ new = take_snapshot(self.watch, self.extensions, self.ignore)
175
+ changed = diff_snapshots(self._snapshot, new)
176
+ self._snapshot = new
177
+ return changed
178
+
179
+ def run(self, stop_event: threading.Event | None = None) -> int:
180
+ """Run until the child exits (without reload) or *stop_event* is set.
181
+
182
+ With reloading enabled, a child that exits on its own is restarted on
183
+ the next file change, so a crash can be fixed without restarting
184
+ ``avalon dev``. Returns the last exit code of the child (0 if it was
185
+ stopped by us). The child is always terminated before returning.
186
+ """
187
+ stop_event = stop_event or threading.Event()
188
+ self._snapshot = take_snapshot(self.watch, self.extensions, self.ignore)
189
+ self.start()
190
+ assert self.process is not None
191
+ reported_exit = False
192
+ try:
193
+ while not stop_event.wait(self.interval):
194
+ code = self.process.poll()
195
+ if code is not None and not self.reload:
196
+ return code
197
+ if code is not None and not reported_exit:
198
+ self.log(f"process exited with code {code}; waiting for changes...")
199
+ reported_exit = True
200
+ if not self.reload:
201
+ continue
202
+ changed = self.poll_changes()
203
+ if changed:
204
+ names = ", ".join(sorted(_display(p, self.cwd) for p in changed)[:3])
205
+ more = f" (+{len(changed) - 3} more)" if len(changed) > 3 else ""
206
+ self.log(f"change detected: {names}{more}; restarting")
207
+ self.restart()
208
+ reported_exit = False
209
+ return 0
210
+ finally:
211
+ self.stop()
212
+
213
+
214
+ def _display(path: Path, base: Path) -> str:
215
+ try:
216
+ return str(path.relative_to(base))
217
+ except ValueError:
218
+ return str(path)
avalon_cli/scaffold.py ADDED
@@ -0,0 +1,89 @@
1
+ """Create new project directories from built-in templates (``avalon new``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from .templates import DEFAULT_TEMPLATE, get_template
11
+
12
+ __all__ = ["ScaffoldError", "ScaffoldResult", "create_project", "package_name_for", "validate_project_name"]
13
+
14
+ _NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$")
15
+
16
+
17
+ class ScaffoldError(Exception):
18
+ """Raised when a project cannot be created (bad name, existing directory...)."""
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ScaffoldResult:
23
+ """Outcome of :func:`create_project`."""
24
+
25
+ root: Path
26
+ template: str
27
+ files: tuple[Path, ...]
28
+
29
+
30
+ def validate_project_name(name: str) -> str:
31
+ """Return *name* unchanged if it is a valid project name.
32
+
33
+ A valid name starts with a letter and contains only letters, digits,
34
+ ``-`` and ``_``. Raises :class:`ScaffoldError` otherwise.
35
+ """
36
+ if not _NAME_RE.match(name):
37
+ raise ScaffoldError(
38
+ f"invalid project name {name!r}: use letters, digits, '-' or '_', starting with a letter"
39
+ )
40
+ return name
41
+
42
+
43
+ def package_name_for(name: str) -> str:
44
+ """Convert a project name into a Python identifier (``My-App`` -> ``my_app``)."""
45
+ return validate_project_name(name).replace("-", "_").lower()
46
+
47
+
48
+ def create_project(
49
+ name: str,
50
+ template: str = DEFAULT_TEMPLATE,
51
+ parent: str | Path = ".",
52
+ *,
53
+ force: bool = False,
54
+ ) -> ScaffoldResult:
55
+ """Generate a new project called *name* inside *parent*.
56
+
57
+ :param name: project name; also the directory name.
58
+ :param template: name of a built-in template (see ``avalon templates``).
59
+ :param parent: directory in which the project directory is created.
60
+ :param force: allow writing into an existing non-empty directory,
61
+ overwriting files that the template provides.
62
+ :raises ScaffoldError: for an invalid name, unknown template, or a
63
+ non-empty target directory without *force*.
64
+ """
65
+ validate_project_name(name)
66
+ try:
67
+ tmpl = get_template(template)
68
+ except KeyError as exc:
69
+ raise ScaffoldError(exc.args[0]) from None
70
+
71
+ root = Path(parent).expanduser().resolve() / name
72
+ if root.exists():
73
+ if not root.is_dir():
74
+ raise ScaffoldError(f"{root} exists and is not a directory")
75
+ if any(root.iterdir()) and not force:
76
+ raise ScaffoldError(f"{root} is not empty (use --force to write into it)")
77
+
78
+ context = {
79
+ "project_name": name,
80
+ "package_name": package_name_for(name),
81
+ "cli_version": __version__,
82
+ }
83
+ written: list[Path] = []
84
+ for rel_path, content in sorted(tmpl.render(context).items()):
85
+ target = root / rel_path
86
+ target.parent.mkdir(parents=True, exist_ok=True)
87
+ target.write_text(content, encoding="utf-8")
88
+ written.append(target)
89
+ return ScaffoldResult(root=root, template=tmpl.name, files=tuple(written))
@@ -0,0 +1,235 @@
1
+ """Built-in project templates used by ``avalon new``.
2
+
3
+ Each template is a mapping of relative file paths to file contents. Contents
4
+ are rendered with :class:`string.Template`, so placeholders look like
5
+ ``$project_name`` / ``${package_name}`` and a literal dollar sign is written
6
+ as ``$$``. Using ``string.Template`` (rather than ``str.format``) keeps the
7
+ curly braces in generated Python, HTML, CSS and JavaScript untouched.
8
+
9
+ Available placeholders:
10
+
11
+ ``project_name``
12
+ The name given on the command line, e.g. ``my-app``.
13
+ ``package_name``
14
+ A Python-identifier-safe version of the name, e.g. ``my_app``.
15
+ ``cli_version``
16
+ The version of ``avalon-cli`` that generated the project.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+ from string import Template as _StringTemplate
23
+
24
+ __all__ = ["ProjectTemplate", "TEMPLATES", "DEFAULT_TEMPLATE", "get_template"]
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ProjectTemplate:
29
+ """A named set of files that makes up a new project skeleton."""
30
+
31
+ name: str
32
+ description: str
33
+ files: dict[str, str] = field(default_factory=dict)
34
+
35
+ def render(self, context: dict[str, str]) -> dict[str, str]:
36
+ """Return ``{relative_path: rendered_content}`` for every file.
37
+
38
+ Raises :class:`KeyError` if a template references a placeholder that
39
+ is missing from *context* (a bug in the template, not user error).
40
+ """
41
+ return {
42
+ path: _StringTemplate(body).substitute(context)
43
+ for path, body in self.files.items()
44
+ }
45
+
46
+
47
+ _GITIGNORE = """\
48
+ __pycache__/
49
+ *.py[cod]
50
+ .venv/
51
+ .env
52
+ dist/
53
+ build/
54
+ *.egg-info/
55
+ """
56
+
57
+ _MINIMAL_TOML = """\
58
+ # Project settings read by avalon-cli (generated by avalon-cli $cli_version).
59
+
60
+ [project]
61
+ name = "$project_name"
62
+
63
+ [dev]
64
+ # Command started (and restarted on change) by `avalon dev`.
65
+ # Placeholders: {host}, {port}, {python}. AVALON_HOST / AVALON_PORT are
66
+ # also exported into the child's environment.
67
+ command = "{python} app.py"
68
+ host = "127.0.0.1"
69
+ port = 8000
70
+ watch = ["."]
71
+ extensions = [".py", ".html", ".css", ".js", ".toml"]
72
+ ignore = [".venv", ".git", "__pycache__", "node_modules"]
73
+ """
74
+
75
+ _MINIMAL_APP = '''\
76
+ """Entry point for $project_name, an Avalon real-time application.
77
+
78
+ Run it with `avalon dev`, which restarts this process whenever a watched
79
+ file changes and passes the address in AVALON_HOST / AVALON_PORT.
80
+ """
81
+
82
+ import os
83
+ from pathlib import Path
84
+
85
+ from avalon import Avalon, HTMLResponse
86
+
87
+ app = Avalon()
88
+
89
+ INDEX_HTML = Path(__file__).with_name("templates") / "index.html"
90
+
91
+
92
+ @app.get("/")
93
+ def index(request):
94
+ """Serve the landing page."""
95
+ return HTMLResponse(INDEX_HTML.read_text(encoding="utf-8"))
96
+
97
+
98
+ @app.websocket("/ws/echo")
99
+ async def echo(ws):
100
+ """Echo every message back to the connected client in real time."""
101
+ async for message in ws:
102
+ await ws.send(message)
103
+
104
+
105
+ if __name__ == "__main__":
106
+ app.run(
107
+ host=os.environ.get("AVALON_HOST", "127.0.0.1"),
108
+ port=int(os.environ.get("AVALON_PORT", "8000")),
109
+ )
110
+ '''
111
+
112
+ _MINIMAL_INDEX = """\
113
+ <!doctype html>
114
+ <html lang="en">
115
+ <head>
116
+ <meta charset="utf-8">
117
+ <title>$project_name</title>
118
+ </head>
119
+ <body>
120
+ <h1>$project_name</h1>
121
+ <p>Edit <code>app.py</code> and save: <code>avalon dev</code> reloads automatically.</p>
122
+ <script>
123
+ const ws = new WebSocket(`ws://$${location.host}/ws/echo`);
124
+ ws.onmessage = (event) => console.log("echo:", event.data);
125
+ ws.onopen = () => ws.send("hello from $project_name");
126
+ </script>
127
+ </body>
128
+ </html>
129
+ """
130
+
131
+ _MINIMAL_README = """\
132
+ # $project_name
133
+
134
+ An [Avalon](https://pypi.org/project/avalon/) real-time web application.
135
+
136
+ ```bash
137
+ avalon dev # start the auto-reloading development server
138
+ avalon info # show the resolved project configuration
139
+ ```
140
+ """
141
+
142
+ _STATIC_TOML = """\
143
+ # Project settings read by avalon-cli (generated by avalon-cli $cli_version).
144
+ # This template needs nothing beyond the Python standard library.
145
+
146
+ [project]
147
+ name = "$project_name"
148
+
149
+ [dev]
150
+ command = "{python} -m http.server {port} --bind {host} --directory public"
151
+ host = "127.0.0.1"
152
+ port = 8000
153
+ watch = ["public"]
154
+ extensions = [".html", ".css", ".js"]
155
+ ignore = [".git"]
156
+ """
157
+
158
+ _STATIC_INDEX = """\
159
+ <!doctype html>
160
+ <html lang="en">
161
+ <head>
162
+ <meta charset="utf-8">
163
+ <title>$project_name</title>
164
+ <link rel="stylesheet" href="style.css">
165
+ </head>
166
+ <body>
167
+ <h1>$project_name</h1>
168
+ <p>A static prototype. Swap to the <code>minimal</code> template when you need real-time features.</p>
169
+ <script src="app.js"></script>
170
+ </body>
171
+ </html>
172
+ """
173
+
174
+ _STATIC_CSS = """\
175
+ body {
176
+ font-family: system-ui, sans-serif;
177
+ max-width: 40rem;
178
+ margin: 3rem auto;
179
+ padding: 0 1rem;
180
+ }
181
+ """
182
+
183
+ _STATIC_JS = """\
184
+ console.log("$project_name loaded");
185
+ """
186
+
187
+ _STATIC_README = """\
188
+ # $project_name
189
+
190
+ A static site served by Python's built-in `http.server`, managed with avalon-cli.
191
+
192
+ ```bash
193
+ avalon dev # serve ./public and restart when files change
194
+ ```
195
+ """
196
+
197
+ TEMPLATES: dict[str, ProjectTemplate] = {
198
+ "minimal": ProjectTemplate(
199
+ name="minimal",
200
+ description="Avalon app with one page and a WebSocket echo endpoint.",
201
+ files={
202
+ "avalon.toml": _MINIMAL_TOML,
203
+ "app.py": _MINIMAL_APP,
204
+ "templates/index.html": _MINIMAL_INDEX,
205
+ "README.md": _MINIMAL_README,
206
+ ".gitignore": _GITIGNORE,
207
+ },
208
+ ),
209
+ "static": ProjectTemplate(
210
+ name="static",
211
+ description="Plain HTML/CSS/JS served by the standard library (no Avalon needed).",
212
+ files={
213
+ "avalon.toml": _STATIC_TOML,
214
+ "public/index.html": _STATIC_INDEX,
215
+ "public/style.css": _STATIC_CSS,
216
+ "public/app.js": _STATIC_JS,
217
+ "README.md": _STATIC_README,
218
+ ".gitignore": _GITIGNORE,
219
+ },
220
+ ),
221
+ }
222
+
223
+ DEFAULT_TEMPLATE = "minimal"
224
+
225
+
226
+ def get_template(name: str) -> ProjectTemplate:
227
+ """Look up a built-in template by name.
228
+
229
+ Raises :class:`KeyError` with a helpful message listing valid names.
230
+ """
231
+ try:
232
+ return TEMPLATES[name]
233
+ except KeyError:
234
+ valid = ", ".join(sorted(TEMPLATES))
235
+ raise KeyError(f"unknown template {name!r} (choose from: {valid})") from None
@@ -0,0 +1,192 @@
1
+ Metadata-Version: 2.4
2
+ Name: avalon-cli
3
+ Version: 0.2.0
4
+ Summary: Command-line companion for the Avalon real-time web framework: scaffold projects and run an auto-reloading dev server.
5
+ Author: nehz
6
+ License-Expression: MIT
7
+ Keywords: avalon,cli,scaffold,dev-server,real-time,web
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Environment :: Web Environment
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
19
+ Classifier: Topic :: Software Development :: Code Generators
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Dynamic: license-file
25
+
26
+ # avalon-cli
27
+
28
+ **The command-line companion for the [Avalon](https://pypi.org/project/avalon/) real-time web framework.**
29
+
30
+ `avalon-cli` gets you from zero to a running, auto-reloading app in two commands:
31
+ `avalon new` scaffolds a project and `avalon dev` runs it, restarting the server
32
+ every time you save a file. It uses only the Python standard library and does not
33
+ import `avalon` itself, so it installs in seconds and works with any Avalon version
34
+ (or with no framework at all, via the `static` template).
35
+
36
+ ## Features
37
+
38
+ - **Project scaffolding**: `avalon new` generates a ready-to-run project from a built-in template.
39
+ - **Auto-reloading dev server**: `avalon dev` runs your dev command, watches files by polling
40
+ (no native dependencies), and restarts the process on change. If the process crashes it waits
41
+ for your fix and starts again on the next save, so you never have to restart `avalon dev`.
42
+ - **One config file**: `avalon.toml` holds the dev command, host, port and watch rules, found by
43
+ walking upward from the current directory.
44
+ - **Clean shutdown**: Ctrl-C or SIGTERM always terminates the child process; nothing is orphaned.
45
+ - **Zero dependencies**: standard library only, Python 3.11+.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install avalon-cli
51
+ ```
52
+
53
+ This installs the `avalon` command. `python -m avalon_cli` works too.
54
+
55
+ ## Quickstart
56
+
57
+ ```bash
58
+ avalon new chat-demo # Avalon app (default "minimal" template)
59
+ cd chat-demo
60
+ pip install avalon # the framework the generated app.py uses
61
+ avalon dev # http://127.0.0.1:8000/, reloads on save
62
+ ```
63
+
64
+ No framework yet? The `static` template needs nothing but Python:
65
+
66
+ ```bash
67
+ avalon new mysite --template static
68
+ avalon dev -C mysite --port 9000
69
+ ```
70
+
71
+ ## Commands
72
+
73
+ All commands print errors as `error: <message>` on stderr and exit with status 1;
74
+ usage errors exit with status 2.
75
+
76
+ ### `avalon new NAME [-t TEMPLATE] [-d DIRECTORY] [--force]`
77
+
78
+ Create the directory `DIRECTORY/NAME` and fill it from a template.
79
+
80
+ | Option | Default | Meaning |
81
+ | --- | --- | --- |
82
+ | `NAME` | (required) | Project name and directory name. Must start with a letter and contain only letters, digits, `-` and `_`. |
83
+ | `-t`, `--template` | `minimal` | One of the templates listed by `avalon templates`. |
84
+ | `-d`, `--directory` | `.` | Parent directory to create the project in. |
85
+ | `--force` | off | Write into an existing non-empty directory, overwriting files the template provides (other files are left alone). |
86
+
87
+ An existing *empty* directory is always accepted.
88
+
89
+ ### `avalon templates`
90
+
91
+ List the built-in templates:
92
+
93
+ | Template | Files | Notes |
94
+ | --- | --- | --- |
95
+ | `minimal` (default) | `avalon.toml`, `app.py`, `templates/index.html`, `README.md`, `.gitignore` | An Avalon app with one page and a WebSocket echo endpoint. Requires `avalon`. |
96
+ | `static` | `avalon.toml`, `public/index.html`, `public/style.css`, `public/app.js`, `README.md`, `.gitignore` | Served by `python -m http.server`; no dependencies. |
97
+
98
+ ### `avalon dev [-C PROJECT] [--host HOST] [-p PORT] [--interval SECONDS] [--no-reload]`
99
+
100
+ Find `avalon.toml` (in `PROJECT` or any parent directory), start the configured dev
101
+ command from the project root, and restart it whenever a watched file is added,
102
+ modified or removed.
103
+
104
+ | Option | Default | Meaning |
105
+ | --- | --- | --- |
106
+ | `-C`, `--project` | `.` | Directory to start looking for `avalon.toml`. |
107
+ | `--host` | `[dev].host` | Overrides the host. |
108
+ | `-p`, `--port` | `[dev].port` | Overrides the port (1-65535). |
109
+ | `--interval` | `[dev].interval` | Seconds between file-change polls. |
110
+ | `--no-reload` | off | Run the command once, without watching; `avalon dev` exits with the command's exit code. |
111
+
112
+ The child process receives `AVALON_HOST`, `AVALON_PORT` and `AVALON_ENV=development`
113
+ in its environment. With reloading on, `avalon dev` exits with status 0 on SIGTERM
114
+ and 130 on Ctrl-C.
115
+
116
+ ### `avalon info [-C PROJECT]`
117
+
118
+ Print the `avalon-cli` and Python versions and, if a project is found, its name,
119
+ root, fully-resolved dev command, address, watch paths and extensions.
120
+
121
+ ### `avalon --version`
122
+
123
+ Print `avalon 0.2.0`.
124
+
125
+ ## Configuration: `avalon.toml`
126
+
127
+ ```toml
128
+ [project]
129
+ name = "chat-demo" # default: the directory name
130
+
131
+ [dev]
132
+ command = "{python} app.py" # placeholders: {host}, {port}, {python}
133
+ host = "127.0.0.1"
134
+ port = 8000
135
+ watch = ["."] # files or directories, relative to the project root
136
+ extensions = [".py", ".html", ".css", ".js", ".toml"] # [] watches every file
137
+ ignore = [".venv", ".git", "__pycache__", "node_modules"] # glob patterns matched against names
138
+ interval = 0.5 # seconds between polls
139
+ ```
140
+
141
+ Every key is optional; the values above are the defaults. Extensions without a
142
+ leading dot get one (`"py"` becomes `".py"`). Unknown keys in `[dev]` are rejected
143
+ so typos are caught early. The command is split shell-style *before* placeholders
144
+ are substituted, so a `{python}` path containing spaces stays a single argument.
145
+ `{python}` is the interpreter running `avalon-cli`.
146
+
147
+ ## Python API
148
+
149
+ Everything the CLI does is available from `avalon_cli`:
150
+
151
+ ```python
152
+ from avalon_cli import DevServer, build_command, create_project, load_config
153
+
154
+ result = create_project("chat-demo", template="minimal", parent=".", force=False)
155
+ print(result.root, result.template, result.files) # ScaffoldResult
156
+
157
+ config = load_config(result.root) # ProjectConfig(root, name, dev=DevConfig(...))
158
+ argv = build_command(config.dev.command, host=config.dev.host, port=config.dev.port)
159
+
160
+ server = DevServer(
161
+ argv,
162
+ cwd=config.root,
163
+ watch=config.dev.watch,
164
+ extensions=config.dev.extensions,
165
+ ignore=config.dev.ignore,
166
+ interval=config.dev.interval,
167
+ )
168
+ server.run() # blocks; pass a threading.Event to stop it from another thread
169
+ ```
170
+
171
+ | Name | Description |
172
+ | --- | --- |
173
+ | `create_project(name, template="minimal", parent=".", *, force=False) -> ScaffoldResult` | Scaffold a project; raises `ScaffoldError`. |
174
+ | `load_config(start=".") -> ProjectConfig` | Locate and validate `avalon.toml`; raises `ConfigError`. |
175
+ | `find_project_root(start=".") -> Path` | Directory containing the nearest `avalon.toml`; raises `ConfigError`. |
176
+ | `build_command(template, *, host, port, python=None) -> list[str]` | Turn a dev command template into argv; raises `ValueError`. |
177
+ | `DevServer(argv, *, cwd=".", env=None, watch=(".",), extensions=(), ignore=(), interval=0.5, reload=True, log=...)` | Reloading process runner with `start()`, `stop(timeout=5.0)`, `restart()`, `poll_changes()`, `run(stop_event=None) -> int`, and a `restarts` counter. |
178
+ | `take_snapshot(roots, extensions=(), ignore=()) -> dict[Path, int]` | File-to-mtime map of watched files. |
179
+ | `diff_snapshots(old, new) -> set[Path]` | Paths added, removed or modified between two snapshots. |
180
+ | `TEMPLATES`, `get_template(name)`, `ProjectTemplate` | The built-in template registry. |
181
+
182
+ ## Development
183
+
184
+ ```bash
185
+ python3 -m venv .venv && . .venv/bin/activate
186
+ pip install -e .
187
+ python -m unittest discover -s tests -t .
188
+ ```
189
+
190
+ ## License
191
+
192
+ MIT
@@ -0,0 +1,14 @@
1
+ avalon_cli/__init__.py,sha256=vuxKHExQw02ogJcf2GvC-FdnELkGUrTwImDR-wLvqTY,1125
2
+ avalon_cli/__main__.py,sha256=WG0hXQDOYngR5yRZsWmhMyzax-2R9KqRqT32ENhUikg,91
3
+ avalon_cli/cli.py,sha256=EVAlA8_gZNBrLcYQE5ikuSpafpwrmYhGwldQUV8ZhyM,6358
4
+ avalon_cli/config.py,sha256=15TVv4eU9nMOc5BqKzUJ_zRxHwNVjxFoGtgdC1zldxM,4664
5
+ avalon_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ avalon_cli/reloader.py,sha256=yVmslWaCToM0RYP2QI5K77pcXkbvBXaAGPjm60nw7Q8,8085
7
+ avalon_cli/scaffold.py,sha256=lzUyL1HMs2oo6JKnuGvxqg6l7FMLKf68eB-hZGyQKjI,2975
8
+ avalon_cli/templates.py,sha256=rOIdth653NW5jbLJF4DFgDrppzb8y_YYFgkWj1cRGas,6152
9
+ avalon_cli-0.2.0.dist-info/licenses/LICENSE,sha256=pAfYREEW9GAy7cnK20OXjDn7ofJahYny9GCuIZQTDAA,1061
10
+ avalon_cli-0.2.0.dist-info/METADATA,sha256=JavRCO6LZAUtoQnYWayoBgDVGQag-h7t0cb40Rn32r4,8043
11
+ avalon_cli-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
12
+ avalon_cli-0.2.0.dist-info/entry_points.txt,sha256=5M-BHK_zp4gUzapusuoHdRDVjaDVoa-soZ-8v08PAdQ,47
13
+ avalon_cli-0.2.0.dist-info/top_level.txt,sha256=nROcDniKc61f2q-lh4Kxwobojbp34slyaqPiz-JPyIA,11
14
+ avalon_cli-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ avalon = avalon_cli.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nehz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ avalon_cli