mirror of
https://github.com/github/awesome-copilot.git
synced 2026-04-11 18:55:55 +00:00
Add new skill: Python PyPI Package Builder (#1302)
* Add python-pypi-package-builder skill for Python packaging - Created `SKILL.md` defining decision-driven workflow for building, testing, versioning, and publishing Python packages. - Added reference modules covering PyPA packaging, architecture patterns, CI/CD, testing, versioning strategy, and release governance. - Implemented scaffold script to generate complete project structure with pyproject.toml, CI workflows, tests, and configuration. - Included support for multiple build backends (setuptools_scm, hatchling, flit, poetry) with clear decision rules. - Added secure release practices including tag-based versioning, branch protection, and OIDC Trusted Publishing. * fix: correct spelling issues detected by codespell
This commit is contained in:
920
skills/python-pypi-package-builder/scripts/scaffold.py
Normal file
920
skills/python-pypi-package-builder/scripts/scaffold.py
Normal file
@@ -0,0 +1,920 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
scaffold.py — Generate a production-grade Python PyPI package structure.
|
||||
|
||||
Usage:
|
||||
python scaffold.py --name my-package
|
||||
python scaffold.py --name my-package --layout src
|
||||
python scaffold.py --name my-package --build hatchling
|
||||
|
||||
Options:
|
||||
--name PyPI package name (lowercase, hyphens). Required.
|
||||
--layout 'flat' (default) or 'src'.
|
||||
--build 'setuptools' (default, uses setuptools_scm) or 'hatchling'.
|
||||
--author Author name (default: Your Name).
|
||||
--email Author email (default: you@example.com).
|
||||
--output Output directory (default: current directory).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def pkg_name(pypi_name: str) -> str:
|
||||
"""Convert 'my-pkg' → 'my_pkg'."""
|
||||
return pypi_name.replace("-", "_")
|
||||
|
||||
|
||||
def write(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(textwrap.dedent(content).lstrip(), encoding="utf-8")
|
||||
print(f" created {path}")
|
||||
|
||||
|
||||
def touch(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.touch()
|
||||
print(f" created {path}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File generators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def gen_pyproject_setuptools(name: str, mod: str, author: str, email: str, layout: str) -> str:
|
||||
packages_find = (
|
||||
'where = ["src"]' if layout == "src" else f'include = ["{mod}*"]'
|
||||
)
|
||||
pkg_data_key = f"src/{mod}" if layout == "src" else mod
|
||||
pythonpath = "" if layout == "src" else '\npythonpath = ["."]'
|
||||
return f'''\
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel", "setuptools_scm"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "{name}"
|
||||
dynamic = ["version"]
|
||||
description = "<your description>"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = {{text = "MIT"}}
|
||||
authors = [
|
||||
{{name = "{author}", email = "{email}"}},
|
||||
]
|
||||
keywords = [
|
||||
"python",
|
||||
# Add 10-15 specific keywords — they affect PyPI discoverability
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
# List your runtime dependencies here. Keep them minimal.
|
||||
# Example: "httpx>=0.24", "pydantic>=2.0"
|
||||
]
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
redis = [
|
||||
"redis>=4.2",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-asyncio>=0.21",
|
||||
"httpx>=0.24",
|
||||
"pytest-cov>=4.0",
|
||||
"ruff>=0.4",
|
||||
"black>=24.0",
|
||||
"isort>=5.13",
|
||||
"mypy>=1.0",
|
||||
"pre-commit>=3.0",
|
||||
"build",
|
||||
"twine",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/yourusername/{name}"
|
||||
Documentation = "https://github.com/yourusername/{name}#readme"
|
||||
Repository = "https://github.com/yourusername/{name}"
|
||||
"Bug Tracker" = "https://github.com/yourusername/{name}/issues"
|
||||
Changelog = "https://github.com/yourusername/{name}/blob/master/CHANGELOG.md"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
{packages_find}
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
{mod} = ["py.typed"]
|
||||
|
||||
[tool.setuptools_scm]
|
||||
version_scheme = "post-release"
|
||||
local_scheme = "no-local-version"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py310"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "C4", "PTH"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["S101"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ["py310", "py311", "py312", "py313"]
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
line_length = 100
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
ignore_missing_imports = true
|
||||
strict = false
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]{pythonpath}
|
||||
python_files = "test_*.py"
|
||||
python_classes = "Test*"
|
||||
python_functions = "test_*"
|
||||
addopts = "-v --tb=short --cov={mod} --cov-report=term-missing"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["{mod}"]
|
||||
omit = ["tests/*"]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 80
|
||||
show_missing = true
|
||||
'''
|
||||
|
||||
|
||||
def gen_pyproject_hatchling(name: str, mod: str, author: str, email: str) -> str:
|
||||
return f'''\
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "{name}"
|
||||
version = "0.1.0"
|
||||
description = "<your description>"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = {{text = "MIT"}}
|
||||
authors = [
|
||||
{{name = "{author}", email = "{email}"}},
|
||||
]
|
||||
keywords = ["python"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
# List your runtime dependencies here.
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-asyncio>=0.21",
|
||||
"httpx>=0.24",
|
||||
"pytest-cov>=4.0",
|
||||
"ruff>=0.4",
|
||||
"black>=24.0",
|
||||
"isort>=5.13",
|
||||
"mypy>=1.0",
|
||||
"pre-commit>=3.0",
|
||||
"build",
|
||||
"twine",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/yourusername/{name}"
|
||||
Changelog = "https://github.com/yourusername/{name}/blob/master/CHANGELOG.md"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["{mod}"]
|
||||
|
||||
[tool.hatch.build.targets.wheel.sources]
|
||||
"{mod}" = "{mod}"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py310"
|
||||
line-length = 100
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
disallow_untyped_defs = true
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
addopts = "-v --tb=short --cov={mod} --cov-report=term-missing"
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 80
|
||||
show_missing = true
|
||||
'''
|
||||
|
||||
|
||||
def gen_init(name: str, mod: str) -> str:
|
||||
return f'''\
|
||||
"""{name}: <one-line description>."""
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
try:
|
||||
__version__ = version("{name}")
|
||||
except PackageNotFoundError:
|
||||
__version__ = "0.0.0-dev"
|
||||
|
||||
from {mod}.core import YourClient
|
||||
from {mod}.config import YourSettings
|
||||
from {mod}.exceptions import YourPackageError
|
||||
|
||||
__all__ = [
|
||||
"YourClient",
|
||||
"YourSettings",
|
||||
"YourPackageError",
|
||||
"__version__",
|
||||
]
|
||||
'''
|
||||
|
||||
|
||||
def gen_core(mod: str) -> str:
|
||||
return f'''\
|
||||
from __future__ import annotations
|
||||
|
||||
from {mod}.exceptions import YourPackageError
|
||||
|
||||
|
||||
class YourClient:
|
||||
"""
|
||||
Main entry point for <your purpose>.
|
||||
|
||||
Args:
|
||||
api_key: Required authentication credential.
|
||||
timeout: Request timeout in seconds. Defaults to 30.
|
||||
retries: Number of retry attempts. Defaults to 3.
|
||||
|
||||
Raises:
|
||||
ValueError: If api_key is empty or timeout is non-positive.
|
||||
|
||||
Example:
|
||||
>>> from {mod} import YourClient
|
||||
>>> client = YourClient(api_key="sk-...")
|
||||
>>> result = client.process(data)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
timeout: int = 30,
|
||||
retries: int = 3,
|
||||
) -> None:
|
||||
if not api_key:
|
||||
raise ValueError("api_key must not be empty")
|
||||
if timeout <= 0:
|
||||
raise ValueError("timeout must be positive")
|
||||
self._api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.retries = retries
|
||||
|
||||
def process(self, data: dict) -> dict:
|
||||
"""
|
||||
Process data and return results.
|
||||
|
||||
Args:
|
||||
data: Input dictionary to process.
|
||||
|
||||
Returns:
|
||||
Processed result as a dictionary.
|
||||
|
||||
Raises:
|
||||
YourPackageError: If processing fails.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
'''
|
||||
|
||||
|
||||
def gen_exceptions(mod: str) -> str:
|
||||
return f'''\
|
||||
class YourPackageError(Exception):
|
||||
"""Base exception for {mod}."""
|
||||
|
||||
|
||||
class YourPackageConfigError(YourPackageError):
|
||||
"""Raised on invalid configuration."""
|
||||
'''
|
||||
|
||||
|
||||
def gen_backends_init() -> str:
|
||||
return '''\
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class BaseBackend(ABC):
|
||||
"""Abstract storage backend interface."""
|
||||
|
||||
@abstractmethod
|
||||
async def get(self, key: str) -> str | None:
|
||||
"""Retrieve a value by key. Returns None if not found."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
|
||||
"""Store a value. Optional TTL in seconds."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, key: str) -> None:
|
||||
"""Delete a key."""
|
||||
...
|
||||
'''
|
||||
|
||||
|
||||
def gen_memory_backend() -> str:
|
||||
return '''\
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from . import BaseBackend
|
||||
|
||||
|
||||
class MemoryBackend(BaseBackend):
|
||||
"""Thread-safe in-memory backend. Zero extra dependencies."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[str, tuple[str, float | None]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
async with self._lock:
|
||||
entry = self._store.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
value, expires_at = entry
|
||||
if expires_at is not None and time.time() > expires_at:
|
||||
del self._store[key]
|
||||
return None
|
||||
return value
|
||||
|
||||
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
|
||||
async with self._lock:
|
||||
expires_at = time.time() + ttl if ttl is not None else None
|
||||
self._store[key] = (value, expires_at)
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
async with self._lock:
|
||||
self._store.pop(key, None)
|
||||
'''
|
||||
|
||||
|
||||
def gen_conftest(name: str, mod: str) -> str:
|
||||
return f'''\
|
||||
import pytest
|
||||
|
||||
from {mod}.backends.memory import MemoryBackend
|
||||
from {mod}.core import YourClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_backend() -> MemoryBackend:
|
||||
return MemoryBackend()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(memory_backend: MemoryBackend) -> YourClient:
|
||||
return YourClient(
|
||||
api_key="test-key",
|
||||
backend=memory_backend,
|
||||
)
|
||||
'''
|
||||
|
||||
|
||||
def gen_test_core(mod: str) -> str:
|
||||
return f'''\
|
||||
import pytest
|
||||
|
||||
from {mod} import YourClient
|
||||
from {mod}.exceptions import YourPackageError
|
||||
|
||||
|
||||
def test_client_creates_with_valid_key() -> None:
|
||||
client = YourClient(api_key="sk-test")
|
||||
assert client is not None
|
||||
|
||||
|
||||
def test_client_raises_on_empty_key() -> None:
|
||||
with pytest.raises(ValueError, match="api_key"):
|
||||
YourClient(api_key="")
|
||||
|
||||
|
||||
def test_client_raises_on_invalid_timeout() -> None:
|
||||
with pytest.raises(ValueError, match="timeout"):
|
||||
YourClient(api_key="sk-test", timeout=-1)
|
||||
'''
|
||||
|
||||
|
||||
def gen_test_backends() -> str:
|
||||
return '''\
|
||||
import pytest
|
||||
from your_package.backends.memory import MemoryBackend
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_and_get() -> None:
|
||||
backend = MemoryBackend()
|
||||
await backend.set("key1", "value1")
|
||||
result = await backend.get("key1")
|
||||
assert result == "value1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_missing_key_returns_none() -> None:
|
||||
backend = MemoryBackend()
|
||||
result = await backend.get("nonexistent")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_removes_key() -> None:
|
||||
backend = MemoryBackend()
|
||||
await backend.set("key1", "value1")
|
||||
await backend.delete("key1")
|
||||
result = await backend.get("key1")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_keys_are_independent() -> None:
|
||||
backend = MemoryBackend()
|
||||
await backend.set("key1", "a")
|
||||
await backend.set("key2", "b")
|
||||
assert await backend.get("key1") == "a"
|
||||
assert await backend.get("key2") == "b"
|
||||
'''
|
||||
|
||||
|
||||
def gen_ci_yml(name: str, mod: str) -> str:
|
||||
return f'''\
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint, Format & Type Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install dev dependencies
|
||||
run: pip install -e ".[dev]"
|
||||
- name: ruff
|
||||
run: ruff check .
|
||||
- name: black
|
||||
run: black . --check
|
||||
- name: isort
|
||||
run: isort . --check-only
|
||||
- name: mypy
|
||||
run: mypy {mod}/
|
||||
|
||||
test:
|
||||
name: Test (Python ${{{{ matrix.python-version }}}})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{{{ matrix.python-version }}}}
|
||||
- name: Install dependencies
|
||||
run: pip install -e ".[dev]"
|
||||
- name: Run tests with coverage
|
||||
run: pytest --cov --cov-report=xml
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
fail_ci_if_error: false
|
||||
'''
|
||||
|
||||
|
||||
def gen_publish_yml() -> str:
|
||||
return '''\
|
||||
name: Publish to PyPI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build distribution
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install build tools
|
||||
run: pip install build twine
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
- name: Check distribution
|
||||
run: twine check dist/*
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
publish:
|
||||
name: Publish to PyPI
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
'''
|
||||
|
||||
|
||||
def gen_precommit() -> str:
|
||||
return '''\
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.4.4
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 24.4.2
|
||||
hooks:
|
||||
- id: black
|
||||
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.13.2
|
||||
hooks:
|
||||
- id: isort
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.10.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-toml
|
||||
- id: check-merge-conflict
|
||||
- id: debug-statements
|
||||
- id: no-commit-to-branch
|
||||
args: [--branch, master, --branch, main]
|
||||
'''
|
||||
|
||||
|
||||
def gen_changelog(name: str) -> str:
|
||||
return f'''\
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Initial project scaffold
|
||||
|
||||
[Unreleased]: https://github.com/yourusername/{name}/commits/master
|
||||
'''
|
||||
|
||||
|
||||
def gen_readme(name: str, mod: str) -> str:
|
||||
return f'''\
|
||||
# {name}
|
||||
|
||||
> One-line description — what it does and why it's useful.
|
||||
|
||||
[](https://pypi.org/project/{name}/)
|
||||
[](https://pypi.org/project/{name}/)
|
||||
[](LICENSE)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install {name}
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from {mod} import YourClient
|
||||
|
||||
client = YourClient(api_key="sk-...")
|
||||
result = client.process({{"input": "value"}})
|
||||
print(result)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| api_key | str | required | Authentication credential |
|
||||
| timeout | int | 30 | Request timeout in seconds |
|
||||
| retries | int | 3 | Number of retry attempts |
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md)
|
||||
|
||||
## Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md)
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](./LICENSE)
|
||||
'''
|
||||
|
||||
|
||||
def gen_setup_py() -> str:
|
||||
return '''\
|
||||
# Thin shim for legacy editable install compatibility.
|
||||
# All configuration lives in pyproject.toml.
|
||||
from setuptools import setup
|
||||
|
||||
setup()
|
||||
'''
|
||||
|
||||
|
||||
def gen_license(author: str) -> str:
|
||||
return f'''\
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 {author}
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main scaffold
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def scaffold(
|
||||
name: str,
|
||||
layout: str,
|
||||
build: str,
|
||||
author: str,
|
||||
email: str,
|
||||
output: str,
|
||||
) -> None:
|
||||
mod = pkg_name(name)
|
||||
root = Path(output) / name
|
||||
pkg_root = root / "src" / mod if layout == "src" else root / mod
|
||||
|
||||
print(f"\nScaffolding {name!r} ({layout} layout, {build} build backend)\n")
|
||||
|
||||
# Package source
|
||||
touch(pkg_root / "py.typed")
|
||||
write(pkg_root / "__init__.py", gen_init(name, mod))
|
||||
write(pkg_root / "core.py", gen_core(mod))
|
||||
write(pkg_root / "exceptions.py", gen_exceptions(mod))
|
||||
write(pkg_root / "backends" / "__init__.py", gen_backends_init())
|
||||
write(pkg_root / "backends" / "memory.py", gen_memory_backend())
|
||||
|
||||
# Tests
|
||||
write(root / "tests" / "__init__.py", "")
|
||||
write(root / "tests" / "conftest.py", gen_conftest(name, mod))
|
||||
write(root / "tests" / "test_core.py", gen_test_core(mod))
|
||||
write(root / "tests" / "test_backends.py", gen_test_backends())
|
||||
|
||||
# CI
|
||||
write(root / ".github" / "workflows" / "ci.yml", gen_ci_yml(name, mod))
|
||||
write(root / ".github" / "workflows" / "publish.yml", gen_publish_yml())
|
||||
write(
|
||||
root / ".github" / "ISSUE_TEMPLATE" / "bug_report.md",
|
||||
"""\
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a reproducible bug
|
||||
labels: bug
|
||||
---
|
||||
|
||||
**Python version:**
|
||||
**Package version:**
|
||||
|
||||
**Describe the bug:**
|
||||
|
||||
**Minimal reproducible example:**
|
||||
```python
|
||||
# paste here
|
||||
```
|
||||
|
||||
**Expected behavior:**
|
||||
|
||||
**Actual behavior:**
|
||||
""",
|
||||
)
|
||||
write(
|
||||
root / ".github" / "ISSUE_TEMPLATE" / "feature_request.md",
|
||||
"""\
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
**Problem this would solve:**
|
||||
|
||||
**Proposed solution:**
|
||||
|
||||
**Alternatives considered:**
|
||||
""",
|
||||
)
|
||||
|
||||
# Config files
|
||||
write(root / ".pre-commit-config.yaml", gen_precommit())
|
||||
write(root / "CHANGELOG.md", gen_changelog(name))
|
||||
write(root / "README.md", gen_readme(name, mod))
|
||||
write(root / "LICENSE", gen_license(author))
|
||||
|
||||
# pyproject.toml + setup.py
|
||||
if build == "setuptools":
|
||||
write(root / "pyproject.toml", gen_pyproject_setuptools(name, mod, author, email, layout))
|
||||
write(root / "setup.py", gen_setup_py())
|
||||
else:
|
||||
write(root / "pyproject.toml", gen_pyproject_hatchling(name, mod, author, email))
|
||||
|
||||
# .gitignore
|
||||
write(
|
||||
root / ".gitignore",
|
||||
"""\
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
*.egg
|
||||
.env
|
||||
.venv
|
||||
venv/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
cov_annotate/
|
||||
*.xml
|
||||
""",
|
||||
)
|
||||
|
||||
print(f"\nDone! Created {root.resolve()}")
|
||||
print("\nNext steps:")
|
||||
print(f" cd {name}")
|
||||
print(" git init && git add .")
|
||||
print(' git commit -m "chore: initial scaffold"')
|
||||
print(" pip install -e '.[dev]'")
|
||||
print(" pre-commit install")
|
||||
print(" pytest")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Scaffold a production-grade Python PyPI package."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
required=True,
|
||||
help="PyPI package name (lowercase, hyphens). Example: my-package",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--layout",
|
||||
choices=["flat", "src"],
|
||||
default="flat",
|
||||
help="Project layout: 'flat' (default) or 'src'.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--build",
|
||||
choices=["setuptools", "hatchling"],
|
||||
default="setuptools",
|
||||
help="Build backend: 'setuptools' (default, uses setuptools_scm) or 'hatchling'.",
|
||||
)
|
||||
parser.add_argument("--author", default="Your Name", help="Author name.")
|
||||
parser.add_argument("--email", default="you@example.com", help="Author email.")
|
||||
parser.add_argument("--output", default=".", help="Output directory (default: .).")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate name
|
||||
import re
|
||||
if not re.match(r"^[a-z][a-z0-9\-]*$", args.name):
|
||||
print(
|
||||
f"Error: --name must be lowercase letters, digits, and hyphens only. Got: {args.name!r}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
target = Path(args.output) / args.name
|
||||
if target.exists():
|
||||
print(f"Error: {target} already exists.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
scaffold(
|
||||
name=args.name,
|
||||
layout=args.layout,
|
||||
build=args.build,
|
||||
author=args.author,
|
||||
email=args.email,
|
||||
output=args.output,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user