pyforge-init 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.
- pyforge/__init__.py +4 -0
- pyforge/cli.py +194 -0
- pyforge/config.py +119 -0
- pyforge/generator.py +174 -0
- pyforge/prompts.py +167 -0
- pyforge/remote.py +179 -0
- pyforge/templates/basic/tests/test_main.py.jinja +3 -0
- pyforge/templates/cli/src/package/__main__.py.jinja +4 -0
- pyforge/templates/cli/src/package/cli.py.jinja +7 -0
- pyforge/templates/cli/tests/test_cli.py.jinja +8 -0
- pyforge/templates/fastapi/src/package/main.py.jinja +7 -0
- pyforge/templates/fastapi/tests/test_main.py.jinja +9 -0
- pyforge/templates/flask/src/package/app.py.jinja +10 -0
- pyforge/templates/flask/tests/test_app.py.jinja +19 -0
- pyforge/templates/shared/Justfile.jinja +28 -0
- pyforge/templates/shared/Makefile.jinja +30 -0
- pyforge/templates/shared/README.md.jinja +28 -0
- pyforge/templates/shared/env.example.jinja +5 -0
- pyforge/templates/shared/gitignore.jinja +13 -0
- pyforge/templates/shared/pre-commit-config.yaml.jinja +15 -0
- pyforge/templates/shared/pyproject.toml.jinja +58 -0
- pyforge/templates/shared/requirements.txt.jinja +1 -0
- pyforge/templates/shared/src/package/__init__.py.jinja +3 -0
- pyforge/utils.py +65 -0
- pyforge_init-0.1.0.dist-info/METADATA +221 -0
- pyforge_init-0.1.0.dist-info/RECORD +28 -0
- pyforge_init-0.1.0.dist-info/WHEEL +4 -0
- pyforge_init-0.1.0.dist-info/entry_points.txt +2 -0
pyforge/__init__.py
ADDED
pyforge/cli.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entry point for pyforge (the `newpython` command).
|
|
3
|
+
|
|
4
|
+
Precedence for values (highest → lowest):
|
|
5
|
+
1. CLI flags (--template, --no-pre-commit, --justfile, --yes)
|
|
6
|
+
2. Config file (~/.newpythonrc)
|
|
7
|
+
3. Interactive prompts / built-in defaults
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import click
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from pyforge.config import load_config
|
|
17
|
+
from pyforge.prompts import ask_questions
|
|
18
|
+
from pyforge.utils import is_valid_package_name, create_virtual_environment, init_git_repo
|
|
19
|
+
from pyforge.generator import generate_project
|
|
20
|
+
from pyforge.remote import is_remote_template
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
BUILTIN_TEMPLATES = {"basic", "cli", "fastapi", "flask"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _validate_template(ctx: click.Context, param: click.Parameter, value: str) -> str:
|
|
28
|
+
"""Click callback that validates both built-in names and gh: strings."""
|
|
29
|
+
if value in BUILTIN_TEMPLATES or is_remote_template(value):
|
|
30
|
+
return value
|
|
31
|
+
raise click.BadParameter(
|
|
32
|
+
f"'{value}' is not a built-in template (basic, cli, fastapi, flask) "
|
|
33
|
+
"and does not start with 'gh:'. "
|
|
34
|
+
"Use --template basic or --template gh:username/repo."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@click.command()
|
|
39
|
+
@click.argument("project_name")
|
|
40
|
+
@click.option(
|
|
41
|
+
"--template",
|
|
42
|
+
default="basic",
|
|
43
|
+
show_default=True,
|
|
44
|
+
callback=_validate_template,
|
|
45
|
+
is_eager=True,
|
|
46
|
+
help=(
|
|
47
|
+
"Project template. Built-ins: basic, cli, fastapi, flask. "
|
|
48
|
+
"Remote: gh:username/repo or gh:username/repo@ref."
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
@click.option(
|
|
52
|
+
"--no-pre-commit",
|
|
53
|
+
"no_pre_commit",
|
|
54
|
+
is_flag=True,
|
|
55
|
+
default=False,
|
|
56
|
+
help="Skip generating .pre-commit-config.yaml.",
|
|
57
|
+
)
|
|
58
|
+
@click.option(
|
|
59
|
+
"--justfile",
|
|
60
|
+
is_flag=True,
|
|
61
|
+
default=False,
|
|
62
|
+
help="Generate a Justfile instead of a Makefile.",
|
|
63
|
+
)
|
|
64
|
+
@click.option(
|
|
65
|
+
"--yes",
|
|
66
|
+
"-y",
|
|
67
|
+
"accept_defaults",
|
|
68
|
+
is_flag=True,
|
|
69
|
+
default=False,
|
|
70
|
+
help="Accept all defaults — skip interactive prompts (useful for CI/scripting).",
|
|
71
|
+
)
|
|
72
|
+
def main(
|
|
73
|
+
project_name: str,
|
|
74
|
+
template: str,
|
|
75
|
+
no_pre_commit: bool,
|
|
76
|
+
justfile: bool,
|
|
77
|
+
accept_defaults: bool,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""✨ Scaffold a new Python project."""
|
|
80
|
+
|
|
81
|
+
# ── Validate project name ─────────────────────────────────────────────────
|
|
82
|
+
if not is_valid_package_name(project_name):
|
|
83
|
+
console.print(
|
|
84
|
+
f"[bold red]Error: '{project_name}' is not a valid Python package name.\n"
|
|
85
|
+
"Package names must be valid Python identifiers (letters, digits, underscores; "
|
|
86
|
+
"no hyphens or spaces).[/bold red]"
|
|
87
|
+
)
|
|
88
|
+
sys.exit(1)
|
|
89
|
+
|
|
90
|
+
project_dir = Path.cwd() / project_name
|
|
91
|
+
if project_dir.exists():
|
|
92
|
+
console.print(
|
|
93
|
+
f"[bold red]Error: Directory '{project_dir}' already exists.[/bold red]"
|
|
94
|
+
)
|
|
95
|
+
sys.exit(1)
|
|
96
|
+
|
|
97
|
+
# ── Load config file defaults ─────────────────────────────────────────────
|
|
98
|
+
cfg = load_config()
|
|
99
|
+
|
|
100
|
+
# CLI flags override config-file values.
|
|
101
|
+
# (no_pre_commit and justfile are False by default, so only override
|
|
102
|
+
# when the user actually passed the flag on the CLI.)
|
|
103
|
+
effective_no_pre_commit = no_pre_commit or not cfg.get("pre_commit", True)
|
|
104
|
+
effective_justfile = justfile or cfg.get("justfile", False)
|
|
105
|
+
|
|
106
|
+
# Template: CLI always wins over config.
|
|
107
|
+
# (template is already validated by the click callback)
|
|
108
|
+
effective_template = template # CLI value (default "basic" or user-supplied)
|
|
109
|
+
if template == "basic" and "template" in cfg:
|
|
110
|
+
# Only use config template when the CLI value is the pure default.
|
|
111
|
+
effective_template = cfg["template"]
|
|
112
|
+
|
|
113
|
+
# ── Collect answers ───────────────────────────────────────────────────────
|
|
114
|
+
if accept_defaults:
|
|
115
|
+
# Non-interactive: use config + built-in defaults, skip all prompts.
|
|
116
|
+
context = {
|
|
117
|
+
"project_name": project_name,
|
|
118
|
+
"description": cfg.get("description", "A new Python project"),
|
|
119
|
+
"author": cfg.get("author", "Your Name"),
|
|
120
|
+
"license": cfg.get("license", "MIT"),
|
|
121
|
+
"template": effective_template,
|
|
122
|
+
"use_pre_commit": not effective_no_pre_commit,
|
|
123
|
+
"use_justfile": effective_justfile,
|
|
124
|
+
}
|
|
125
|
+
init_venv = False
|
|
126
|
+
init_git = False
|
|
127
|
+
else:
|
|
128
|
+
# Interactive: merge config defaults into the prompts.
|
|
129
|
+
wizard_defaults = {
|
|
130
|
+
"description": cfg.get("description", "A new Python project"),
|
|
131
|
+
"author": cfg.get("author", "Your Name"),
|
|
132
|
+
"license": cfg.get("license", "MIT"),
|
|
133
|
+
}
|
|
134
|
+
try:
|
|
135
|
+
answers = ask_questions(project_name, wizard_defaults)
|
|
136
|
+
except (KeyboardInterrupt, click.exceptions.Abort):
|
|
137
|
+
console.print("\n[dim]Aborted.[/dim]")
|
|
138
|
+
sys.exit(0)
|
|
139
|
+
|
|
140
|
+
init_venv = answers.pop("init_venv", True)
|
|
141
|
+
init_git = answers.pop("init_git", True)
|
|
142
|
+
|
|
143
|
+
context = {
|
|
144
|
+
**answers,
|
|
145
|
+
"template": effective_template,
|
|
146
|
+
"use_pre_commit": not effective_no_pre_commit,
|
|
147
|
+
"use_justfile": effective_justfile,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# ── Generate project ──────────────────────────────────────────────────────
|
|
151
|
+
try:
|
|
152
|
+
generate_project(project_dir, context)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
console.print(f"\n[bold red]✗ Error during project generation:[/bold red] {e}")
|
|
155
|
+
sys.exit(1)
|
|
156
|
+
|
|
157
|
+
# ── Post-generation actions ───────────────────────────────────────────────
|
|
158
|
+
if init_venv:
|
|
159
|
+
create_virtual_environment(project_dir)
|
|
160
|
+
|
|
161
|
+
if init_git:
|
|
162
|
+
init_git_repo(project_dir)
|
|
163
|
+
|
|
164
|
+
# ── Success message ───────────────────────────────────────────────────────
|
|
165
|
+
make_cmd = "just" if context["use_justfile"] else "make"
|
|
166
|
+
|
|
167
|
+
console.print(
|
|
168
|
+
f"\n[bold green]✓ Project [bold white]{project_name}[/bold white] created "
|
|
169
|
+
f"with template [bold white]{effective_template}[/bold white]![/bold green]"
|
|
170
|
+
)
|
|
171
|
+
console.print("\n[bold]Next steps:[/bold]")
|
|
172
|
+
console.print(f" [cyan]cd {project_name}[/cyan]")
|
|
173
|
+
|
|
174
|
+
if init_venv:
|
|
175
|
+
console.print(
|
|
176
|
+
" [cyan].venv\\Scripts\\activate[/cyan]"
|
|
177
|
+
" [dim]# Windows[/dim]"
|
|
178
|
+
)
|
|
179
|
+
console.print(
|
|
180
|
+
" [cyan]source .venv/bin/activate[/cyan]"
|
|
181
|
+
" [dim]# macOS / Linux[/dim]"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
console.print(f" [cyan]{make_cmd} install[/cyan]")
|
|
185
|
+
|
|
186
|
+
if context["use_pre_commit"]:
|
|
187
|
+
console.print(" [cyan]pre-commit install[/cyan]")
|
|
188
|
+
|
|
189
|
+
console.print(f" [cyan]{make_cmd} run[/cyan]")
|
|
190
|
+
console.print()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
main()
|
pyforge/config.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Config file support for pyforge.
|
|
3
|
+
|
|
4
|
+
Reads defaults from ~/.newpythonrc (TOML format).
|
|
5
|
+
The path can be overridden with the NEWPYTHON_CONFIG environment variable.
|
|
6
|
+
|
|
7
|
+
Example ~/.newpythonrc:
|
|
8
|
+
author = "Jane Doe"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
template = "basic"
|
|
11
|
+
pre_commit = true
|
|
12
|
+
justfile = false
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Use stdlib tomllib on Python 3.11+, fall back to tomli on older versions.
|
|
23
|
+
if sys.version_info >= (3, 11):
|
|
24
|
+
import tomllib
|
|
25
|
+
else:
|
|
26
|
+
try:
|
|
27
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
28
|
+
except ImportError:
|
|
29
|
+
tomllib = None # type: ignore[assignment]
|
|
30
|
+
|
|
31
|
+
CONFIG_ENV_VAR = "NEWPYTHON_CONFIG"
|
|
32
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".newpythonrc"
|
|
33
|
+
|
|
34
|
+
# Recognised keys and their expected types for validation.
|
|
35
|
+
KNOWN_KEYS: Dict[str, type] = {
|
|
36
|
+
"author": str,
|
|
37
|
+
"license": str,
|
|
38
|
+
"template": str,
|
|
39
|
+
"pre_commit": bool,
|
|
40
|
+
"justfile": bool,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
VALID_LICENSES = {"MIT", "Apache-2.0", "none"}
|
|
44
|
+
BUILTIN_TEMPLATES = {"basic", "cli", "fastapi", "flask"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _config_path() -> Path:
|
|
48
|
+
"""Return the config file path, respecting the env-var override."""
|
|
49
|
+
env_override = os.environ.get(CONFIG_ENV_VAR)
|
|
50
|
+
if env_override:
|
|
51
|
+
return Path(env_override).expanduser()
|
|
52
|
+
return DEFAULT_CONFIG_PATH
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_config() -> Dict[str, Any]:
|
|
56
|
+
"""
|
|
57
|
+
Load defaults from ~/.newpythonrc.
|
|
58
|
+
|
|
59
|
+
Returns an empty dict if the file does not exist or tomllib is unavailable.
|
|
60
|
+
Prints a warning and returns an empty dict on parse errors (never crashes).
|
|
61
|
+
"""
|
|
62
|
+
if tomllib is None:
|
|
63
|
+
# tomli not installed and Python < 3.11 — gracefully degrade.
|
|
64
|
+
return {}
|
|
65
|
+
|
|
66
|
+
path = _config_path()
|
|
67
|
+
if not path.exists():
|
|
68
|
+
return {}
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
with open(path, "rb") as fh:
|
|
72
|
+
raw = tomllib.load(fh)
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
from rich.console import Console
|
|
75
|
+
Console().print(
|
|
76
|
+
f"[bold yellow]⚠ Could not parse config file {path}: {exc}[/bold yellow]"
|
|
77
|
+
)
|
|
78
|
+
return {}
|
|
79
|
+
|
|
80
|
+
return _validate(raw, path)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _validate(raw: Dict[str, Any], path: Path) -> Dict[str, Any]:
|
|
84
|
+
"""Validate and normalise the raw TOML data. Unknown/invalid keys are dropped with a warning."""
|
|
85
|
+
from rich.console import Console
|
|
86
|
+
console = Console()
|
|
87
|
+
clean: Dict[str, Any] = {}
|
|
88
|
+
|
|
89
|
+
for key, value in raw.items():
|
|
90
|
+
if key not in KNOWN_KEYS:
|
|
91
|
+
console.print(
|
|
92
|
+
f"[dim yellow]Config: ignoring unknown key '{key}' in {path}[/dim yellow]"
|
|
93
|
+
)
|
|
94
|
+
continue
|
|
95
|
+
expected_type = KNOWN_KEYS[key]
|
|
96
|
+
if not isinstance(value, expected_type):
|
|
97
|
+
console.print(
|
|
98
|
+
f"[dim yellow]Config: '{key}' should be {expected_type.__name__}, "
|
|
99
|
+
f"got {type(value).__name__} — ignoring.[/dim yellow]"
|
|
100
|
+
)
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
# Domain validation
|
|
104
|
+
if key == "license" and value not in VALID_LICENSES:
|
|
105
|
+
console.print(
|
|
106
|
+
f"[dim yellow]Config: 'license' must be one of "
|
|
107
|
+
f"{sorted(VALID_LICENSES)}, got '{value}' — ignoring.[/dim yellow]"
|
|
108
|
+
)
|
|
109
|
+
continue
|
|
110
|
+
if key == "template" and value not in BUILTIN_TEMPLATES and not value.startswith("gh:"):
|
|
111
|
+
console.print(
|
|
112
|
+
f"[dim yellow]Config: 'template' '{value}' is not a built-in template "
|
|
113
|
+
f"and doesn't start with 'gh:' — ignoring.[/dim yellow]"
|
|
114
|
+
)
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
clean[key] = value
|
|
118
|
+
|
|
119
|
+
return clean
|
pyforge/generator.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project scaffolding engine for pyforge.
|
|
3
|
+
|
|
4
|
+
Handles both local built-in templates (Jinja2-rendered) and remote
|
|
5
|
+
GitHub templates (plain file copy via remote.fetch_and_apply).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict
|
|
12
|
+
|
|
13
|
+
from jinja2 import Environment, FileSystemLoader
|
|
14
|
+
|
|
15
|
+
from pyforge.remote import is_remote_template, fetch_and_apply
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_templates_dir() -> Path:
|
|
19
|
+
"""Return the absolute path to the built-in templates directory."""
|
|
20
|
+
return Path(__file__).parent / "templates"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render_template(
|
|
24
|
+
env: Environment,
|
|
25
|
+
template_name: str,
|
|
26
|
+
context: Dict[str, Any],
|
|
27
|
+
output_path: Path,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Render a single Jinja2 template and write it to *output_path*."""
|
|
30
|
+
# Jinja2 FileSystemLoader expects forward slashes on all platforms.
|
|
31
|
+
template_name_posix = template_name.replace("\\", "/")
|
|
32
|
+
template = env.get_template(template_name_posix)
|
|
33
|
+
content = template.render(**context)
|
|
34
|
+
|
|
35
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
37
|
+
f.write(content)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def generate_project(
|
|
41
|
+
project_path: Path,
|
|
42
|
+
context: Dict[str, Any],
|
|
43
|
+
) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Generate the project files at *project_path* using *context*.
|
|
46
|
+
|
|
47
|
+
Dispatches to the remote path when the template string starts with
|
|
48
|
+
``gh:``; otherwise renders local Jinja2 templates.
|
|
49
|
+
"""
|
|
50
|
+
template_type = context.get("template", "basic")
|
|
51
|
+
|
|
52
|
+
# ── Remote GitHub template ────────────────────────────────────────────────
|
|
53
|
+
if is_remote_template(template_type):
|
|
54
|
+
project_path.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
try:
|
|
56
|
+
fetch_and_apply(template_type, project_path)
|
|
57
|
+
except Exception:
|
|
58
|
+
if project_path.exists():
|
|
59
|
+
shutil.rmtree(project_path)
|
|
60
|
+
raise
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
# ── Local Jinja2 template ─────────────────────────────────────────────────
|
|
64
|
+
use_pre_commit = context.get("use_pre_commit", True)
|
|
65
|
+
use_justfile = context.get("use_justfile", False)
|
|
66
|
+
templates_dir = get_templates_dir()
|
|
67
|
+
|
|
68
|
+
project_path.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
# Load templates: template-specific folder takes priority over shared.
|
|
71
|
+
env = Environment(
|
|
72
|
+
loader=FileSystemLoader(
|
|
73
|
+
[
|
|
74
|
+
str(templates_dir / template_type),
|
|
75
|
+
str(templates_dir / "shared"),
|
|
76
|
+
]
|
|
77
|
+
),
|
|
78
|
+
keep_trailing_newline=True,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
# ── Shared files ──────────────────────────────────────────────────────
|
|
83
|
+
render_template(
|
|
84
|
+
env, "pyproject.toml.jinja", context, project_path / "pyproject.toml"
|
|
85
|
+
)
|
|
86
|
+
render_template(
|
|
87
|
+
env, "gitignore.jinja", context, project_path / ".gitignore"
|
|
88
|
+
)
|
|
89
|
+
render_template(
|
|
90
|
+
env, "env.example.jinja", context, project_path / ".env.example"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if use_pre_commit:
|
|
94
|
+
render_template(
|
|
95
|
+
env,
|
|
96
|
+
"pre-commit-config.yaml.jinja",
|
|
97
|
+
context,
|
|
98
|
+
project_path / ".pre-commit-config.yaml",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
makefile_name = "Justfile" if use_justfile else "Makefile"
|
|
102
|
+
makefile_template = "Justfile.jinja" if use_justfile else "Makefile.jinja"
|
|
103
|
+
render_template(
|
|
104
|
+
env, makefile_template, context, project_path / makefile_name
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# ── Template-specific files ───────────────────────────────────────────
|
|
108
|
+
render_template(
|
|
109
|
+
env, "README.md.jinja", context, project_path / "README.md"
|
|
110
|
+
)
|
|
111
|
+
render_template(
|
|
112
|
+
env,
|
|
113
|
+
"requirements.txt.jinja",
|
|
114
|
+
context,
|
|
115
|
+
project_path / "requirements.txt",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
package_dir = project_path / "src" / context["project_name"]
|
|
119
|
+
render_template(
|
|
120
|
+
env,
|
|
121
|
+
"src/package/__init__.py.jinja",
|
|
122
|
+
context,
|
|
123
|
+
package_dir / "__init__.py",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if template_type == "cli":
|
|
127
|
+
render_template(
|
|
128
|
+
env,
|
|
129
|
+
"src/package/__main__.py.jinja",
|
|
130
|
+
context,
|
|
131
|
+
package_dir / "__main__.py",
|
|
132
|
+
)
|
|
133
|
+
render_template(
|
|
134
|
+
env, "src/package/cli.py.jinja", context, package_dir / "cli.py"
|
|
135
|
+
)
|
|
136
|
+
render_template(
|
|
137
|
+
env,
|
|
138
|
+
"tests/test_cli.py.jinja",
|
|
139
|
+
context,
|
|
140
|
+
project_path / "tests" / "test_cli.py",
|
|
141
|
+
)
|
|
142
|
+
elif template_type == "fastapi":
|
|
143
|
+
render_template(
|
|
144
|
+
env, "src/package/main.py.jinja", context, package_dir / "main.py"
|
|
145
|
+
)
|
|
146
|
+
render_template(
|
|
147
|
+
env,
|
|
148
|
+
"tests/test_main.py.jinja",
|
|
149
|
+
context,
|
|
150
|
+
project_path / "tests" / "test_main.py",
|
|
151
|
+
)
|
|
152
|
+
elif template_type == "flask":
|
|
153
|
+
render_template(
|
|
154
|
+
env, "src/package/app.py.jinja", context, package_dir / "app.py"
|
|
155
|
+
)
|
|
156
|
+
render_template(
|
|
157
|
+
env,
|
|
158
|
+
"tests/test_app.py.jinja",
|
|
159
|
+
context,
|
|
160
|
+
project_path / "tests" / "test_app.py",
|
|
161
|
+
)
|
|
162
|
+
else: # basic
|
|
163
|
+
render_template(
|
|
164
|
+
env,
|
|
165
|
+
"tests/test_main.py.jinja",
|
|
166
|
+
context,
|
|
167
|
+
project_path / "tests" / "test_main.py",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
except Exception:
|
|
171
|
+
# Clean up any partially-created project directory on failure.
|
|
172
|
+
if project_path.exists():
|
|
173
|
+
shutil.rmtree(project_path)
|
|
174
|
+
raise
|
pyforge/prompts.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interactive TUI prompts for pyforge using questionary.
|
|
3
|
+
|
|
4
|
+
Provides a beautiful, npm-create-vite-style wizard for collecting project
|
|
5
|
+
configuration. Falls back to click.prompt/confirm when questionary is not
|
|
6
|
+
available (e.g., non-interactive/CI environments).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
BUILTIN_TEMPLATES = ["basic", "cli", "fastapi", "flask"]
|
|
19
|
+
LICENSES = ["MIT", "Apache-2.0", "none"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _print_header(project_name: str) -> None:
|
|
23
|
+
"""Print the styled welcome banner."""
|
|
24
|
+
title = Text("✨ newpython", style="bold cyan")
|
|
25
|
+
subtitle = Text(f"Scaffolding project: {project_name}", style="dim")
|
|
26
|
+
combined = Text.assemble(title, "\n", subtitle)
|
|
27
|
+
console.print(Panel(combined, border_style="cyan", padding=(0, 2)))
|
|
28
|
+
console.print()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_interactive() -> bool:
|
|
32
|
+
"""Return True if we are attached to a real TTY."""
|
|
33
|
+
import sys
|
|
34
|
+
return sys.stdin.isatty()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def ask_questions(project_name: str, defaults: Dict[str, Any]) -> Dict[str, Any]:
|
|
38
|
+
"""
|
|
39
|
+
Run the interactive questionary wizard (or fall back to click prompts in CI).
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
project_name: The project name already validated and accepted via CLI arg.
|
|
43
|
+
defaults: Pre-populated values from the config file.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
A fully populated context dict ready for the generator.
|
|
47
|
+
"""
|
|
48
|
+
_print_header(project_name)
|
|
49
|
+
|
|
50
|
+
if _is_interactive():
|
|
51
|
+
return _ask_with_questionary(project_name, defaults)
|
|
52
|
+
else:
|
|
53
|
+
console.print("[dim]Non-interactive mode — using defaults.[/dim]")
|
|
54
|
+
return _ask_with_click(project_name, defaults)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ask_with_questionary(project_name: str, defaults: Dict[str, Any]) -> Dict[str, Any]:
|
|
58
|
+
"""Collect answers using questionary (full TUI)."""
|
|
59
|
+
import questionary
|
|
60
|
+
from questionary import Style
|
|
61
|
+
|
|
62
|
+
custom_style = Style([
|
|
63
|
+
("qmark", "fg:#5f87ff bold"),
|
|
64
|
+
("question", "bold"),
|
|
65
|
+
("answer", "fg:#5fd7ff bold"),
|
|
66
|
+
("pointer", "fg:#5f87ff bold"),
|
|
67
|
+
("highlighted", "fg:#5f87ff bold"),
|
|
68
|
+
("selected", "fg:#5fd7ff"),
|
|
69
|
+
("separator", "fg:#6c6c6c"),
|
|
70
|
+
("instruction", "fg:#6c6c6c italic"),
|
|
71
|
+
])
|
|
72
|
+
|
|
73
|
+
# ── Description ───────────────────────────────────────────────────────────
|
|
74
|
+
description = questionary.text(
|
|
75
|
+
"Project description:",
|
|
76
|
+
default=defaults.get("description", "A new Python project"),
|
|
77
|
+
style=custom_style,
|
|
78
|
+
).ask()
|
|
79
|
+
if description is None: # user hit Ctrl-C
|
|
80
|
+
raise KeyboardInterrupt
|
|
81
|
+
|
|
82
|
+
# ── Author ─────────────────────────────────────────────────────────────────
|
|
83
|
+
author = questionary.text(
|
|
84
|
+
"Author name:",
|
|
85
|
+
default=defaults.get("author", "Your Name"),
|
|
86
|
+
style=custom_style,
|
|
87
|
+
).ask()
|
|
88
|
+
if author is None:
|
|
89
|
+
raise KeyboardInterrupt
|
|
90
|
+
|
|
91
|
+
# ── License ────────────────────────────────────────────────────────────────
|
|
92
|
+
license_default = defaults.get("license", "MIT")
|
|
93
|
+
license_type = questionary.select(
|
|
94
|
+
"License:",
|
|
95
|
+
choices=LICENSES,
|
|
96
|
+
default=license_default if license_default in LICENSES else "MIT",
|
|
97
|
+
style=custom_style,
|
|
98
|
+
).ask()
|
|
99
|
+
if license_type is None:
|
|
100
|
+
raise KeyboardInterrupt
|
|
101
|
+
|
|
102
|
+
# ── Virtual environment ────────────────────────────────────────────────────
|
|
103
|
+
init_venv = questionary.confirm(
|
|
104
|
+
"Initialize a virtual environment (.venv)?",
|
|
105
|
+
default=True,
|
|
106
|
+
style=custom_style,
|
|
107
|
+
).ask()
|
|
108
|
+
if init_venv is None:
|
|
109
|
+
raise KeyboardInterrupt
|
|
110
|
+
|
|
111
|
+
# ── Git ────────────────────────────────────────────────────────────────────
|
|
112
|
+
init_git = questionary.confirm(
|
|
113
|
+
"Initialize a git repository?",
|
|
114
|
+
default=True,
|
|
115
|
+
style=custom_style,
|
|
116
|
+
).ask()
|
|
117
|
+
if init_git is None:
|
|
118
|
+
raise KeyboardInterrupt
|
|
119
|
+
|
|
120
|
+
console.print()
|
|
121
|
+
return _build_context(project_name, defaults, description, author,
|
|
122
|
+
license_type, init_venv, init_git)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _ask_with_click(project_name: str, defaults: Dict[str, Any]) -> Dict[str, Any]:
|
|
126
|
+
"""Fallback: collect answers using plain click prompts (CI / non-TTY)."""
|
|
127
|
+
import click
|
|
128
|
+
|
|
129
|
+
description = click.prompt(
|
|
130
|
+
"Project description",
|
|
131
|
+
default=defaults.get("description", "A new Python project"),
|
|
132
|
+
)
|
|
133
|
+
author = click.prompt(
|
|
134
|
+
"Author name",
|
|
135
|
+
default=defaults.get("author", "Your Name"),
|
|
136
|
+
)
|
|
137
|
+
license_type = click.prompt(
|
|
138
|
+
"License",
|
|
139
|
+
type=click.Choice(LICENSES),
|
|
140
|
+
default=defaults.get("license", "MIT"),
|
|
141
|
+
)
|
|
142
|
+
init_venv = click.confirm("Initialize virtual environment?", default=True)
|
|
143
|
+
init_git = click.confirm("Initialize git repository?", default=True)
|
|
144
|
+
|
|
145
|
+
return _build_context(project_name, defaults, description, author,
|
|
146
|
+
license_type, init_venv, init_git)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _build_context(
|
|
150
|
+
project_name: str,
|
|
151
|
+
defaults: Dict[str, Any],
|
|
152
|
+
description: str,
|
|
153
|
+
author: str,
|
|
154
|
+
license_type: str,
|
|
155
|
+
init_venv: bool,
|
|
156
|
+
init_git: bool,
|
|
157
|
+
) -> Dict[str, Any]:
|
|
158
|
+
"""Assemble the final context dict passed to the generator and actions."""
|
|
159
|
+
return {
|
|
160
|
+
"project_name": project_name,
|
|
161
|
+
"description": description,
|
|
162
|
+
"author": author,
|
|
163
|
+
"license": license_type,
|
|
164
|
+
# template and tool flags come from CLI / config, not the wizard
|
|
165
|
+
"init_venv": init_venv,
|
|
166
|
+
"init_git": init_git,
|
|
167
|
+
}
|