diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0e480f9b..9ee804ac 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -75,4 +75,3 @@ package.json @tleonhardt pyproject.toml @tleonhardt @kmvanbrunt ruff.toml @tleonhardt README.md @kmvanbrunt @tleonhardt -tasks.py @tleonhardt diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index c3374dce..688e24dd 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -96,7 +96,6 @@ for a list of dependencies needed for building `cmd2`. | Prerequisite | Minimum Version | Purpose | | -------------------------------------------------------------------- | --------------- | -------------------------------- | | [codecov](http://doc.pytest.org/en/latest/) | `2.1.13` | Cover coverage reporting | -| [invoke](https://www.pyinvoke.org/) | `2.2.0` | Command automation | | [mypy](https://mypy-lang.org/) | `1.13.0` | Static type checker | | [pytest](https://docs.pytest.org/en/stable/) | `3.0.6` | Unit and integration tests | | [pytest-cov](http://doc.pytest.org/en/latest/) | `6.0.0` | Pytest code coverage | @@ -776,14 +775,14 @@ Since 0.9.2, the process of publishing a new release of `cmd2` to [PyPi](https:/ mostly automated. The manual steps are all git operations. Here's the checklist: 1. Make sure you're on the proper branch (almost always **main**) -1. Make sure all the unit tests pass with `invoke pytest` or `py.test` +1. Make sure all the unit tests pass with `make test` 1. Make sure latest year in `LICENSE` matches current year 1. Make sure `CHANGELOG.md` describes the version and has the correct release date -1. Add a git tag representing the version number using `invoke tag x.y.z` +1. Add a git tag representing the version number using `make tag TAG=x.y.z` - Where x, y, and z are all small non-negative integers -1. (Optional) Run `invoke pypi-test` to clean, build, and upload a new release to +1. (Optional) Run `make publish-test` to clean, build, and upload a new release to [Test PyPi](https://test.pypi.org) -1. Run `invoke pypi` to clean, build, and upload a new release to [PyPi](https://pypi.org/) +1. Run `make publish` to clean, build, and upload a new release to [PyPi](https://pypi.org/) ## Acknowledgement diff --git a/MANIFEST.in b/MANIFEST.in index b9fb5f89..39f59c1c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -include LICENSE README.md CHANGELOG.md mkdocs.yml pyproject.toml ruff.toml tasks.py +include LICENSE README.md CHANGELOG.md Makefile mkdocs.yml pyproject.toml ruff.toml recursive-include examples * recursive-include tests * recursive-include docs * diff --git a/Makefile b/Makefile index 5d735b88..07afeadc 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,7 @@ # Simple Makefile for use with a uv-based development environment +# The at (@) prefix tells make to suppress output from the command +# The hyphen (-) prefix tells make to ignore errors (e.g., if a directory doesn't exist) + .PHONY: install install: ## Install the virtual environment with dependencies @echo "🚀 Creating uv Python virtual environment" @@ -48,11 +51,6 @@ build: clean-build ## Build wheel file @echo "🚀 Creating wheel file" @uv build -.PHONY: clean-build -clean-build: ## Clean build artifacts - @echo "🚀 Removing build artifacts" - @uv run python -c "import shutil; import os; shutil.rmtree('dist') if os.path.exists('dist') else None" - .PHONY: tag tag: ## Add a Git tag and push it to origin with syntax: make tag TAG=tag_name @echo "🚀 Creating git tag: ${TAG}" @@ -63,7 +61,7 @@ tag: ## Add a Git tag and push it to origin with syntax: make tag TAG=tag_name .PHONY: validate-tag validate-tag: ## Check to make sure that a tag exists for the current HEAD and it looks like a valid version number @echo "🚀 Validating version tag" - @uv run inv validatetag + @uv run scripts/validate_tag.py .PHONY: publish-test publish-test: validate-tag build ## Test publishing a release to PyPI, uses token from ~/.pypirc file. @@ -75,6 +73,46 @@ publish: validate-tag build ## Publish a release to PyPI, uses token from ~/.pyp @echo "🚀 Publishing." @uv run uv-publish +# Define variables for files/directories to clean +BUILD_DIRS = build dist *.egg-info +DOC_DIRS = build +MYPY_DIRS = .mypy_cache dmypy.json dmypy.sock +TEST_DIRS = .cache .coverage .pytest_cache htmlcov + +.PHONY: clean-build +clean-build: ## Clean build artifacts + @echo "🚀 Removing build artifacts" + @uv run python -c "import shutil; import os; [shutil.rmtree(d, ignore_errors=True) for d in '$(BUILD_DIRS)'.split() if os.path.isdir(d)]" + +.PHONY: clean-docs +clean-docs: ## Clean documentation artifacts + @echo "🚀 Removing documentation artifacts" + @uv run python -c "import shutil; import os; [shutil.rmtree(d, ignore_errors=True) for d in '$(DOC_DIRS)'.split() if os.path.isdir(d)]" + +.PHONY: clean-mypy +clean-mypy: ## Clean mypy artifacts + @echo "🚀 Removing mypy artifacts" + @uv run python -c "import shutil; import os; [shutil.rmtree(d, ignore_errors=True) for d in '$(MYPY_DIRS)'.split() if os.path.isdir(d)]" + +.PHONY: clean-pycache +clean-pycache: ## Clean pycache artifacts + @echo "🚀 Removing pycache artifacts" + @-find . -type d -name "__pycache__" -exec rm -r {} + + +.PHONY: clean-ruff +clean-ruff: ## Clean ruff artifacts + @echo "🚀 Removing ruff artifacts" + @uv run ruff clean + +.PHONY: clean-test +clean-test: ## Clean test artifacts + @echo "🚀 Removing test artifacts" + @uv run python -c "import shutil; import os; [shutil.rmtree(d, ignore_errors=True) for d in '$(TEST_DIRS)'.split() if os.path.isdir(d)]" + +.PHONY: clean +clean: clean-build clean-docs clean-mypy clean-pycache clean-ruff clean-test ## Clean all artifacts + @echo "🚀 Cleaned all artifacts" + .PHONY: help help: @uv run python -c "import re; \ diff --git a/pyproject.toml b/pyproject.toml index 21279038..65d313f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,6 @@ build = ["build>=1.2.2", "setuptools>=80.7.1", "setuptools-scm>=9.2"] dev = [ "black>=25", "codecov>=2.1", - "invoke>=2.2.1", "ipython>=8.23", "mkdocs-git-revision-date-localized-plugin>=1.5", "mkdocs-material>=9.7.1", @@ -87,7 +86,6 @@ exclude = [ "^noxfile\\.py$", # nox config file "setup\\.py$", # any files named setup.py "^site/", - "^tasks\\.py$", # tasks.py invoke config file "^tests/", # tests directory ] files = ['.'] diff --git a/ruff.toml b/ruff.toml index 80995579..7d5962b7 100644 --- a/ruff.toml +++ b/ruff.toml @@ -151,6 +151,9 @@ mccabe.max-complexity = 49 ] "examples/scripts/*.py" = ["F821"] # Undefined name `app` +# Ignore starting a process with a partial executable path (i.e. git) +"scripts/validate_tag.py" = ["S607"] + # Ingore various rulesets in test directories "{tests}/*.py" = [ "ANN", # Ignore all type annotation rules in test folders diff --git a/scripts/validate_tag.py b/scripts/validate_tag.py new file mode 100755 index 00000000..21456a0c --- /dev/null +++ b/scripts/validate_tag.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +"""A simple script to validate that a git tag matches a SemVer pattern.""" + +import re +import subprocess + +SEMVER_SIMPLE = re.compile(r'(\d+)\.(\d+)\.(\d+)((a|b|rc)(\d+))?') +SEMVER_PATTERN = re.compile( + r""" + ^ # Start of the string + v? # Optional 'v' prefix (common in Git tags) + (?P0|[1-9]\d*)\. # Major version + (?P0|[1-9]\d*)\. # Minor version + (?P0|[1-9]\d*) # Patch version + (?:-(?P # Optional pre-release section + (?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*) # Identifier + (?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))* + ))? + (?:\+(?P # Optional build metadata section + [0-9a-zA-Z-]+ # Identifier + (?:\.[0-9a-zA-Z-]+)* + ))? + $ # End of the string + """, + re.VERBOSE, +) + + +def get_current_tag() -> str: + """Get current git tag.""" + try: + # Gets the name of the latest tag reachable from the current commit + result = subprocess.run(['git', 'describe', '--tags', '--abbrev=0'], capture_output=True, text=True, check=True) + return result.stdout.strip() + except subprocess.CalledProcessError: + print("Could not find a reachable tag.") + return '' + + +def is_semantic_version(tag_name: str) -> bool: + """Check if a given string complies with the semantic versioning 2.0.0 specification. + + Args: + tag_name: The name of the Git tag to validate. + + Returns: + bool: True if the tag is a valid semantic version, False otherwise. + + """ + # The regex pattern for semantic versioning 2.0.0 (source: https://semver.org/) + semver_pattern = re.compile( + r""" + ^ # Start of the string + v? # Optional 'v' prefix (common in Git tags) + (?P0|[1-9]\d*)\. # Major version + (?P0|[1-9]\d*)\. # Minor version + (?P0|[1-9]\d*) # Patch version + (?:-(?P # Optional pre-release section + (?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*) # Identifier + (?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))* + ))? + (?:\+(?P # Optional build metadata section + [0-9a-zA-Z-]+ # Identifier + (?:\.[0-9a-zA-Z-]+)* + ))? + $ # End of the string + """, + re.VERBOSE, + ) + + return bool(semver_pattern.match(tag_name)) + + +if __name__ == '__main__': + import sys + + git_tag = get_current_tag() + if not git_tag: + print('Git tag does not exist for current commit.') + sys.exit(-1) + + if not is_semantic_version(git_tag): + print(rf"Git tag '{git_tag}' is invalid according to SemVer.") + sys.exit(-1) + + print(rf"Git tag '{git_tag}' is valid.") diff --git a/tasks.py b/tasks.py deleted file mode 100644 index 0ba27990..00000000 --- a/tasks.py +++ /dev/null @@ -1,275 +0,0 @@ -"""Development related tasks to be run with 'invoke'.""" - -import contextlib -import os -import pathlib -import re -import shutil -import sys - -import invoke -from invoke.context import Context - -TASK_ROOT = pathlib.Path(__file__).resolve().parent -TASK_ROOT_STR = str(TASK_ROOT) - - -# shared function -def rmrf(items: str | list[str] | set[str], verbose: bool = True) -> None: - """Silently remove a list of directories or files.""" - if isinstance(items, str): - items = [items] - - for item in items: - if verbose: - print(f"Removing {item}") - shutil.rmtree(item, ignore_errors=True) - # rmtree doesn't remove bare files - with contextlib.suppress(FileNotFoundError): - os.remove(item) - - -# create namespaces -namespace = invoke.Collection() -namespace_clean = invoke.Collection('clean') -namespace.add_collection(namespace_clean, 'clean') - -##### -# -# pytest, pylint, and codecov -# -##### - - -@invoke.task() -def pytest(context: Context, junit: bool = False, pty: bool = True) -> None: - """Run tests and code coverage using pytest.""" - with context.cd(TASK_ROOT_STR): - command_str = 'pytest ' - command_str += ' --cov=cmd2 ' - command_str += ' --cov-append --cov-report=term --cov-report=html ' - - if junit: - command_str += ' --junitxml=junit/test-results.xml ' - - tests_cmd = command_str + ' tests' - context.run(tests_cmd, pty=pty) - - -namespace.add_task(pytest) - - -@invoke.task() -def pytest_clean(context: Context) -> None: - """Remove pytest cache and code coverage files and directories.""" - # pylint: disable=unused-argument - with context.cd(str(TASK_ROOT / 'tests')): - dirs = ['.pytest_cache', '.cache', 'htmlcov', '.coverage'] - rmrf(dirs) - rmrf(dirs) - - -namespace_clean.add_task(pytest_clean, 'pytest') - - -@invoke.task() -def mypy(context: Context) -> None: - """Run mypy optional static type checker.""" - with context.cd(TASK_ROOT_STR): - context.run("mypy .") - - -namespace.add_task(mypy) - - -@invoke.task() -def mypy_clean(context: Context) -> None: - """Remove mypy cache directory.""" - # pylint: disable=unused-argument - with context.cd(TASK_ROOT_STR): - dirs = ['.mypy_cache', 'dmypy.json', 'dmypy.sock'] - rmrf(dirs) - - -namespace_clean.add_task(mypy_clean, 'mypy') - - -##### -# -# documentation -# -##### -DOCS_BUILDDIR = 'build' - - -@invoke.task() -def docs(context: Context) -> None: - """Build documentation using MkDocs.""" - with context.cd(TASK_ROOT_STR): - context.run('mkdocs build', pty=True) - - -namespace.add_task(docs) - - -@invoke.task -def docs_clean(context: Context) -> None: - """Remove rendered documentation.""" - # pylint: disable=unused-argument - with context.cd(TASK_ROOT_STR): - rmrf(DOCS_BUILDDIR) - - -namespace_clean.add_task(docs_clean, name='docs') - - -@invoke.task -def livehtml(context: Context) -> None: - """Launch webserver on http://localhost:8000 with rendered documentation.""" - with context.cd(TASK_ROOT_STR): - context.run('mkdocs serve', pty=True) - - -namespace.add_task(livehtml) - - -##### -# -# build and distribute -# -##### -BUILDDIR = 'build' -DISTDIR = 'dist' - - -@invoke.task() -def build_clean(context: Context) -> None: - """Remove the build directory.""" - # pylint: disable=unused-argument - with context.cd(TASK_ROOT_STR): - rmrf(BUILDDIR) - - -namespace_clean.add_task(build_clean, 'build') - - -@invoke.task() -def dist_clean(context: Context) -> None: - """Remove the dist directory.""" - # pylint: disable=unused-argument - with context.cd(TASK_ROOT_STR): - rmrf(DISTDIR) - - -namespace_clean.add_task(dist_clean, 'dist') - - -@invoke.task() -def eggs_clean(context: Context) -> None: - """Remove egg directories.""" - # pylint: disable=unused-argument - with context.cd(TASK_ROOT_STR): - dirs = set() - dirs.add('.eggs') - for name in os.listdir(os.curdir): - if name.endswith('.egg-info'): - dirs.add(name) - if name.endswith('.egg'): - dirs.add(name) - rmrf(dirs) - - -namespace_clean.add_task(eggs_clean, 'eggs') - - -@invoke.task() -def pycache_clean(context: Context) -> None: - """Remove __pycache__ directories.""" - # pylint: disable=unused-argument - with context.cd(TASK_ROOT_STR): - dirs = set() - for root, dirnames, _ in os.walk(os.curdir): - if '__pycache__' in dirnames: - dirs.add(os.path.join(root, '__pycache__')) - print("Removing __pycache__ directories") - rmrf(dirs, verbose=False) - - -namespace_clean.add_task(pycache_clean, 'pycache') - - -# ruff fast linter -@invoke.task() -def lint(context: Context) -> None: - """Run ruff fast linter.""" - with context.cd(TASK_ROOT_STR): - context.run("ruff check") - - -namespace.add_task(lint) - - -# ruff fast formatter -@invoke.task() -def format(context: Context) -> None: # noqa: A001 - """Run ruff format --check.""" - with context.cd(TASK_ROOT_STR): - context.run("ruff format --check") - - -namespace.add_task(format) - - -@invoke.task() -def ruff_clean(context: Context) -> None: - """Remove .ruff_cache directory.""" - with context.cd(TASK_ROOT_STR): - context.run("ruff clean") - - -namespace_clean.add_task(ruff_clean, 'ruff') - -# -# make a dummy clean task which runs all the tasks in the clean namespace -clean_tasks = list(namespace_clean.tasks.values()) - - -@invoke.task(pre=clean_tasks, default=True) -def clean_all(_: Context) -> None: - """Run all clean tasks.""" - # pylint: disable=unused-argument - - -namespace_clean.add_task(clean_all, 'all') - - -@invoke.task -def tag(context: Context, name: str, message: str = '') -> None: - """Add a Git tag and push it to origin.""" - # If a tag was provided on the command-line, then add a Git tag and push it to origin - if name: - context.run(f'git tag -a {name} -m {message!r}') - context.run(f'git push origin {name}') - - -namespace.add_task(tag) - - -@invoke.task() -def validatetag(context: Context) -> None: - """Check to make sure that a tag exists for the current HEAD and it looks like a valid version number.""" - # Validate that a Git tag exists for the current commit HEAD - result = context.run("git describe --exact-match --tags $(git log -n1 --pretty='%h')") - git_tag = result.stdout.rstrip() - - # Validate that the Git tag appears to be a valid version number - ver_regex = re.compile(r'(\d+)\.(\d+)\.(\d+)((a|b|rc)(\d+))?') - found = ver_regex.fullmatch(git_tag) - if found is None: - print(f'Tag {git_tag!r} does not appear to be a valid version number') - sys.exit(-1) - else: - print(f'Tag {git_tag!r} appears to be a valid version number') - - -namespace.add_task(validatetag)