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.
- sillo_start/__init__.py +35 -0
- sillo_start/__main__.py +19 -0
- sillo_start/cli/__init__.py +20 -0
- sillo_start/cli/app.py +106 -0
- sillo_start/cli/create.py +114 -0
- sillo_start/exceptions.py +69 -0
- sillo_start/project/__init__.py +10 -0
- sillo_start/project/template.py +225 -0
- sillo_start/utils/__init__.py +21 -0
- sillo_start/utils/console.py +220 -0
- sillo_start/utils/naming.py +148 -0
- sillo_start/utils/pkgmanagers.py +283 -0
- sillo_start/utils/subprocess.py +179 -0
- sillo_start-0.1.0.dist-info/METADATA +153 -0
- sillo_start-0.1.0.dist-info/RECORD +17 -0
- sillo_start-0.1.0.dist-info/WHEEL +4 -0
- sillo_start-0.1.0.dist-info/entry_points.txt +2 -0
sillo_start/__init__.py
ADDED
|
@@ -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__"]
|
sillo_start/__main__.py
ADDED
|
@@ -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,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
|
+
]
|