Skip to content

Commit

Permalink
Add CI workflow for linting, tests, and coverage (#21)
Browse files Browse the repository at this point in the history
* Add real tests and a CI workflow that includes
coverage reports

* Debug test coverage

* Fix coverage output file name

* Only trigger CI workflow on push to default branch
  • Loading branch information
bsweger authored Jan 20, 2025
1 parent 472ef35 commit 1d9581e
Show file tree
Hide file tree
Showing 12 changed files with 309 additions and 61 deletions.
112 changes: 112 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
name: CI

on:
push:
branches:
- main
pull_request:
workflow_dispatch:

env:
FORCE_COLOR: "1"
PIP_DISABLE_PIP_VERSION_CHECK: "1"
PIP_NO_PYTHON_VERSION_WARNING: "1"

jobs:

lint:
name: Run linter
runs-on: ubuntu-latest

steps:
- name: Checkout 🛎️
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Install uv 🌟
uses: astral-sh/setup-uv@v5
with:
version: ">=0.0.1"

- name: Lint 🧹
run: |
uv tool install ruff
ruff check
tests:
name: Run tests
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]

steps:
- name: Checkout 🛎️
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Install uv 🌟
uses: astral-sh/setup-uv@v5
with:
python-version: ${{ matrix.python-version }}
version: ">=0.0.1"

- name: Test with python ${{ matrix.python-version }} 🧪
run: |
uv run --frozen coverage run \
--data-file .coverage.${{ matrix.python-version }} \
-m pytest
- name: Upload coverage data 📤
uses: actions/upload-artifact@v4
with:
name: coverage-data-${{ matrix.python-version }}
path: .coverage.*
include-hidden-files: true
if-no-files-found: ignore

coverage:
# https://hynek.me/articles/ditch-codecov-python/
name: Combine coverage reports & fail if below threshold
runs-on: ubuntu-latest
needs: tests
if: always()

steps:
- name: Checkout 🛎️
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Install uv 🌟
uses: astral-sh/setup-uv@v5
with:
version: '>=0.0.1'

- name: Download coverage data 📥
uses: actions/download-artifact@v4
with:
pattern: coverage-data-*
merge-multiple: true

- name: Generate coverage report 📊
run: |
uv tool install 'coverage[toml]'
coverage combine
coverage html --skip-covered --skip-empty
coverage report --format=markdown >> $GITHUB_STEP_SUMMARY
# Generate report again, this time with a fail-under threshold
coverage report --fail-under=80
- name: Upload HTML report if coverage check fails
uses: actions/upload-artifact@v4
with:
name: html-cov-report
path: htmlcov
if: ${{ failure() }}


File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
push:
tags:
# only run workflow for tags in release format
- "v[0-9].[0-9].[0-9]"
- "v[0-9]+.[0-9]+.[0-9]+"

jobs:
build:
Expand Down
32 changes: 0 additions & 32 deletions .github/workflows/pythonapp-workflow.yml

This file was deleted.

4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = 'pyprefab'
description = 'Python application template for personal use'
license = {text = 'MIT License'}
readme = 'README.md'
requires-python = '>=3.9'
requires-python = '>=3.9,<3.14'
classifiers = [
'Programming Language :: Python :: 3',
'License :: OSI Approved :: MIT License',
Expand All @@ -19,13 +19,15 @@ dependencies = [

[dependency-groups]
dev = [
'build',
'coverage',
'freezegun',
'mypy',
'pre-commit',
'pytest',
"pytest-random-order>=1.1.1",
'ruff',
"tomli>=2.2.1",
]

[project.scripts]
Expand Down
33 changes: 20 additions & 13 deletions src/pyprefab/cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Command-line interface for the pyprefab package."""

import shutil
from pathlib import Path
from typing import Optional
Expand All @@ -10,10 +11,12 @@

app = typer.Typer(help='Generate python project scaffolding based on pyprefab.')


def validate_project_name(name: str) -> bool:
"""Validate project name follows Python package naming conventions."""
return name.isidentifier() and name.islower()


@app.command()
def create(
name: str = typer.Argument(..., help='Name of the project'),
Expand All @@ -30,14 +33,13 @@ def create(
fg=typer.colors.RED,
)
raise typer.Exit(1)

templates_dir = Path(__file__).parent / 'templates'
target_dir = project_dir or Path.cwd() / name

try:
# Create project directory
target_dir.mkdir(parents=True, exist_ok=True)

# Template context
context = {
'project_name': name,
Expand All @@ -48,13 +50,13 @@ def create(
# Process templates
env = Environment(loader=FileSystemLoader(templates_dir))
path_env = Environment() # For rendering path names
#env = Environment(loader=FileSystemLoader(templates_dir))
# env = Environment(loader=FileSystemLoader(templates_dir))
for template_file in templates_dir.rglob('*'):
if template_file.is_file():
rel_path = template_file.relative_to(templates_dir)
template = env.get_template(str(rel_path))
output = template.render(**context)

# Process path parts through Jinja
path_parts = []
for part in rel_path.parts:
Expand All @@ -63,28 +65,33 @@ def create(
if rendered_part.endswith('.j2'):
rendered_part = rendered_part[:-3]
path_parts.append(rendered_part)

# Create destination path preserving structure
dest_file = target_dir.joinpath(*path_parts)
dest_file.parent.mkdir(parents=True, exist_ok=True)
dest_file.write_text(output)

print(Panel.fit(
f'✨ Created new project [bold green]{name}[/] in {target_dir}\n'
f'Author: [blue]{author}[/]\n'
f'Description: {description}',
title='Project Created Successfully',
border_style='green',
))
print(
Panel.fit(
f'✨ Created new project [bold green]{name}[/] in {target_dir}\n'
f'Author: [blue]{author}[/]\n'
f'Description: {description}',
title='Project Created Successfully',
border_style='green',
)
)

except Exception as e:
typer.secho(f'Error creating project: {str(e)}', fg=typer.colors.RED)
if target_dir.exists():
shutil.rmtree(target_dir)
raise typer.Exit(1)


def main():
app()


if __name__ == '__main__':
main()
main()

8 changes: 4 additions & 4 deletions src/pyprefab/templates/pyproject.toml.j2
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
[project]
name = '{{ project_name }}'
name = "{{ project_name }}"
dynamic = ['version']
description = '{{ description }}'
authors = [{name = '{{ author }}'}]
maintainers = [{name = '{{ author }}'}]
description = "{{ description }}"
authors = [{name = "{{ author }}"}]
maintainers = [{name = "{{ author }}"}]
license = {text = 'MIT License'}

requires-python = '>=3.9'
Expand Down
1 change: 0 additions & 1 deletion src/pyprefab/templates/test/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,3 @@

def test_insert_here():
assert True

24 changes: 24 additions & 0 deletions test/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from typer.testing import CliRunner

from pyprefab.cli import app # type: ignore


def test_pyprefab_cli(tmp_path):
runner = CliRunner()
result = runner.invoke(
app,
['pytest_project', '--author', 'Py Test', '--directory', tmp_path],
)
assert result.exit_code == 0

# project directory populated with tesmplate output should contain
# two folders: src and test
dir_count = 0
dir_names = []
for child in tmp_path.iterdir():
if child.is_dir():
dir_names.append(child.name)
dir_count += 1
assert dir_count == 2
assert 'src' in dir_names
assert 'test' in dir_names
2 changes: 0 additions & 2 deletions test/test_pyprefab.py

This file was deleted.

58 changes: 58 additions & 0 deletions test/test_templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import subprocess

from typer.testing import CliRunner

from pyprefab.cli import app # type: ignore

# tomllib is not part of the standard library until Python 3.11
try:
import tomllib # type: ignore
except ModuleNotFoundError:
import tomli as tomllib # type: ignore


def test_pyproject(tmp_path):
runner = CliRunner()
result = runner.invoke(
app,
[
'transporter_logs',
'--author',
"Miles O'Brien",
'--description',
"An app for parsin' transporter logs",
'--directory',
tmp_path,
],
)
assert result.exit_code == 0
with open(tmp_path / 'pyproject.toml', 'rb') as f:
pyproject = tomllib.load(f)
assert pyproject.get('project', {}).get('name') == 'transporter_logs'
assert pyproject.get('project').get('authors')[0].get('name') == "Miles O'Brien"
assert pyproject.get('project').get('description') == "An app for parsin' transporter logs"


def test_build(tmp_path):
"""Files created should build a valid python package."""
runner = CliRunner()
result = runner.invoke(
app,
[
'transporter_logs',
'--author',
"Miles O'Brien",
'--description',
"An app for parsin' transporter logs",
'--directory',
tmp_path,
],
)
assert result.exit_code == 0

# pyprefab output should be a valid Python package
result = subprocess.run(
['python', '-m', 'build', '--sdist', '--wheel'], capture_output=True, cwd=tmp_path, text=True
)

assert result.returncode == 0
Loading

0 comments on commit 1d9581e

Please sign in to comment.