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/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,3 @@
1
+ def test_example():
2
+ """Example test that always passes."""
3
+ assert True
@@ -0,0 +1,4 @@
1
+ from {{ project_name }}.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,7 @@
1
+ import click
2
+
3
+ @click.command()
4
+ @click.option("--name", default="World", help="Who to greet.")
5
+ def main(name: str):
6
+ """Simple CLI entrypoint."""
7
+ click.echo(f"Hello, {name}!")
@@ -0,0 +1,8 @@
1
+ from click.testing import CliRunner
2
+ from {{ project_name }}.cli import main
3
+
4
+ def test_main():
5
+ runner = CliRunner()
6
+ result = runner.invoke(main, ["--name", "Test"])
7
+ assert result.exit_code == 0
8
+ assert "Hello, Test!" in result.output
@@ -0,0 +1,7 @@
1
+ from fastapi import FastAPI
2
+
3
+ app = FastAPI(title="{{ project_name }}")
4
+
5
+ @app.get("/")
6
+ def read_root():
7
+ return {"message": "Hello, World!"}
@@ -0,0 +1,9 @@
1
+ from fastapi.testclient import TestClient
2
+ from {{ project_name }}.main import app
3
+
4
+ client = TestClient(app)
5
+
6
+ def test_read_root():
7
+ response = client.get("/")
8
+ assert response.status_code == 200
9
+ assert response.json() == {"message": "Hello, World!"}
@@ -0,0 +1,10 @@
1
+ from flask import Flask
2
+
3
+ def create_app():
4
+ app = Flask(__name__)
5
+
6
+ @app.route("/")
7
+ def hello():
8
+ return {"message": "Hello, World!"}
9
+
10
+ return app
@@ -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,5 @@
1
+ # Environment variables — copy to .env and fill in values
2
+ # Do NOT commit .env to version control
3
+
4
+ # DATABASE_URL=postgresql://user:password@localhost:5432/{{ project_name }}
5
+ # SECRET_KEY=your-secret-key-here
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .venv/
7
+ env/
8
+ venv/
9
+ ENV/
10
+ .env
11
+ build/
12
+ dist/
13
+ *.egg-info/
@@ -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
@@ -0,0 +1,3 @@
1
+ """
2
+ {{ project_name }}
3
+ """
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
+ )