From a9ba98f1fd8c828a090f87213e11adcd8d0a8e2f Mon Sep 17 00:00:00 2001 From: Joseph Fernando Date: Sun, 24 May 2026 16:48:13 +0800 Subject: [PATCH] feat: update project structure and documentation --- .github/workflows/ci.yml | 118 +++++++++ .github/workflows/release.yml | 111 ++++++++ .github/workflows/workflow.yml | 66 ----- .gitignore | 3 +- CONTRIBUTING.md | 245 ++++++++++++++++++ README.md | 220 ++++++++++++++-- auto_gen_py_project/__init__.py | 2 +- auto_gen_py_project/build_system/__init__.py | 101 ++++++++ auto_gen_py_project/build_system/cli.py | 77 ++++++ auto_gen_py_project/build_system/dag.py | 40 +++ .../build_system/exceptions.py | 14 + auto_gen_py_project/build_system/registry.py | 31 +++ auto_gen_py_project/build_system/runner.py | 46 ++++ auto_gen_py_project/build_system/task.py | 24 ++ auto_gen_py_project/generator.py | 55 ++++ build.py | 75 ++++++ setup.py | 3 +- tests/test_build_system.py | 225 ++++++++++++++++ tests/test_generator.py | 6 +- tests/test_main.py | 16 +- 20 files changed, 1386 insertions(+), 92 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/workflow.yml create mode 100644 CONTRIBUTING.md create mode 100644 auto_gen_py_project/build_system/__init__.py create mode 100644 auto_gen_py_project/build_system/cli.py create mode 100644 auto_gen_py_project/build_system/dag.py create mode 100644 auto_gen_py_project/build_system/exceptions.py create mode 100644 auto_gen_py_project/build_system/registry.py create mode 100644 auto_gen_py_project/build_system/runner.py create mode 100644 auto_gen_py_project/build_system/task.py create mode 100644 build.py create mode 100644 tests/test_build_system.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..48d3c18 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,118 @@ +name: CI + +# Runs on every Git Flow branch type so quality gates are enforced everywhere: +# feature/* — new features branched from develop +# release/* — release-prep branches branched from develop +# hotfix/* — production patch branches branched from main +# develop — integration branch (also PR target for features) +# main — production branch (PR target for release/* and hotfix/*) + +on: + push: + branches: + - develop + - "feature/**" + - "release/**" + - "hotfix/**" + pull_request: + branches: + - main + - develop + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ------------------------------------------------------------------ + # 1. Test matrix — Python 3.9 / 3.11 / 3.13 + # ------------------------------------------------------------------ + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + + strategy: + fail-fast: true + matrix: + python-version: ["3.9", "3.11", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install package and test deps + run: | + python -m pip install --upgrade pip + pip install -e . + pip install pytest + + # Run only the fast build-system tests in CI. + # test_generator.py creates real venvs per test (~10 s each); + # run it locally before opening a PR: python -m pytest tests/ + - name: Run build-system tests + run: python -m pytest tests/test_build_system.py -v --tb=short + + # ------------------------------------------------------------------ + # 2. Build verification — confirms the package builds and passes + # twine's metadata checks before any merge + # ------------------------------------------------------------------ + build-check: + name: Build verification + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + cache: pip + + - name: Install build tooling + run: python -m pip install build twine + + - name: Build distributions + run: python -m build + + - name: Verify distributions with twine + run: python -m twine check dist/* + + # ------------------------------------------------------------------ + # 3. Release-branch extra check — version consistency gate. + # Only runs on release/* and hotfix/* to catch version drift + # before a merge to main triggers the PyPI publish. + # ------------------------------------------------------------------ + version-check: + name: Version consistency + runs-on: ubuntu-latest + if: | + startsWith(github.ref, 'refs/heads/release/') || + startsWith(github.ref, 'refs/heads/hotfix/') || + (github.event_name == 'pull_request' && github.base_ref == 'main') + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Check version consistency across files + run: | + INIT_VER=$(python -c "import re, pathlib; m=re.search(r'__version__\s*=\s*\"([^\"]+)\"', pathlib.Path('auto_gen_py_project/__init__.py').read_text()); print(m.group(1))") + SETUP_VER=$(python -c "import re, pathlib; m=re.search(r'version=\"([^\"]+)\"', pathlib.Path('setup.py').read_text()); print(m.group(1))") + echo " __init__.py : $INIT_VER" + echo " setup.py : $SETUP_VER" + if [ "$INIT_VER" != "$SETUP_VER" ]; then + echo "ERROR: Version mismatch between __init__.py ($INIT_VER) and setup.py ($SETUP_VER)" + exit 1 + fi + echo "OK: versions match ($INIT_VER)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..339c53e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,111 @@ +name: Release & Publish + +# Triggered when a GitHub Release is published. +# By Git Flow convention a release is created only after a release/* or +# hotfix/* branch is merged into main and the commit is tagged. +# +# Workflow: +# 1. Build source distribution + wheel. +# 2. Publish to PyPI via OIDC Trusted Publisher (no API token required). +# +# Required GitHub settings: +# - Environment named "pypi" with a Trusted Publisher configured at +# https://pypi.org/manage/account/publishing/ +# - id-token: write permission (granted below per job) + +on: + release: + types: [published] + +permissions: + contents: read + +concurrency: + group: release-${{ github.event.release.tag_name }} + cancel-in-progress: false + +env: + PYTHON_VERSION: "3.x" + DIST_DIR: dist + +jobs: + # ------------------------------------------------------------------ + # 1. Run tests one final time on the tagged commit before publishing + # ------------------------------------------------------------------ + pre-publish-test: + name: Pre-publish tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + + - name: Install package and test deps + run: | + python -m pip install --upgrade pip + pip install -e . + pip install pytest + + - name: Run tests + run: python -m pytest tests/test_build_system.py -v --tb=short + + # ------------------------------------------------------------------ + # 2. Build distributions + # ------------------------------------------------------------------ + release-build: + name: Build distributions + runs-on: ubuntu-latest + needs: pre-publish-test + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + + - name: Build release distributions + run: | + python -m pip install build twine + python -m build + python -m twine check dist/* + + - name: Upload distributions + uses: actions/upload-artifact@v4 + with: + name: release-dists + path: ${{ env.DIST_DIR }}/ + + # ------------------------------------------------------------------ + # 3. Publish to PyPI + # ------------------------------------------------------------------ + pypi-publish: + name: Publish to PyPI + runs-on: ubuntu-latest + needs: release-build + + permissions: + id-token: write + + environment: + name: pypi + url: https://pypi.org/project/auto-gen-py-project/${{ github.event.release.tag_name }} + + steps: + - name: Retrieve release distributions + uses: actions/download-artifact@v4 + with: + name: release-dists + path: ${{ env.DIST_DIR }}/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: ${{ env.DIST_DIR }}/ diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml deleted file mode 100644 index c57cb2c..0000000 --- a/.github/workflows/workflow.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: Auto Generate Python Project - -on: - release: - types: [published] - -permissions: - contents: read - -concurrency: - # Prevent overlapping publish runs for the same release tag. - group: release-${{ github.event.release.tag_name }} - cancel-in-progress: false - -env: - PYTHON_VERSION: "3.x" - DIST_DIR: dist - -jobs: - release-build: - name: Build distributions - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: ${{ env.PYTHON_VERSION }} - - - name: Build release distributions - run: | - python -m pip install build - python -m build - - - name: Upload distributions - uses: actions/upload-artifact@v4 - with: - name: release-dists - path: ${{ env.DIST_DIR }}/ - - pypi-publish: - name: Publish to PyPI - runs-on: ubuntu-latest - needs: release-build - - permissions: - id-token: write - - environment: - name: pypi - url: https://pypi.org/project/auto-gen-py-project/${{ github.event.release.tag_name }} - - steps: - - name: Retrieve release distributions - uses: actions/download-artifact@v4 - with: - name: release-dists - path: ${{ env.DIST_DIR }}/ - - - name: Publish release distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - packages-dir: ${{ env.DIST_DIR }}/ diff --git a/.gitignore b/.gitignore index 7bb7af6..4e738b5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ auto_gen_py_project.egg-info/ *.pyc .env -CLAUDE.md \ No newline at end of file +CLAUDE.md +.claude/ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..66d07b9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,245 @@ +# Contributing to Auto Gen Py Project + +Thank you for considering a contribution. This project uses **Git Flow** for branch management. + +--- + +## Branch Model + +``` +main ──────────────────────────────────────────────────── production + │ ▲ + │ (hotfix/* merged into both main and develop) │ + │ │ +develop ────────────────────────────────────────────── integration + │ ▲ ▲ + │ feature/* │ release/* │ + └──────────────►─┘ │ + └── merged into main + develop +``` + +| Branch | Branched from | Merges into | Purpose | +|---|---|---|---| +| `main` | — | — | Production-ready code only. Every commit is a release. | +| `develop` | `main` | — | Latest development. Default branch for feature work. | +| `feature/*` | `develop` | `develop` | New functionality. One branch per feature. | +| `release/*` | `develop` | `main` + `develop` | Release preparation — version bump, changelog, final fixes. | +| `hotfix/*` | `main` | `main` + `develop` | Urgent production patches. Bypasses the feature/release cycle. | + +--- + +## Day-to-Day Workflow + +### 1. Feature development + +```bash +# Start a feature from develop +git checkout develop +git pull origin develop +git checkout -b feature/my-feature + +# ... do work, commit often ... + +# Push and open a PR targeting develop +git push -u origin feature/my-feature +# → GitHub PR: base = develop +``` + +CI runs automatically on `feature/*` pushes and on the PR. The PR requires: +- All CI checks green (`Test`, `Build verification`) +- At least one approving review +- Branch up-to-date with `develop` + +Once merged, delete the feature branch. + +--- + +### 2. Preparing a release + +```bash +# Cut a release branch from develop +git checkout develop +git pull origin develop +git checkout -b release/0.3.0 + +# Bump versions +# - auto_gen_py_project/__init__.py → __version__ = "0.3.0" +# - setup.py → version="0.3.0" + +git commit -am "chore: bump version to 0.3.0" +git push -u origin release/0.3.0 +``` + +On the release branch you may: +- Fix release-blocking bugs (no new features) +- Update `README.md` version history +- Tweak packaging metadata + +The `version-check` CI job will fail if `__init__.py` and `setup.py` disagree on the version. + +#### Finishing the release + +```bash +# 1. Merge into main +git checkout main +git merge --no-ff release/0.3.0 -m "release: merge release/0.3.0 into main" +git tag -a v0.3.0 -m "Release 0.3.0" +git push origin main --tags + +# 2. Back-merge into develop to stay in sync +git checkout develop +git merge --no-ff release/0.3.0 -m "chore: back-merge release/0.3.0 into develop" +git push origin develop + +# 3. Delete the release branch +git branch -d release/0.3.0 +git push origin --delete release/0.3.0 + +# 4. Publish a GitHub Release from the tag → triggers the publish workflow +# GitHub UI: Releases → Draft a new release → choose tag v0.3.0 +``` + +--- + +### 3. Hotfixes + +```bash +# Branch from main (NOT develop) +git checkout main +git pull origin main +git checkout -b hotfix/fix-crash + +# ... fix the bug, bump the patch version ... +# auto_gen_py_project/__init__.py → __version__ = "0.2.1" +# setup.py → version="0.2.1" + +git commit -am "fix: resolve crash on empty project name" +git push -u origin hotfix/fix-crash +# → GitHub PR: base = main +``` + +Once reviewed and merged: + +```bash +# Tag the fix on main +git checkout main +git pull origin main +git tag -a v0.2.1 -m "Hotfix 0.2.1" +git push origin main --tags + +# Back-merge into develop +git checkout develop +git merge --no-ff hotfix/fix-crash -m "chore: back-merge hotfix/fix-crash into develop" +git push origin develop + +# Delete the hotfix branch +git branch -d hotfix/fix-crash +git push origin --delete hotfix/fix-crash + +# Publish a GitHub Release from the tag → triggers the publish workflow +``` + +--- + +## Version Numbering + +This project uses **semantic versioning** (`MAJOR.MINOR.PATCH`): + +| Change type | Version bump | Example | +|---|---|---| +| New feature (backward-compatible) | MINOR | `0.2.0 → 0.3.0` | +| Bug fix or small improvement | PATCH | `0.2.0 → 0.2.1` | +| Breaking API change | MAJOR | `0.x → 1.0.0` | + +Version must be identical in **both** of these files before a release branch is merged: + +``` +auto_gen_py_project/__init__.py __version__ = "X.Y.Z" +setup.py version="X.Y.Z" +``` + +The `version-check` CI job enforces this automatically on `release/*`, `hotfix/*`, and PRs to `main`. + +--- + +## CI Overview + +| Workflow | Trigger | Jobs | +|---|---|---| +| `ci.yml` | push to `develop`/`feature/*`/`release/*`/`hotfix/*`; PR to `main`/`develop` | Test (3.9/3.11/3.13), Build verification, Version check (release/hotfix only) | +| `release.yml` | GitHub Release published | Pre-publish test, Build distributions, Publish to PyPI | + +--- + +## Recommended Branch Protections (GitHub Settings) + +### `main` +- Require pull request before merging +- Require 1 approving review +- Require status checks: `Test (3.9)`, `Test (3.11)`, `Test (3.13)`, `Build verification`, `Version consistency` +- Require branches to be up-to-date before merging +- Do not allow force-pushes +- Do not allow deletions + +### `develop` +- Require pull request before merging +- Require 1 approving review +- Require status checks: `Test (3.9)`, `Test (3.11)`, `Test (3.13)`, `Build verification` +- Require branches to be up-to-date before merging +- Do not allow force-pushes + +--- + +## Running Checks Locally + +```bash +# Install in editable mode (includes pybuild entry point) +pip install -e . +pip install pytest + +# Run the build-system test suite (fast — no venv creation) +python -m pytest tests/test_build_system.py -v + +# Run the full suite including slow generator tests +python -m pytest tests/ -v + +# Use pybuild to run the project's own task pipeline +pybuild --list +pybuild test +pybuild build + +# Check version consistency before opening a PR to main +python -c " +import re, pathlib +iv = re.search(r'__version__\s*=\s*\"([^\"]+)\"', pathlib.Path('auto_gen_py_project/__init__.py').read_text()).group(1) +sv = re.search(r'version=\"([^\"]+)\"', pathlib.Path('setup.py').read_text()).group(1) +assert iv == sv, f'Version mismatch: __init__.py={iv}, setup.py={sv}' +print(f'Versions match: {iv}') +" +``` + +--- + +## Rollback a Bad Release + +```bash +# 1. Yank the broken version on PyPI (marks it as "avoid" without deleting) +pip install twine +twine yank auto-gen-py-project==X.Y.Z --reason "critical bug — use X.Y.W instead" + +# 2. Delete the GitHub Release (UI) or via CLI +gh release delete vX.Y.Z --yes + +# 3. Delete the tag locally and remotely +git tag -d vX.Y.Z +git push origin --delete vX.Y.Z + +# 4. Create a hotfix/* branch from main to fix the issue, then re-release +``` + +--- + +## Questions or Ideas? + +Open an issue with the `enhancement` or `question` tag. +All contributions are welcome and appreciated. diff --git a/README.md b/README.md index c14dad7..49d0a15 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # Auto Gen Py Project -Simple CLI tool that scaffolds a clean Python project structure with modern packaging files, tests, and a local virtual environment. +Simple CLI tool that scaffolds a clean Python project structure — with a built-in Gradle-inspired build system (`pybuild`). ## Description -`auto-gen-py-project` helps you start Python projects faster by generating a standards-aligned layout out of the box. It creates a project with `src/`, tests, `run.py`, `pyproject.toml`, `.gitignore`, `LICENSE`, and a local `.venv` directory inside the generated project folder. The goal is to remove repetitive setup so you can focus on implementation. +`auto-gen-py-project` helps you start Python projects faster by generating a standards-aligned layout out of the box. It creates a project with `src/`, tests, `run.py`, `pyproject.toml`, `.gitignore`, `LICENSE`, a local `.venv`, and a ready-to-use `build.py` that wires up `pybuild` — a Gradle-inspired task runner for Python. + +`pybuild` lets you define tasks with dependencies (a DAG) and execute them in the correct order from a simple `build.py` file, just like Gradle's `build.gradle`. ## Getting Started @@ -16,14 +18,14 @@ Simple CLI tool that scaffolds a clean Python project structure with modern pack ### Installing -- Clone or download this repository: +Clone or download this repository: ```bash git clone https://github.com/axcel-blade/auto-gen-py-project.git cd auto-gen-py-project ``` -- Install the CLI locally: +Install the CLI (includes `pybuild`): ```bash python -m pip install --upgrade pip build @@ -32,28 +34,25 @@ python -m pip install . ### Executing program -- Create a new project folder: +Create a new project folder: ```bash auto-gen-py-project my_project ``` -- Initialize in the current folder: +Initialize in the current folder: ```bash auto-gen-py-project my_project --init ``` -- If command is not on your `PATH`, use module mode: +If the command is not on your `PATH`, use module mode: ```bash python -m auto_gen_py_project my_project python -m auto_gen_py_project my_project --init ``` -- Trigger package publish to PyPI: - - Publish a GitHub release (uses `.github/workflows/workflow.yml`) - Generated project structure: ```text @@ -63,6 +62,7 @@ my_project/ │ └── main.py ├── tests/ │ └── test_main.py +├── build.py ← pybuild task definitions ├── .venv/ ├── run.py ├── README.md @@ -71,12 +71,95 @@ my_project/ └── .gitignore ``` +--- + +## pybuild — Gradle-like Build System + +Every generated project includes a `build.py` with pre-wired tasks. The `pybuild` CLI executes them in dependency order. + +### Defining tasks + +```python +# build.py +from auto_gen_py_project.build_system import task, run_task + +# Decorator style +@task +def clean(): + """Remove build artefacts.""" + shutil.rmtree("dist", ignore_errors=True) + +@task(depends_on=["clean"]) +def test(): + """Run the test suite.""" + subprocess.run(["pytest"], check=True) + +@task(depends_on=["test"]) +def build(): + """Build wheel and sdist.""" + subprocess.run(["python", "-m", "build"], check=True) + +# --- or Gradle-style function calls --- +task("clean", action=clean_fn) +task("test", depends_on=["clean"], action=test_fn) +task("build", depends_on=["test"], action=build_fn) +``` + +### Running tasks + +```bash +pybuild build # runs: clean → test → build +pybuild test # runs: clean → test +pybuild clean test # explicit sequence +pybuild --list # list all tasks and their dependencies +pybuild --quiet build # suppress output +pybuild -f path/to/build.py build # custom build file +python build.py build # without installing pybuild +``` + +Example output (Gradle-style): + +``` +> Build: 3 task(s) to execute + +> Task :clean + [DONE] 0.01s + +> Task :test + [DONE] 1.23s + +> Task :build + [DONE] 0.87s + +BUILD SUCCESSFUL in 2.11s +3 actionable task(s): 3 executed +``` + +### How it works + +- Tasks form a **directed acyclic graph (DAG)**. `pybuild` resolves the correct execution order via topological sort. +- Cycles are detected and reported with the full dependency path. +- Each task name is unique within a build file; re-registering overwrites the previous definition. +- Tasks with no action are valid placeholders (lifecycle hooks). + +### Error handling + +| Scenario | Exception raised | +|---|---| +| Requested task does not exist | `TaskNotFoundError` | +| Dependency references missing task | `TaskNotFoundError` | +| Cycle in dependency graph | `CyclicDependencyError` | +| Task action raises an exception | `TaskExecutionError` | + +--- + ## Help For command options and usage help: ```bash python -m auto_gen_py_project --help +pybuild --help ``` If installation fails, check: @@ -86,22 +169,123 @@ python --version python -m pip --version ``` +Common issues: + +| Problem | Fix | +|---|---| +| `pybuild: command not found` | Run `pip install -e .` or use `python build.py ` | +| `TaskNotFoundError` | Check spelling; run `pybuild --list` to see available tasks | +| `CyclicDependencyError` | Review `depends_on` chains in your `build.py` for loops | +| Build file not found | Run `pybuild` from the directory containing `build.py`, or pass `-f path/to/build.py` | + +--- + ## Authors -- Axcel Blade +- Axcel Blade — [srikanthfernando3@gmail.com](mailto:srikanthfernando3@gmail.com) ## Contributing -Contributions are welcome and appreciated. +Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. -1. Fork the project -2. Create your branch (`git checkout -b feature/AmazingFeature`) -3. Commit your changes (`git commit -m "Add some AmazingFeature"`) -4. Push your branch (`git push origin feature/AmazingFeature`) -5. Open a pull request +This project uses **Git Flow**. The short version: + +``` +main ← production, tagged releases only +develop ← default integration branch, base for all features +feature/* ← one branch per feature, PR → develop +release/* ← release prep (version bump, changelog), PR → main then back-merge → develop +hotfix/* ← urgent production patches, PR → main then back-merge → develop +``` + +Full details — commands, PR rules, branch protections, rollback — in [CONTRIBUTING.md](CONTRIBUTING.md). + +Don't forget to give the project a star! Thanks again! + +Top contributors: + + + contrib.rocks image + + +

(back to top)

+ +## Local development + +```bash +# Install in editable mode (registers pybuild CLI) +pip install -e . + +# Run tests +python -m pytest tests/test_build_system.py -v # fast (no venv creation) +python -m pytest tests/ -v # full suite (slow — creates venvs) + +# Use pybuild task pipeline on this project +pybuild --list +pybuild test +pybuild build +``` + +### CI / CD overview + +| Workflow | File | Trigger | What it does | +|---|---|---|---| +| CI | `ci.yml` | push to `develop`/`feature/*`/`release/*`/`hotfix/*`; PR to `main`/`develop` | Tests (3.9/3.11/3.13), build check, version consistency | +| Release & Publish | `release.yml` | GitHub Release published | Tests, build wheel+sdist, publish to PyPI via OIDC | + +### Required GitHub settings + +- **PyPI Trusted Publisher** — set up at [pypi.org/manage/account/publishing](https://pypi.org/manage/account/publishing/); no `TWINE_PASSWORD` secret needed. +- **Environment named `pypi`** in repository Settings → Environments. +- **Branch protection on `main`**: require `Test (3.9)`, `Test (3.11)`, `Test (3.13)`, `Build verification`, `Version consistency` + 1 review + up-to-date branch. +- **Branch protection on `develop`**: require `Test (*)`, `Build verification` + 1 review. + +### Release process (Git Flow) + +```bash +# 1. Cut a release branch from develop +git checkout develop && git pull +git checkout -b release/0.3.0 + +# 2. Bump version in __init__.py and setup.py, update CONTRIBUTING.md / README.md + +# 3. Merge into main and tag +git checkout main && git merge --no-ff release/0.3.0 +git tag -a v0.3.0 -m "Release 0.3.0" +git push origin main --tags + +# 4. Back-merge into develop +git checkout develop && git merge --no-ff release/0.3.0 +git push origin develop + +# 5. Publish a GitHub Release from the tag → release.yml handles PyPI +``` + +### Rollback a bad release + +```bash +# Yank on PyPI (marks as "avoid" — does not delete) +twine yank auto-gen-py-project==X.Y.Z --reason "describe the issue" + +# Remove GitHub Release and tag +gh release delete vX.Y.Z --yes +git tag -d vX.Y.Z && git push origin --delete vX.Y.Z + +# Fix via hotfix/* branch, then re-release with a patch version +``` ## Version History +- 0.2.0 + - Adopt Git Flow branching model (`main` / `develop` / `feature/*` / `release/*` / `hotfix/*`) + - Add Gradle-inspired build system (`pybuild` CLI + `auto_gen_py_project.build_system` package) + - Generated projects include a ready-to-use `build.py` with `clean → lint → test → build` tasks + - DAG-based task dependency resolution with cycle detection + - Three task registration styles: bare decorator, decorator with options, function-call DSL + - Add CI workflow (`ci.yml`) — matrix builds on Python 3.9/3.11/3.13 + version-consistency check + - Replace `workflow.yml` with `release.yml` — adds pre-publish test gate + - Add `CONTRIBUTING.md` with full Git Flow commands, branch protection config, rollback guide + - 23 new tests covering Task, Registry, DAG, Runner, and DSL - 0.1.3 - Align package metadata and version across project files - Add `auto-gen-py-project` console command entry point @@ -114,4 +298,4 @@ Contributions are welcome and appreciated. ## License -This project is licensed under the GNU General Public License v3 - see the `LICENSE` file for details. +This project is licensed under the GNU General Public License v3 — see the `LICENSE` file for details. diff --git a/auto_gen_py_project/__init__.py b/auto_gen_py_project/__init__.py index 44962cc..58fe19e 100644 --- a/auto_gen_py_project/__init__.py +++ b/auto_gen_py_project/__init__.py @@ -1,4 +1,4 @@ # auto_gen_py_project/__init__.py -__version__ = "0.1.3" +__version__ = "0.2.0" __author__ = "Axcel Blade" \ No newline at end of file diff --git a/auto_gen_py_project/build_system/__init__.py b/auto_gen_py_project/build_system/__init__.py new file mode 100644 index 0000000..5f2f493 --- /dev/null +++ b/auto_gen_py_project/build_system/__init__.py @@ -0,0 +1,101 @@ +# auto_gen_py_project/build_system/__init__.py + +from typing import Callable, List, Optional, Union + +from .dag import resolve_execution_order +from .exceptions import ( + BuildError, + CyclicDependencyError, + TaskExecutionError, + TaskNotFoundError, +) +from .registry import TaskRegistry, _registry +from .runner import TaskRunner +from .task import Task + + +def task( + name_or_func: Union[str, Callable, None] = None, + *, + depends_on: Optional[List[str]] = None, + action: Optional[Callable] = None, + description: Optional[str] = None, +) -> Union[Task, Callable]: + """Register a build task. + + Three usage styles: + + 1. Bare decorator:: + + @task + def build(): ... + + 2. Decorator with options:: + + @task(depends_on=["test"]) + def build(): ... + + 3. Function call (Gradle-style):: + + task("build", depends_on=["test"], action=build_fn) + """ + # Style 1: @task with no parentheses — name_or_func is the decorated callable. + if callable(name_or_func): + func = name_or_func + t = Task( + name=func.__name__, + action=func, + depends_on=depends_on or [], + description=description or func.__doc__, + ) + _registry.register(t) + return func + + # Style 3: task("name", action=..., depends_on=...) — name_or_func is a string. + if isinstance(name_or_func, str): + t = Task( + name=name_or_func, + action=action, + depends_on=depends_on or [], + description=description, + ) + _registry.register(t) + return t + + # Style 2: @task(...) — name_or_func is None, returns a decorator. + def decorator(func: Callable) -> Callable: + t = Task( + name=func.__name__, + action=func, + depends_on=depends_on or [], + description=description or func.__doc__, + ) + _registry.register(t) + return func + + return decorator + + +def run_task(task_name: str, *, verbose: bool = True) -> None: + """Execute a named task and all of its transitive dependencies.""" + TaskRunner(_registry).run(task_name, verbose=verbose) + + +def list_tasks() -> dict: + """Return all registered tasks keyed by name.""" + return _registry.all_tasks() + + +__all__ = [ + "task", + "run_task", + "list_tasks", + "Task", + "TaskRunner", + "TaskRegistry", + "BuildError", + "CyclicDependencyError", + "TaskNotFoundError", + "TaskExecutionError", + "resolve_execution_order", +] diff --git a/auto_gen_py_project/build_system/cli.py b/auto_gen_py_project/build_system/cli.py new file mode 100644 index 0000000..022ff87 --- /dev/null +++ b/auto_gen_py_project/build_system/cli.py @@ -0,0 +1,77 @@ +"""pybuild — Gradle-inspired build CLI for Python projects.""" + +import argparse +import importlib.util +import sys +from pathlib import Path + +from .exceptions import BuildError +from .registry import _registry +from .runner import TaskRunner + + +def _load_build_file(path: str) -> None: + build_path = Path(path).resolve() + if not build_path.exists(): + print(f"error: build file not found: {build_path}", file=sys.stderr) + sys.exit(1) + + build_dir = str(build_path.parent) + if build_dir not in sys.path: + sys.path.insert(0, build_dir) + + spec = importlib.util.spec_from_file_location("_pybuild_script", build_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="pybuild", + description="Gradle-inspired build tool for Python projects", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +examples: + pybuild build run the 'build' task (and its dependencies) + pybuild clean test run 'clean', then 'test' in order + pybuild --list list all tasks defined in build.py + pybuild -f scripts/build.py test + """, + ) + parser.add_argument("tasks", nargs="*", help="task(s) to execute") + parser.add_argument("--list", "-l", action="store_true", help="list available tasks") + parser.add_argument( + "--file", "-f", default="build.py", metavar="FILE", + help="build file to load (default: build.py)", + ) + parser.add_argument("--quiet", "-q", action="store_true", help="suppress build output") + args = parser.parse_args() + + _load_build_file(args.file) + + if args.list: + tasks = _registry.all_tasks() + if not tasks: + print("No tasks defined in build file.") + return + print("\nAvailable tasks:") + print("-" * 48) + for name, t in sorted(tasks.items()): + dep_str = f" <- {', '.join(t.depends_on)}" if t.depends_on else "" + desc = t.description.splitlines()[0] if t.description else "" + print(f" {name:<22} {desc}{dep_str}") + return + + if not args.tasks: + parser.print_help() + sys.exit(1) + + runner = TaskRunner(_registry) + verbose = not args.quiet + + for task_name in args.tasks: + try: + runner.run(task_name, verbose=verbose) + except BuildError as exc: + print(f"\nBUILD FAILED\n{exc}", file=sys.stderr) + sys.exit(1) diff --git a/auto_gen_py_project/build_system/dag.py b/auto_gen_py_project/build_system/dag.py new file mode 100644 index 0000000..af587fb --- /dev/null +++ b/auto_gen_py_project/build_system/dag.py @@ -0,0 +1,40 @@ +from typing import Dict, List +from .task import Task +from .exceptions import CyclicDependencyError, TaskNotFoundError + + +def resolve_execution_order(tasks: Dict[str, Task], target: str) -> List[str]: + """Return tasks in topological order so every dependency runs before the task that needs it. + + Raises CyclicDependencyError if a cycle is detected. + Raises TaskNotFoundError if target or any dependency is missing. + """ + if target not in tasks: + raise TaskNotFoundError( + f"Task '{target}' not found. Available tasks: {list(tasks.keys())}" + ) + + visited: set = set() + in_progress: set = set() + order: List[str] = [] + + def visit(name: str) -> None: + if name not in tasks: + raise TaskNotFoundError( + f"Dependency '{name}' not found. Available tasks: {list(tasks.keys())}" + ) + if name in in_progress: + cycle = " -> ".join(list(in_progress) + [name]) + raise CyclicDependencyError(f"Cyclic dependency detected: {cycle}") + if name in visited: + return + + in_progress.add(name) + for dep in tasks[name].depends_on: + visit(dep) + in_progress.discard(name) + visited.add(name) + order.append(name) + + visit(target) + return order diff --git a/auto_gen_py_project/build_system/exceptions.py b/auto_gen_py_project/build_system/exceptions.py new file mode 100644 index 0000000..9c381cf --- /dev/null +++ b/auto_gen_py_project/build_system/exceptions.py @@ -0,0 +1,14 @@ +class BuildError(Exception): + """Base exception for all build system errors.""" + + +class CyclicDependencyError(BuildError): + """Raised when a cyclic dependency is detected in the task graph.""" + + +class TaskNotFoundError(BuildError): + """Raised when a referenced task does not exist in the registry.""" + + +class TaskExecutionError(BuildError): + """Raised when a task's action raises an exception during execution.""" diff --git a/auto_gen_py_project/build_system/registry.py b/auto_gen_py_project/build_system/registry.py new file mode 100644 index 0000000..ec2d791 --- /dev/null +++ b/auto_gen_py_project/build_system/registry.py @@ -0,0 +1,31 @@ +from typing import Dict +from .task import Task +from .exceptions import TaskNotFoundError + + +class TaskRegistry: + """Holds all registered tasks for a build session.""" + + def __init__(self) -> None: + self._tasks: Dict[str, Task] = {} + + def register(self, task: Task) -> None: + self._tasks[task.name] = task + + def get(self, name: str) -> Task: + if name not in self._tasks: + available = list(self._tasks.keys()) + raise TaskNotFoundError( + f"Task '{name}' not found. Available tasks: {available}" + ) + return self._tasks[name] + + def all_tasks(self) -> Dict[str, Task]: + return dict(self._tasks) + + def clear(self) -> None: + self._tasks.clear() + + +# Module-level registry shared across a build session. +_registry = TaskRegistry() diff --git a/auto_gen_py_project/build_system/runner.py b/auto_gen_py_project/build_system/runner.py new file mode 100644 index 0000000..9e5bd00 --- /dev/null +++ b/auto_gen_py_project/build_system/runner.py @@ -0,0 +1,46 @@ +import time +from .dag import resolve_execution_order +from .registry import TaskRegistry +from .exceptions import TaskExecutionError + + +class TaskRunner: + """Executes tasks in dependency-resolved order with Gradle-style output.""" + + def __init__(self, registry: TaskRegistry) -> None: + self.registry = registry + + def run(self, task_name: str, verbose: bool = True) -> None: + tasks = self.registry.all_tasks() + order = resolve_execution_order(tasks, task_name) + + total = len(order) + if verbose: + print(f"\n> Build: {total} task(s) to execute\n") + + start_all = time.time() + executed = 0 + + for name in order: + task = tasks[name] + if verbose: + print(f"> Task :{name}") + t0 = time.time() + try: + task.execute() + executed += 1 + elapsed = time.time() - t0 + if verbose: + print(f" [DONE] {elapsed:.2f}s\n") + except Exception as exc: + print(f" [FAILED] :{name}") + raise TaskExecutionError( + f"Task ':{name}' failed: {exc}" + ) from exc + + total_elapsed = time.time() - start_all + if verbose: + print( + f"BUILD SUCCESSFUL in {total_elapsed:.2f}s\n" + f"{total} actionable task(s): {executed} executed" + ) diff --git a/auto_gen_py_project/build_system/task.py b/auto_gen_py_project/build_system/task.py new file mode 100644 index 0000000..8c187c9 --- /dev/null +++ b/auto_gen_py_project/build_system/task.py @@ -0,0 +1,24 @@ +from typing import Callable, List, Optional + + +class Task: + """A single build task with an optional action and dependency list.""" + + def __init__( + self, + name: str, + action: Optional[Callable] = None, + depends_on: Optional[List[str]] = None, + description: Optional[str] = None, + ) -> None: + self.name = name + self.action = action + self.depends_on: List[str] = list(depends_on or []) + self.description: str = description or (action.__doc__ or "") if action else (description or "") + + def execute(self) -> None: + if self.action is not None: + self.action() + + def __repr__(self) -> str: + return f"Task(name={self.name!r}, depends_on={self.depends_on!r})" diff --git a/auto_gen_py_project/generator.py b/auto_gen_py_project/generator.py index f15e677..fea847f 100644 --- a/auto_gen_py_project/generator.py +++ b/auto_gen_py_project/generator.py @@ -3,6 +3,58 @@ import venv from pathlib import Path +_BUILD_PY_TEMPLATE = '''\ +"""build.py — pybuild task definitions for {project_name}. + +Run tasks with: + pybuild # e.g. pybuild build + pybuild --list # list all tasks + python build.py # without installing pybuild +""" + +import shutil +import subprocess +import sys +from pathlib import Path + +from auto_gen_py_project.build_system import task, run_task + + +# --------------------------------------------------------------------------- +# Task definitions +# --------------------------------------------------------------------------- + +@task +def clean(): + """Remove build artefacts.""" + for d in ("build", "dist", "__pycache__", ".pytest_cache"): + shutil.rmtree(d, ignore_errors=True) + for p in Path(".").rglob("*.pyc"): + p.unlink(missing_ok=True) + + +@task(depends_on=["clean"]) +def test(): + """Run the test suite with pytest.""" + subprocess.run([sys.executable, "-m", "pytest", "tests/", "-v"], check=True) + + +@task(depends_on=["test"]) +def build(): + """Build source distribution and wheel.""" + subprocess.run([sys.executable, "-m", "build"], check=True) + + +# --------------------------------------------------------------------------- +# Direct execution: python build.py +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + target = sys.argv[1] if len(sys.argv) > 1 else "build" + run_task(target) +''' + + def create_project(project_name: str, init_in_current_folder: bool = False) -> None: """ Create a new Python project structure. @@ -93,6 +145,9 @@ def create_project(project_name: str, init_in_current_folder: bool = False) -> N ' main()\n' ) + # build.py — Gradle-like task file + (root / "build.py").write_text(_BUILD_PY_TEMPLATE.format(project_name=project_name)) + # Create project-local virtual environment with pip. venv_path = root / ".venv" venv.EnvBuilder(with_pip=True).create(str(venv_path)) diff --git a/build.py b/build.py new file mode 100644 index 0000000..c85a6f6 --- /dev/null +++ b/build.py @@ -0,0 +1,75 @@ +"""build.py — pybuild task definitions for auto-gen-py-project. + +Run tasks with: + pybuild # e.g. pybuild build + pybuild --list # list all tasks + python build.py # without installing pybuild +""" + +import shutil +import subprocess +import sys +from pathlib import Path + +from auto_gen_py_project.build_system import task, run_task + + +# --------------------------------------------------------------------------- +# Task definitions +# --------------------------------------------------------------------------- + +@task +def clean(): + """Remove build artefacts (build/, dist/, __pycache__, .pytest_cache).""" + for d in ("build", "dist", "__pycache__", ".pytest_cache"): + shutil.rmtree(d, ignore_errors=True) + for p in Path(".").rglob("*.pyc"): + p.unlink(missing_ok=True) + + +@task(depends_on=["clean"]) +def lint(): + """Run ruff linter (skipped gracefully if ruff is not installed).""" + result = subprocess.run( + [sys.executable, "-m", "ruff", "check", "auto_gen_py_project/", "tests/"], + capture_output=True, + text=True, + ) + if result.returncode not in (0, 127): + print(result.stdout) + raise RuntimeError("Lint errors found — see output above.") + if result.returncode == 127: + print(" ruff not installed, skipping lint.") + + +@task(depends_on=["lint"]) +def test(): + """Run the full test suite with pytest.""" + subprocess.run( + [sys.executable, "-m", "pytest", "tests/", "-v", "--tb=short"], + check=True, + ) + + +@task(depends_on=["test"]) +def build(): + """Build source distribution and wheel into dist/.""" + subprocess.run([sys.executable, "-m", "build"], check=True) + + +@task(depends_on=["build"]) +def publish(): + """Upload dist/ packages to PyPI via twine (requires TWINE_* env vars or ~/.pypirc).""" + subprocess.run( + [sys.executable, "-m", "twine", "upload", "dist/*"], + check=True, + ) + + +# --------------------------------------------------------------------------- +# Direct execution: python build.py +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + target = sys.argv[1] if len(sys.argv) > 1 else "build" + run_task(target) diff --git a/setup.py b/setup.py index edae5e0..387ddfa 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name="auto-gen-py-project", - version="0.1.3", + version="0.2.0", description="A Python project that auto-generates Python code", long_description=open("README.md", "r", encoding="utf-8").read(), long_description_content_type="text/markdown", @@ -20,6 +20,7 @@ "console_scripts": [ "auto-gen-py-project=auto_gen_py_project.cli:main", "auto_gen_py_project=auto_gen_py_project.cli:main", + "pybuild=auto_gen_py_project.build_system.cli:main", ], }, ) \ No newline at end of file diff --git a/tests/test_build_system.py b/tests/test_build_system.py new file mode 100644 index 0000000..880c2d0 --- /dev/null +++ b/tests/test_build_system.py @@ -0,0 +1,225 @@ +"""Tests for the pybuild Gradle-like build system.""" + +import pytest + +from auto_gen_py_project.build_system import ( + Task, + TaskRegistry, + TaskRunner, + resolve_execution_order, +) +from auto_gen_py_project.build_system.exceptions import ( + CyclicDependencyError, + TaskExecutionError, + TaskNotFoundError, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _registry(*tasks) -> TaskRegistry: + """Build a fresh TaskRegistry from Task objects.""" + reg = TaskRegistry() + for t in tasks: + reg.register(t) + return reg + + +def _task(name, depends_on=None, action=None) -> Task: + return Task(name=name, depends_on=depends_on or [], action=action) + + +# --------------------------------------------------------------------------- +# Task +# --------------------------------------------------------------------------- + +class TestTask: + def test_defaults(self): + t = Task("build") + assert t.name == "build" + assert t.depends_on == [] + assert t.description == "" + + def test_execute_calls_action(self): + called = [] + t = Task("x", action=lambda: called.append(1)) + t.execute() + assert called == [1] + + def test_execute_no_action_is_noop(self): + Task("x").execute() # must not raise + + def test_description_from_docstring(self): + def my_fn(): + """My task description.""" + + t = Task("x", action=my_fn) + assert "My task description" in t.description + + +# --------------------------------------------------------------------------- +# TaskRegistry +# --------------------------------------------------------------------------- + +class TestTaskRegistry: + def test_register_and_get(self): + reg = _registry(_task("build")) + assert reg.get("build").name == "build" + + def test_get_missing_raises(self): + reg = _registry() + with pytest.raises(TaskNotFoundError): + reg.get("missing") + + def test_all_tasks(self): + reg = _registry(_task("a"), _task("b")) + assert set(reg.all_tasks().keys()) == {"a", "b"} + + def test_clear(self): + reg = _registry(_task("a")) + reg.clear() + assert reg.all_tasks() == {} + + +# --------------------------------------------------------------------------- +# DAG — resolve_execution_order +# --------------------------------------------------------------------------- + +class TestDAG: + def _make(self, *specs): + """specs = (name, [deps]) tuples.""" + tasks = {name: _task(name, depends_on=deps) for name, deps in specs} + return tasks + + def test_single_task_no_deps(self): + tasks = self._make(("build", [])) + assert resolve_execution_order(tasks, "build") == ["build"] + + def test_linear_chain(self): + tasks = self._make(("clean", []), ("test", ["clean"]), ("build", ["test"])) + order = resolve_execution_order(tasks, "build") + assert order == ["clean", "test", "build"] + + def test_diamond_deduplication(self): + # build <- [lint, test] <- clean + tasks = self._make( + ("clean", []), + ("lint", ["clean"]), + ("test", ["clean"]), + ("build", ["lint", "test"]), + ) + order = resolve_execution_order(tasks, "build") + # clean must appear exactly once and before lint and test + assert order.count("clean") == 1 + assert order.index("clean") < order.index("lint") + assert order.index("clean") < order.index("test") + assert order[-1] == "build" + + def test_missing_target_raises(self): + with pytest.raises(TaskNotFoundError): + resolve_execution_order({}, "nope") + + def test_missing_dependency_raises(self): + tasks = self._make(("build", ["ghost"])) + with pytest.raises(TaskNotFoundError): + resolve_execution_order(tasks, "build") + + def test_direct_cycle_raises(self): + tasks = self._make(("a", ["b"]), ("b", ["a"])) + with pytest.raises(CyclicDependencyError): + resolve_execution_order(tasks, "a") + + def test_self_cycle_raises(self): + tasks = self._make(("a", ["a"])) + with pytest.raises(CyclicDependencyError): + resolve_execution_order(tasks, "a") + + def test_transitive_cycle_raises(self): + tasks = self._make(("a", ["b"]), ("b", ["c"]), ("c", ["a"])) + with pytest.raises(CyclicDependencyError): + resolve_execution_order(tasks, "a") + + +# --------------------------------------------------------------------------- +# TaskRunner +# --------------------------------------------------------------------------- + +class TestTaskRunner: + def test_runs_in_order(self): + log = [] + reg = _registry( + _task("clean", action=lambda: log.append("clean")), + _task("test", depends_on=["clean"], action=lambda: log.append("test")), + _task("build", depends_on=["test"], action=lambda: log.append("build")), + ) + TaskRunner(reg).run("build", verbose=False) + assert log == ["clean", "test", "build"] + + def test_skips_unneeded_tasks(self): + log = [] + reg = _registry( + _task("clean", action=lambda: log.append("clean")), + _task("test", depends_on=["clean"], action=lambda: log.append("test")), + _task("publish", depends_on=["test"], action=lambda: log.append("publish")), + ) + TaskRunner(reg).run("test", verbose=False) + assert "publish" not in log + + def test_failing_task_raises_execution_error(self): + def boom(): + raise ValueError("intentional failure") + + reg = _registry(_task("bad", action=boom)) + with pytest.raises(TaskExecutionError): + TaskRunner(reg).run("bad", verbose=False) + + def test_missing_task_raises_not_found(self): + reg = _registry() + with pytest.raises(TaskNotFoundError): + TaskRunner(reg).run("ghost", verbose=False) + + +# --------------------------------------------------------------------------- +# Public API: task() decorator / function-call DSL +# --------------------------------------------------------------------------- + +class TestTaskDSL: + """Tests for the module-level task() helper using an isolated registry.""" + + def setup_method(self): + self.reg = TaskRegistry() + + def _register(self, fn=None, *, depends_on=None, name=None, action=None): + """Register via Task directly into self.reg (avoids global registry).""" + from auto_gen_py_project.build_system.task import Task as T + t = T( + name=name or (fn.__name__ if fn else "unnamed"), + action=action or fn, + depends_on=depends_on or [], + ) + self.reg.register(t) + return t + + def test_function_call_style(self): + called = [] + self._register(name="clean", action=lambda: called.append("clean")) + self._register(name="build", depends_on=["clean"], action=lambda: called.append("build")) + TaskRunner(self.reg).run("build", verbose=False) + assert called == ["clean", "build"] + + def test_decorator_style(self): + called = [] + + def my_task(): + called.append("my_task") + + self._register(my_task) + TaskRunner(self.reg).run("my_task", verbose=False) + assert called == ["my_task"] + + def test_depends_on_preserved(self): + self._register(name="a", action=lambda: None) + t = self._register(name="b", depends_on=["a"], action=lambda: None) + assert t.depends_on == ["a"] diff --git a/tests/test_generator.py b/tests/test_generator.py index 4670b30..7760604 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -54,8 +54,8 @@ def test_main_py_content(self): main_py = Path(project_name, "src", "main.py") content = main_py.read_text() - assert "def hello():" in content, "hello() function not found in main.py" - assert "Hello from your new Python project" in content, "Expected content not found" + assert "def main():" in content, "main() function not found in main.py" + assert "Hello World!" in content, "Expected content not found" def test_init_py_created(self): """Test that __init__.py is created""" @@ -135,6 +135,7 @@ def test_project_structure_complete(self): Path(project_name, "pyproject.toml"), Path(project_name, ".gitignore"), Path(project_name, "LICENSE"), + Path(project_name, "build.py"), ] for file_path in expected_files: @@ -253,6 +254,7 @@ def test_init_complete_structure(self): Path("pyproject.toml"), Path(".gitignore"), Path("LICENSE"), + Path("build.py"), ] for file_path in expected_files: diff --git a/tests/test_main.py b/tests/test_main.py index df3570e..eba270c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,4 +1,14 @@ -from src.core import hello +import unittest +from auto_gen_py_project.cli import main +from auto_gen_py_project import __version__ -def test_hello(): - assert hello() + +class TestPackage(unittest.TestCase): + def test_version_is_string(self): + self.assertIsInstance(__version__, str) + + def test_version_format(self): + parts = __version__.split(".") + self.assertGreaterEqual(len(parts), 2) + for part in parts: + self.assertTrue(part.isdigit(), f"Non-numeric version segment: {part}")