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 +37 -0
- avalon_cli/__main__.py +7 -0
- avalon_cli/cli.py +163 -0
- avalon_cli/config.py +128 -0
- avalon_cli/py.typed +0 -0
- avalon_cli/reloader.py +218 -0
- avalon_cli/scaffold.py +89 -0
- avalon_cli/templates.py +235 -0
- avalon_cli-0.2.0.dist-info/METADATA +192 -0
- avalon_cli-0.2.0.dist-info/RECORD +14 -0
- avalon_cli-0.2.0.dist-info/WHEEL +5 -0
- avalon_cli-0.2.0.dist-info/entry_points.txt +2 -0
- avalon_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- avalon_cli-0.2.0.dist-info/top_level.txt +1 -0
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
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))
|
avalon_cli/templates.py
ADDED
|
@@ -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,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
|