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 ADDED
@@ -0,0 +1,4 @@
1
+ """
2
+ pyforge - Python Project Generator
3
+ """
4
+ __version__ = "0.1.0"
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
+ }