sillo-start 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ """Sillo Start — bootstrapper and orchestrator for Sillo applications.
2
+
3
+ Sillo Start creates properly structured Sillo projects, installs and removes
4
+ features against an existing project, and runs the development services a
5
+ project needs. ``sillo.toml`` at the project root is the authoritative record
6
+ of what a project is; every command reads and updates it.
7
+
8
+ The package is layered so the CLI stays a thin shell over reusable logic:
9
+
10
+ ``config``
11
+ The manifest schema and its loading/writing.
12
+ ``operations``
13
+ Reversible units of work (write a file, edit TOML, install a package) and
14
+ the transaction that applies them or rolls them back.
15
+ ``packages``
16
+ The package-group registry, dependency resolver, and installers.
17
+ ``blueprints``
18
+ Named project archetypes that turn wizard answers into a manifest.
19
+ ``project``
20
+ Creation, inspection and validation of projects on disk.
21
+ ``generators``
22
+ Component scaffolding for an existing project.
23
+ ``orchestration``
24
+ The supervised process manager behind ``sillo-start dev``.
25
+ ``prompts``
26
+ The interactive wizard.
27
+ ``cli``
28
+ Typer commands, which do argument handling and rendering only.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ __version__ = "0.1.0"
34
+
35
+ __all__ = ["__version__"]
@@ -0,0 +1,19 @@
1
+ """``python -m sillo_start`` and the ``sillo-start`` console script."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def main() -> None:
7
+ """Load the command tree and run the CLI."""
8
+ # Imported here rather than at module scope so that plugin loading and
9
+ # command registration happen once, at invocation, and a broken plugin
10
+ # cannot make `python -m sillo_start` unimportable.
11
+ from .cli import build_cli
12
+
13
+ # A Typer app is called, not `.main()`ed — calling it is what runs the
14
+ # underlying Click command with argv.
15
+ build_cli()()
16
+
17
+
18
+ if __name__ == "__main__":
19
+ main()
@@ -0,0 +1,20 @@
1
+ """The Typer command tree.
2
+
3
+ Importing the command module registers its command on the shared ``app``, so
4
+ :func:`build_cli` imports it and returns the app.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import typer
10
+
11
+
12
+ def build_cli() -> typer.Typer:
13
+ """Assemble and return the CLI application."""
14
+ from . import create # noqa: F401 — importing registers the command
15
+ from .app import app
16
+
17
+ return app
18
+
19
+
20
+ __all__ = ["build_cli"]
sillo_start/cli/app.py ADDED
@@ -0,0 +1,106 @@
1
+ """The Typer application and its shared behaviour.
2
+
3
+ The CLI layer stays thin: it parses arguments, calls into
4
+ :mod:`sillo_start.project.template`, and renders the result. The fetching and
5
+ personalising happen there, which is what lets them be driven from tests
6
+ without a terminal.
7
+
8
+ Errors are handled in one place. Anything deriving from
9
+ :class:`~sillo_start.exceptions.SilloStartError` becomes a clean message plus
10
+ its hint and a predictable exit code; the traceback appears only under
11
+ ``--verbose``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ from collections.abc import Callable
18
+ from functools import wraps
19
+ from typing import Any
20
+
21
+ import typer
22
+
23
+ from .. import __version__
24
+ from ..exceptions import SilloStartError
25
+ from ..utils.console import console
26
+
27
+ app = typer.Typer(
28
+ name="sillo-start",
29
+ help="Create a Sillo application from a starter repository.",
30
+ add_completion=True,
31
+ no_args_is_help=True,
32
+ rich_markup_mode="rich",
33
+ context_settings={"help_option_names": ["-h", "--help"]},
34
+ )
35
+
36
+
37
+ def version_callback(value: bool) -> None:
38
+ """Print the version and exit."""
39
+ if value:
40
+ console.print(f"sillo-start {__version__}")
41
+ raise typer.Exit()
42
+
43
+
44
+ @app.callback()
45
+ def main(
46
+ version: bool = typer.Option(
47
+ False,
48
+ "--version",
49
+ "-V",
50
+ help="Show the version and exit.",
51
+ callback=version_callback,
52
+ is_eager=True,
53
+ ),
54
+ verbose: bool = typer.Option(
55
+ False, "--verbose", "-v", help="Show debug output and tracebacks."
56
+ ),
57
+ quiet: bool = typer.Option(
58
+ False, "--quiet", "-q", help="Suppress non-essential output."
59
+ ),
60
+ ) -> None:
61
+ """Sillo Start — create a Sillo application from a starter repository."""
62
+ console.configure(verbose=verbose, quiet=quiet)
63
+
64
+
65
+ def handle_errors(func: Callable[..., Any]) -> Callable[..., Any]:
66
+ """Turn deliberate failures into clean messages and exit codes.
67
+
68
+ Applied to every command body. ``typer.Exit`` and ``typer.Abort`` pass
69
+ through untouched so control flow still works.
70
+ """
71
+
72
+ @wraps(func)
73
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
74
+ try:
75
+ return func(*args, **kwargs)
76
+ except (typer.Exit, typer.Abort):
77
+ raise
78
+ except SilloStartError as exc:
79
+ console.error(exc.message, hint=exc.hint)
80
+ if console.verbose:
81
+ console.rich.print_exception()
82
+ raise typer.Exit(code=exc.exit_code) from exc
83
+ except KeyboardInterrupt:
84
+ console.blank()
85
+ console.warning("Cancelled.")
86
+ raise typer.Exit(code=130) from None
87
+ except Exception as exc: # noqa: BLE001 — last resort, reported not swallowed
88
+ console.error(
89
+ f"Unexpected error: {exc}",
90
+ hint="Re-run with --verbose for the full traceback, and please report this.",
91
+ )
92
+ if console.verbose:
93
+ console.rich.print_exception()
94
+ raise typer.Exit(code=1) from exc
95
+
96
+ return wrapper
97
+
98
+
99
+ def run() -> None:
100
+ """Entrypoint used by the console script."""
101
+ try:
102
+ app()
103
+ except SilloStartError as exc:
104
+ # Reached only for failures raised outside a command body.
105
+ console.error(exc.message, hint=exc.hint)
106
+ sys.exit(exc.exit_code)
@@ -0,0 +1,114 @@
1
+ """``sillo-start create-app``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from ..exceptions import UsageError
10
+ from ..utils.console import console
11
+ from ..utils.naming import is_valid_project_name
12
+ from .app import app, handle_errors
13
+
14
+
15
+ @app.command("create-app")
16
+ @handle_errors
17
+ def create_app(
18
+ template: str = typer.Argument(
19
+ None, help="Starter repository, e.g. sillohq/starter or sillohq/starter@v1."
20
+ ),
21
+ name: str = typer.Argument(None, help="Project name. Also the directory name."),
22
+ directory: Path = typer.Option(
23
+ None,
24
+ "--directory",
25
+ "-d",
26
+ help="Where to create the project. Defaults to ./<name>.",
27
+ ),
28
+ ref: str = typer.Option(
29
+ None, "--ref", help="Branch or tag to take. Defaults to main."
30
+ ),
31
+ install: bool = typer.Option(
32
+ False, "--install/--no-install", help="Install dependencies after fetching."
33
+ ),
34
+ git: bool = typer.Option(
35
+ True, "--git/--no-git", help="Initialise a git repository."
36
+ ),
37
+ force: bool = typer.Option(False, "--force", help="Allow a non-empty directory."),
38
+ ) -> None:
39
+ """Create a project from a starter repository.
40
+
41
+ sillo-start create-app myapp
42
+ sillo-start create-app sillohq/starter myapp
43
+ sillo-start create-app sillohq/starter@v1.2 myapp
44
+
45
+ The starter is a real application with its own CI, so what you get has been
46
+ booted and exercised rather than only rendered. With one argument the
47
+ default starter is used and the argument is the project name.
48
+ """
49
+ from ..project.template import DEFAULT_TEMPLATE, Template, fetch, personalise
50
+
51
+ # One argument is the project name; the starter is only ever given when
52
+ # both are, so `create-app myapp` does the obvious thing.
53
+ if name is None:
54
+ name, template = template, DEFAULT_TEMPLATE
55
+ if not name:
56
+ raise UsageError(
57
+ "A project name is required.",
58
+ hint="sillo-start create-app myapp",
59
+ )
60
+ if not is_valid_project_name(name):
61
+ raise UsageError(
62
+ f"'{name}' is not a valid project name.",
63
+ hint="Use a letter followed by letters, digits, hyphens or underscores.",
64
+ )
65
+
66
+ parsed = Template.parse(template or DEFAULT_TEMPLATE, ref=ref)
67
+ root = (directory or Path.cwd() / name).resolve()
68
+ if root.exists() and any(root.iterdir()) and not force:
69
+ raise UsageError(
70
+ f"{root} is not empty.",
71
+ hint="Choose another directory, or pass --force.",
72
+ )
73
+
74
+ console.header(f"Creating {name}", f"from {parsed.slug}@{parsed.ref}")
75
+ with console.progress(f"Fetching {parsed.slug}…"):
76
+ fetch(parsed, root)
77
+
78
+ changed = personalise(root, name)
79
+ console.success(f"Fetched {parsed.slug} and renamed {len(changed)} file(s)")
80
+
81
+ if git:
82
+ from ..utils.subprocess import run, tool_exists
83
+
84
+ if tool_exists("git") and not (root / ".git").exists():
85
+ run(["git", "init", "--quiet"], cwd=root, check=False)
86
+
87
+ if install:
88
+ from ..utils.pkgmanagers import detect_python_manager
89
+ from ..utils.subprocess import run
90
+
91
+ manager = detect_python_manager()
92
+ console.header("Dependencies", f"installing with {manager.name}")
93
+ with console.progress("Resolving…"):
94
+ result = run(manager.sync_command(), cwd=root, check=False, timeout=900)
95
+ if not result.ok:
96
+ console.failure(f"{manager.name} exited with code {result.returncode}.")
97
+ if result.output:
98
+ console.raw(result.output)
99
+ raise typer.Exit(code=1)
100
+ console.success("Dependencies installed.")
101
+
102
+ console.blank()
103
+ console.print("[bold]Next steps[/bold]")
104
+ steps = [f"cd {root.name}"]
105
+ if not install:
106
+ steps.append("make setup")
107
+ else:
108
+ steps.append("make migrate")
109
+ steps.append("make dev")
110
+ console.commands(steps)
111
+ console.blank()
112
+ console.hint(
113
+ "The starter's README covers configuration, migrations and deployment."
114
+ )
@@ -0,0 +1,69 @@
1
+ """Exception hierarchy for Sillo Start.
2
+
3
+ Every error the tool raises on purpose derives from :class:`SilloStartError`.
4
+ The CLI layer catches that base class and renders a clean message with an exit
5
+ code, so a user never sees a traceback unless they pass ``--verbose``.
6
+
7
+ Each exception carries an optional ``hint`` — a concrete next action. Error
8
+ messages that only say what went wrong make the user guess; the hint is where
9
+ we tell them what to type.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+
15
+ class SilloStartError(Exception):
16
+ """Base class for all deliberate Sillo Start failures.
17
+
18
+ Args:
19
+ message: What went wrong, in one sentence.
20
+ hint: An optional concrete remedy shown beneath the error.
21
+ exit_code: Process exit code the CLI should use.
22
+ """
23
+
24
+ exit_code: int = 1
25
+
26
+ def __init__(
27
+ self,
28
+ message: str,
29
+ *,
30
+ hint: str | None = None,
31
+ exit_code: int | None = None,
32
+ ) -> None:
33
+ super().__init__(message)
34
+ self.message = message
35
+ self.hint = hint
36
+ if exit_code is not None:
37
+ self.exit_code = exit_code
38
+
39
+
40
+ class UsageError(SilloStartError):
41
+ """The command was invoked with an invalid combination of arguments."""
42
+
43
+ exit_code = 2
44
+
45
+
46
+ class CommandError(SilloStartError):
47
+ """An external command exited non-zero.
48
+
49
+ The captured output is kept on the exception so the CLI can show it — Sillo
50
+ Start never silently swallows subprocess output.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ message: str,
56
+ *,
57
+ command: list[str] | None = None,
58
+ returncode: int | None = None,
59
+ output: str | None = None,
60
+ hint: str | None = None,
61
+ ) -> None:
62
+ super().__init__(message, hint=hint)
63
+ self.command = command or []
64
+ self.returncode = returncode
65
+ self.output = output
66
+
67
+
68
+ class ToolNotFoundError(SilloStartError):
69
+ """A required external tool is not installed or not on ``PATH``."""
@@ -0,0 +1,10 @@
1
+ """Creating a project from a starter repository."""
2
+
3
+ from .template import DEFAULT_TEMPLATE, Template, fetch, personalise
4
+
5
+ __all__ = [
6
+ "DEFAULT_TEMPLATE",
7
+ "Template",
8
+ "fetch",
9
+ "personalise",
10
+ ]
@@ -0,0 +1,225 @@
1
+ """Creating a project by fetching a starter repository.
2
+
3
+ A generated project can render perfectly and still fail on its first request:
4
+ templates are checked for rendering, not for running. A starter repository is
5
+ a real application with its own CI, so what arrives has been booted and
6
+ exercised before it reaches anyone.
7
+
8
+ The repository is fetched as a tarball rather than cloned. That needs no git on
9
+ the machine, brings no history someone has to delete before their first commit,
10
+ and pins to a tag as easily as to a branch.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import io
16
+ import re
17
+ import secrets
18
+ import tarfile
19
+ import urllib.error
20
+ import urllib.request
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ from ..exceptions import CommandError
25
+
26
+
27
+ def generate_secret_key(length: int = 50) -> str:
28
+ """Generate a URL-safe application secret.
29
+
30
+ So a new project is never born with a placeholder secret that someone might
31
+ ship.
32
+ """
33
+ return secrets.token_urlsafe(length)[:length]
34
+
35
+
36
+ #: The starter used when none is named.
37
+ DEFAULT_TEMPLATE = "sillohq/starter"
38
+
39
+ #: How long to wait for GitHub before giving up, in seconds.
40
+ TIMEOUT = 60
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Template:
45
+ """A starter repository and the revision to take."""
46
+
47
+ owner: str
48
+ repo: str
49
+ ref: str = "main"
50
+
51
+ @property
52
+ def slug(self) -> str:
53
+ return f"{self.owner}/{self.repo}"
54
+
55
+ @property
56
+ def url(self) -> str:
57
+ """The tarball URL. Public repositories need no authentication."""
58
+ return f"https://codeload.github.com/{self.owner}/{self.repo}/tar.gz/{self.ref}"
59
+
60
+ @classmethod
61
+ def parse(cls, value: str, *, ref: str | None = None) -> Template:
62
+ """Parse ``owner/repo``, ``owner/repo@ref`` or a full GitHub URL.
63
+
64
+ Raises:
65
+ CommandError: If *value* is not a recognisable repository.
66
+ """
67
+ text = value.strip()
68
+ text = re.sub(r"^(https?://)?(www\.)?github\.com/", "", text)
69
+ text = re.sub(r"\.git$", "", text).strip("/")
70
+
71
+ at_ref = None
72
+ if "@" in text:
73
+ text, _, at_ref = text.partition("@")
74
+
75
+ parts = [part for part in text.split("/") if part]
76
+ if len(parts) != 2:
77
+ raise CommandError(
78
+ f"'{value}' is not a repository.",
79
+ hint="Use owner/repo, for example sillohq/starter.",
80
+ )
81
+ return cls(owner=parts[0], repo=parts[1], ref=ref or at_ref or "main")
82
+
83
+
84
+ def fetch(template: Template, destination: Path) -> None:
85
+ """Download *template* and unpack it into *destination*.
86
+
87
+ GitHub wraps the archive in a single top-level directory named after the
88
+ repository and commit, which is stripped so the project's own files land at
89
+ the destination root.
90
+
91
+ Raises:
92
+ CommandError: If the repository or ref cannot be fetched.
93
+ """
94
+ try:
95
+ with urllib.request.urlopen(template.url, timeout=TIMEOUT) as response:
96
+ payload = response.read()
97
+ except urllib.error.HTTPError as exc:
98
+ if exc.code == 404:
99
+ raise CommandError(
100
+ f"Could not find {template.slug} at ref '{template.ref}'.",
101
+ hint="Check the name and the branch or tag, and that the repository is public.",
102
+ ) from exc
103
+ raise CommandError(f"GitHub returned {exc.code} for {template.slug}.") from exc
104
+ except urllib.error.URLError as exc:
105
+ raise CommandError(
106
+ f"Could not reach GitHub: {exc.reason}",
107
+ hint="Creating a project needs network access to fetch the starter.",
108
+ ) from exc
109
+
110
+ destination.mkdir(parents=True, exist_ok=True)
111
+ with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive:
112
+ members = archive.getmembers()
113
+ if not members:
114
+ raise CommandError(f"{template.slug} produced an empty archive.")
115
+
116
+ prefix = members[0].name.split("/")[0] + "/"
117
+ for member in members:
118
+ if not member.name.startswith(prefix):
119
+ continue
120
+ relative = member.name[len(prefix) :]
121
+ if not relative:
122
+ continue
123
+ # A member path that escapes the destination is how a malicious
124
+ # archive overwrites files elsewhere. Refuse rather than sanitise.
125
+ target = (destination / relative).resolve()
126
+ if not str(target).startswith(str(destination.resolve())):
127
+ raise CommandError(
128
+ f"{template.slug} contains an unsafe path: {member.name}"
129
+ )
130
+
131
+ member.name = relative
132
+ archive.extract(member, destination, filter="data")
133
+
134
+
135
+ #: Files rewritten to carry the new project's name, and what to replace in each.
136
+ #: Targeted rather than a blanket find-and-replace, so prose that happens to say
137
+ #: "starter" — the README, a Makefile comment — is left as written.
138
+ #:
139
+ #: Model files are deliberately absent. A model's docstring becomes its
140
+ #: ``table_description``, so rewriting one puts the models out of step with the
141
+ #: committed migration and the next `migrate` writes a spurious second one.
142
+ RENAMES = (
143
+ ("pyproject.toml", ('name = "{old}"', 'name = "{new}"')),
144
+ (
145
+ "app/config.py",
146
+ ('"""Typed settings for {Old}."""', '"""Typed settings for {New}."""'),
147
+ ),
148
+ ("app/config.py", ('app_name: str = "{Old}"', 'app_name: str = "{New}"')),
149
+ ("app/config.py", ("sqlite://storage/{old}.db", "sqlite://storage/{new}.db")),
150
+ (".env.example", ("# {Old} environment.", "# {New} environment.")),
151
+ (".env.example", ("APP_NAME={Old}", "APP_NAME={New}")),
152
+ (".env.example", ("sqlite://storage/{old}.db", "sqlite://storage/{new}.db")),
153
+ )
154
+
155
+
156
+ def personalise(root: Path, name: str, *, template_name: str = "starter") -> list[str]:
157
+ """Rewrite the fetched project to be *name*, and create its ``.env``.
158
+
159
+ Args:
160
+ root: The unpacked project.
161
+ name: The new project name.
162
+ template_name: What the starter called itself.
163
+
164
+ Returns:
165
+ The paths that changed, relative to *root*.
166
+ """
167
+ substitutions = {
168
+ "old": template_name,
169
+ "new": name,
170
+ "Old": template_name.replace("_", " ").title().replace(" ", ""),
171
+ "New": name.replace("_", " ").title().replace(" ", ""),
172
+ }
173
+
174
+ changed: list[str] = []
175
+ for relative, (find, replace) in RENAMES:
176
+ path = root / relative
177
+ if not path.is_file():
178
+ continue
179
+ content = path.read_text(encoding="utf-8")
180
+ needle = find.format(**substitutions)
181
+ if needle not in content:
182
+ continue
183
+ path.write_text(
184
+ content.replace(needle, replace.format(**substitutions)), encoding="utf-8"
185
+ )
186
+ if relative not in changed:
187
+ changed.append(relative)
188
+
189
+ # The lock file names the project as a member of its own workspace, so it
190
+ # has to agree with pyproject or `uv sync` fails on a renamed project.
191
+ lock = root / "uv.lock"
192
+ if lock.is_file():
193
+ content = lock.read_text(encoding="utf-8")
194
+ renamed = content.replace(f'name = "{template_name}"', f'name = "{name}"')
195
+ if renamed != content:
196
+ lock.write_text(renamed, encoding="utf-8")
197
+ changed.append("uv.lock")
198
+
199
+ if _write_env(root, name):
200
+ changed.append(".env")
201
+ return changed
202
+
203
+
204
+ def _write_env(root: Path, name: str) -> bool:
205
+ """Create ``.env`` from ``.env.example`` with fresh secrets.
206
+
207
+ Returns:
208
+ True when a file was written. An existing ``.env`` is never replaced —
209
+ it may hold real credentials.
210
+ """
211
+ example = root / ".env.example"
212
+ env = root / ".env"
213
+ if not example.is_file() or env.exists():
214
+ return False
215
+
216
+ lines = []
217
+ for line in example.read_text(encoding="utf-8").splitlines():
218
+ key, sep, _ = line.partition("=")
219
+ # Secrets committed to a starter are placeholders by definition. Every
220
+ # project gets its own, so no two deployments ever share a signing key.
221
+ if sep and key.strip() in {"SECRET_KEY", "JWT_SECRET", "APP_KEY"}:
222
+ line = f"{key}={generate_secret_key()}"
223
+ lines.append(line)
224
+ env.write_text("\n".join(lines) + "\n", encoding="utf-8")
225
+ return True
@@ -0,0 +1,21 @@
1
+ """Shared low-level helpers."""
2
+
3
+ from .console import Console, console, is_ci
4
+ from .naming import is_valid_project_name, is_valid_python_identifier, package_name_for
5
+ from .pkgmanagers import detect_python_manager
6
+ from .subprocess import CommandResult, require_tool, run, tool_exists, which
7
+
8
+ __all__ = [
9
+ "Console",
10
+ "console",
11
+ "is_ci",
12
+ "is_valid_project_name",
13
+ "is_valid_python_identifier",
14
+ "package_name_for",
15
+ "detect_python_manager",
16
+ "CommandResult",
17
+ "require_tool",
18
+ "run",
19
+ "tool_exists",
20
+ "which",
21
+ ]