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/remote.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Remote GitHub template support for pyforge.
|
|
3
|
+
|
|
4
|
+
Parses `gh:user/repo` and `gh:user/repo@ref` template strings,
|
|
5
|
+
downloads the GitHub repository as a ZIP archive, and copies its
|
|
6
|
+
contents into the target project directory (degit-style: plain file copy,
|
|
7
|
+
no Jinja rendering).
|
|
8
|
+
|
|
9
|
+
Template directory convention
|
|
10
|
+
------------------------------
|
|
11
|
+
If the downloaded repo contains a top-level `template/` directory, only
|
|
12
|
+
that subdirectory is copied. Otherwise the entire repo root is copied.
|
|
13
|
+
This lets template authors keep docs, CI config, etc. outside the
|
|
14
|
+
scaffolded content.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import io
|
|
19
|
+
import shutil
|
|
20
|
+
import zipfile
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional, Tuple
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_remote_template(template: str) -> bool:
|
|
26
|
+
"""Return True if the template string refers to a remote GitHub repo."""
|
|
27
|
+
return template.startswith("gh:")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_gh_template(template: str) -> Tuple[str, str, str]:
|
|
31
|
+
"""
|
|
32
|
+
Parse a `gh:user/repo@ref` string.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
(user, repo, ref) — ref defaults to "main".
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ValueError: if the string cannot be parsed.
|
|
39
|
+
"""
|
|
40
|
+
body = template[3:] # strip "gh:"
|
|
41
|
+
ref = "main"
|
|
42
|
+
|
|
43
|
+
if "@" in body:
|
|
44
|
+
body, ref = body.rsplit("@", 1)
|
|
45
|
+
|
|
46
|
+
parts = body.split("/")
|
|
47
|
+
if len(parts) != 2 or not all(parts):
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"Invalid remote template '{template}'. "
|
|
50
|
+
"Expected format: gh:username/repo or gh:username/repo@ref"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
user, repo = parts
|
|
54
|
+
return user, repo, ref
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _zip_url(user: str, repo: str, ref: str) -> str:
|
|
58
|
+
return f"https://github.com/{user}/{repo}/archive/refs/heads/{ref}.zip"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _zip_url_tag(user: str, repo: str, ref: str) -> str:
|
|
62
|
+
"""Alternative URL used when the ref is a tag rather than a branch."""
|
|
63
|
+
return f"https://github.com/{user}/{repo}/archive/refs/tags/{ref}.zip"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def fetch_and_apply(template: str, project_path: Path) -> None:
|
|
67
|
+
"""
|
|
68
|
+
Download a GitHub repo and copy its template content into *project_path*.
|
|
69
|
+
|
|
70
|
+
Uses a Rich spinner for visual feedback during the download.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
template: The `gh:user/repo[@ref]` string.
|
|
74
|
+
project_path: The (already created) destination project directory.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
RuntimeError: on network errors, 404s, or ZIP extraction failures.
|
|
78
|
+
"""
|
|
79
|
+
import httpx
|
|
80
|
+
from rich.console import Console
|
|
81
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
82
|
+
|
|
83
|
+
console = Console()
|
|
84
|
+
user, repo, ref = parse_gh_template(template)
|
|
85
|
+
|
|
86
|
+
url = _zip_url(user, repo, ref)
|
|
87
|
+
|
|
88
|
+
with Progress(
|
|
89
|
+
SpinnerColumn(),
|
|
90
|
+
TextColumn("[progress.description]{task.description}"),
|
|
91
|
+
transient=True,
|
|
92
|
+
console=console,
|
|
93
|
+
) as progress:
|
|
94
|
+
task = progress.add_task(
|
|
95
|
+
f"[cyan]Fetching [bold]{user}/{repo}[/bold] @ {ref}…", total=None
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
zip_bytes = _download_zip(url, user, repo, ref)
|
|
99
|
+
progress.update(task, description=f"[cyan]Unpacking [bold]{user}/{repo}[/bold]…")
|
|
100
|
+
_extract_zip(zip_bytes, repo, ref, project_path)
|
|
101
|
+
|
|
102
|
+
console.print(
|
|
103
|
+
f"[green]✓ Remote template [bold]{user}/{repo}[/bold] applied.[/green]"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _download_zip(url: str, user: str, repo: str, ref: str) -> bytes:
|
|
108
|
+
"""Download the ZIP, retrying the tag URL if the branch URL returns 404."""
|
|
109
|
+
import httpx
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
response = httpx.get(url, follow_redirects=True, timeout=30)
|
|
113
|
+
if response.status_code == 404:
|
|
114
|
+
# Try interpreting the ref as a tag instead of a branch
|
|
115
|
+
tag_url = _zip_url_tag(user, repo, ref)
|
|
116
|
+
response = httpx.get(tag_url, follow_redirects=True, timeout=30)
|
|
117
|
+
response.raise_for_status()
|
|
118
|
+
return response.content
|
|
119
|
+
except httpx.HTTPStatusError as exc:
|
|
120
|
+
raise RuntimeError(
|
|
121
|
+
f"Could not download template from GitHub "
|
|
122
|
+
f"({exc.response.status_code}). "
|
|
123
|
+
f"Check that '{user}/{repo}' exists and ref '{ref}' is valid."
|
|
124
|
+
) from exc
|
|
125
|
+
except httpx.RequestError as exc:
|
|
126
|
+
raise RuntimeError(
|
|
127
|
+
f"Network error while fetching template: {exc}"
|
|
128
|
+
) from exc
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _extract_zip(
|
|
132
|
+
zip_bytes: bytes,
|
|
133
|
+
repo: str,
|
|
134
|
+
ref: str,
|
|
135
|
+
project_path: Path,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""
|
|
138
|
+
Extract the ZIP archive and copy the template content to project_path.
|
|
139
|
+
|
|
140
|
+
GitHub archives are wrapped in a top-level `<repo>-<ref>/` folder.
|
|
141
|
+
We strip that prefix, then look for an inner `template/` subdirectory.
|
|
142
|
+
If found, only its contents are copied; otherwise the whole archive root
|
|
143
|
+
is copied.
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
|
147
|
+
# Determine the top-level prefix GitHub adds (e.g. "myrepo-main/")
|
|
148
|
+
names = zf.namelist()
|
|
149
|
+
if not names:
|
|
150
|
+
raise RuntimeError("Downloaded ZIP archive is empty.")
|
|
151
|
+
|
|
152
|
+
prefix = names[0].split("/")[0] + "/"
|
|
153
|
+
|
|
154
|
+
# Decide which sub-path to extract from inside the archive
|
|
155
|
+
template_prefix = prefix + "template/"
|
|
156
|
+
has_template_dir = any(n.startswith(template_prefix) for n in names)
|
|
157
|
+
source_prefix = template_prefix if has_template_dir else prefix
|
|
158
|
+
|
|
159
|
+
# Extract matching entries into the project directory
|
|
160
|
+
for member in zf.infolist():
|
|
161
|
+
if not member.filename.startswith(source_prefix):
|
|
162
|
+
continue
|
|
163
|
+
# Compute relative path inside the project
|
|
164
|
+
relative = member.filename[len(source_prefix):]
|
|
165
|
+
if not relative:
|
|
166
|
+
continue # skip the directory entry itself
|
|
167
|
+
|
|
168
|
+
dest = project_path / relative
|
|
169
|
+
|
|
170
|
+
if member.is_dir():
|
|
171
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
else:
|
|
173
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
dest.write_bytes(zf.read(member.filename))
|
|
175
|
+
|
|
176
|
+
except zipfile.BadZipFile as exc:
|
|
177
|
+
raise RuntimeError(
|
|
178
|
+
f"Downloaded file is not a valid ZIP archive: {exc}"
|
|
179
|
+
) from exc
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from {{ project_name }}.app import create_app
|
|
3
|
+
|
|
4
|
+
@pytest.fixture
|
|
5
|
+
def app():
|
|
6
|
+
app = create_app()
|
|
7
|
+
app.config.update({
|
|
8
|
+
"TESTING": True,
|
|
9
|
+
})
|
|
10
|
+
yield app
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def client(app):
|
|
14
|
+
return app.test_client()
|
|
15
|
+
|
|
16
|
+
def test_hello(client):
|
|
17
|
+
response = client.get("/")
|
|
18
|
+
assert response.status_code == 200
|
|
19
|
+
assert response.json == {"message": "Hello, World!"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
install:
|
|
2
|
+
pip install -e ".[dev]"
|
|
3
|
+
|
|
4
|
+
test:
|
|
5
|
+
pytest
|
|
6
|
+
|
|
7
|
+
lint:
|
|
8
|
+
ruff check .
|
|
9
|
+
|
|
10
|
+
format:
|
|
11
|
+
ruff format .
|
|
12
|
+
|
|
13
|
+
run:
|
|
14
|
+
{%- if template == 'fastapi' %}
|
|
15
|
+
uvicorn src.{{ project_name }}.main:app --reload
|
|
16
|
+
{%- elif template == 'flask' %}
|
|
17
|
+
FLASK_APP=src/{{ project_name }}/app.py flask run
|
|
18
|
+
{%- elif template == 'cli' %}
|
|
19
|
+
{{ project_name }}
|
|
20
|
+
{%- else %}
|
|
21
|
+
python -m {{ project_name }}
|
|
22
|
+
{%- endif %}
|
|
23
|
+
|
|
24
|
+
clean:
|
|
25
|
+
find . -type d -name __pycache__ -exec rm -rf {} +
|
|
26
|
+
find . -type d -name .pytest_cache -exec rm -rf {} +
|
|
27
|
+
find . -type d -name .ruff_cache -exec rm -rf {} +
|
|
28
|
+
rm -rf build/ dist/ *.egg-info/
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
.PHONY: install test lint format run clean
|
|
2
|
+
|
|
3
|
+
install:
|
|
4
|
+
pip install -e ".[dev]"
|
|
5
|
+
|
|
6
|
+
test:
|
|
7
|
+
pytest
|
|
8
|
+
|
|
9
|
+
lint:
|
|
10
|
+
ruff check .
|
|
11
|
+
|
|
12
|
+
format:
|
|
13
|
+
ruff format .
|
|
14
|
+
|
|
15
|
+
run:
|
|
16
|
+
{%- if template == 'fastapi' %}
|
|
17
|
+
uvicorn src.{{ project_name }}.main:app --reload
|
|
18
|
+
{%- elif template == 'flask' %}
|
|
19
|
+
FLASK_APP=src/{{ project_name }}/app.py flask run
|
|
20
|
+
{%- elif template == 'cli' %}
|
|
21
|
+
{{ project_name }}
|
|
22
|
+
{%- else %}
|
|
23
|
+
python -m {{ project_name }}
|
|
24
|
+
{%- endif %}
|
|
25
|
+
|
|
26
|
+
clean:
|
|
27
|
+
find . -type d -name __pycache__ -exec rm -rf {} +
|
|
28
|
+
find . -type d -name .pytest_cache -exec rm -rf {} +
|
|
29
|
+
find . -type d -name .ruff_cache -exec rm -rf {} +
|
|
30
|
+
rm -rf build/ dist/ *.egg-info/
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# {{ project_name }}
|
|
2
|
+
|
|
3
|
+
{{ description }}
|
|
4
|
+
|
|
5
|
+
## Getting Started
|
|
6
|
+
|
|
7
|
+
1. Set up your environment:
|
|
8
|
+
```bash
|
|
9
|
+
{% if use_justfile %}just install{% else %}make install{% endif %}
|
|
10
|
+
```
|
|
11
|
+
{% if use_pre_commit %}
|
|
12
|
+
2. Install pre-commit hooks:
|
|
13
|
+
```bash
|
|
14
|
+
pre-commit install
|
|
15
|
+
```
|
|
16
|
+
{% endif %}
|
|
17
|
+
|
|
18
|
+
## Development
|
|
19
|
+
|
|
20
|
+
Run tests:
|
|
21
|
+
```bash
|
|
22
|
+
{% if use_justfile %}just test{% else %}make test{% endif %}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Format code:
|
|
26
|
+
```bash
|
|
27
|
+
{% if use_justfile %}just format{% else %}make format{% endif %}
|
|
28
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
3
|
+
rev: v4.4.0
|
|
4
|
+
hooks:
|
|
5
|
+
- id: trailing-whitespace
|
|
6
|
+
- id: end-of-file-fixer
|
|
7
|
+
- id: check-yaml
|
|
8
|
+
- id: check-added-large-files
|
|
9
|
+
|
|
10
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
11
|
+
rev: v0.1.5
|
|
12
|
+
hooks:
|
|
13
|
+
- id: ruff
|
|
14
|
+
args: [--fix, --exit-non-zero-on-fix]
|
|
15
|
+
- id: ruff-format
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "{{ project_name }}"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "{{ description }}"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "{{ author }}" }
|
|
13
|
+
]
|
|
14
|
+
{%- if license != 'none' %}
|
|
15
|
+
license = { text = "{{ license }}" }
|
|
16
|
+
{%- endif %}
|
|
17
|
+
|
|
18
|
+
dependencies = [
|
|
19
|
+
{%- if template == 'fastapi' %}
|
|
20
|
+
"fastapi>=0.100.0",
|
|
21
|
+
"uvicorn[standard]>=0.23.0",
|
|
22
|
+
{%- elif template == 'flask' %}
|
|
23
|
+
"flask>=3.0.0",
|
|
24
|
+
{%- elif template == 'cli' %}
|
|
25
|
+
"click>=8.0.0",
|
|
26
|
+
{%- endif %}
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
{%- if template == 'cli' %}
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
{{ project_name }} = "{{ project_name }}.cli:main"
|
|
33
|
+
{%- endif %}
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
dev = [
|
|
37
|
+
"pytest>=7.0.0",
|
|
38
|
+
{%- if template == 'fastapi' %}
|
|
39
|
+
"httpx",
|
|
40
|
+
{%- endif %}
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/{{ project_name }}"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
line-length = 88
|
|
48
|
+
target-version = "py39"
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint]
|
|
51
|
+
select = ["E", "F", "I"]
|
|
52
|
+
|
|
53
|
+
[tool.black]
|
|
54
|
+
line-length = 88
|
|
55
|
+
target-version = ['py39']
|
|
56
|
+
|
|
57
|
+
[tool.pytest.ini_options]
|
|
58
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Add your project requirements here
|
pyforge/utils.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import venv
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
console = Console()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_valid_package_name(name: str) -> bool:
|
|
10
|
+
"""Check if the given name is a valid Python package name."""
|
|
11
|
+
import keyword
|
|
12
|
+
|
|
13
|
+
if not name.isidentifier():
|
|
14
|
+
return False
|
|
15
|
+
# Python keywords can't be used as module names.
|
|
16
|
+
if keyword.iskeyword(name):
|
|
17
|
+
return False
|
|
18
|
+
return True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_virtual_environment(project_dir: Path) -> None:
|
|
22
|
+
"""Create a virtual environment in the project directory."""
|
|
23
|
+
venv_dir = project_dir / ".venv"
|
|
24
|
+
try:
|
|
25
|
+
console.print("[dim]Creating virtual environment...[/dim]")
|
|
26
|
+
venv.create(venv_dir, with_pip=True)
|
|
27
|
+
except Exception as e:
|
|
28
|
+
console.print(
|
|
29
|
+
f"[bold red]Failed to create virtual environment: {e}[/bold red]"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def init_git_repo(project_dir: Path) -> None:
|
|
34
|
+
"""Initialize a git repository and make the initial commit."""
|
|
35
|
+
try:
|
|
36
|
+
console.print("[dim]Initializing git repository...[/dim]")
|
|
37
|
+
subprocess.run(
|
|
38
|
+
["git", "init"],
|
|
39
|
+
cwd=project_dir,
|
|
40
|
+
check=True,
|
|
41
|
+
stdout=subprocess.DEVNULL,
|
|
42
|
+
stderr=subprocess.DEVNULL,
|
|
43
|
+
)
|
|
44
|
+
subprocess.run(
|
|
45
|
+
["git", "add", "."],
|
|
46
|
+
cwd=project_dir,
|
|
47
|
+
check=True,
|
|
48
|
+
stdout=subprocess.DEVNULL,
|
|
49
|
+
stderr=subprocess.DEVNULL,
|
|
50
|
+
)
|
|
51
|
+
subprocess.run(
|
|
52
|
+
["git", "commit", "-m", "Initial commit"],
|
|
53
|
+
cwd=project_dir,
|
|
54
|
+
check=True,
|
|
55
|
+
stdout=subprocess.DEVNULL,
|
|
56
|
+
stderr=subprocess.DEVNULL,
|
|
57
|
+
)
|
|
58
|
+
except subprocess.CalledProcessError as e:
|
|
59
|
+
console.print(
|
|
60
|
+
f"[bold yellow]Git initialization failed. Is git installed? (Error: {e})[/bold yellow]"
|
|
61
|
+
)
|
|
62
|
+
except FileNotFoundError:
|
|
63
|
+
console.print(
|
|
64
|
+
"[bold yellow]Git executable not found. Skipping git init.[/bold yellow]"
|
|
65
|
+
)
|