From 8ec9cf9601df94ef588c074159de8ddc88a866b8 Mon Sep 17 00:00:00 2001 From: Joao Coelho Date: Fri, 13 Mar 2026 19:33:44 -0500 Subject: [PATCH 1/6] Adopt uv, ruff and typer-invoke (#16, #17) - Replace pip-tools with uv for dependency management - Replace black/isort/flake8 with ruff for linting/formatting - Replace invoke with typer-invoke for task running - Add admin/ package with build, lint, pip, test task modules - Move requirements files to admin/requirements/ - Update pyproject.toml: uv_build backend, ruff config, typer-invoke config - Update GitHub Actions workflows to use uv - Update README.md, docs/develop.md, docs/publish.md - Delete tasks.py and root-level requirements files - Add WARP.md with development workflow docs Co-Authored-By: Oz --- .github/workflows/linting.yml | 25 +- .github/workflows/tests.yml | 18 +- README.md | 13 + WARP.md | 109 +++++ admin/__init__.py | 5 + admin/build.py | 444 ++++++++++++++++++ admin/lint.py | 48 ++ admin/pip.py | 196 ++++++++ admin/requirements/requirements-dev.in | 11 + admin/requirements/requirements-dev.txt | 55 +++ .../requirements/requirements.in | 0 admin/requirements/requirements.txt | 12 + admin/test.py | 31 ++ admin/utils.py | 202 ++++++++ docs/develop.md | 49 +- docs/publish.md | 34 +- pyproject.toml | 75 ++- requirements-dev.in | 18 - requirements-dev.txt | 117 ----- requirements.txt | 18 - tasks.py | 399 ---------------- 21 files changed, 1221 insertions(+), 658 deletions(-) create mode 100644 WARP.md create mode 100644 admin/__init__.py create mode 100644 admin/build.py create mode 100644 admin/lint.py create mode 100644 admin/pip.py create mode 100644 admin/requirements/requirements-dev.in create mode 100644 admin/requirements/requirements-dev.txt rename requirements.in => admin/requirements/requirements.in (100%) create mode 100644 admin/requirements/requirements.txt create mode 100644 admin/test.py create mode 100644 admin/utils.py delete mode 100644 requirements-dev.in delete mode 100644 requirements-dev.txt delete mode 100644 requirements.txt delete mode 100644 tasks.py diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 814b3f1..80fcb12 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -10,17 +10,22 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v5 + - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.10' + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Create virtual environment + run: uv venv + - name: Install requirements - run: pip install -r requirements-dev.txt - - name: isort - run: isort . - - name: black - run: black . - - name: flake8 - run: flake8 . - - name: mypy - run: mypy . + run: | + uv pip install typer-invoke + uv run inv pip install dev + + - name: Linting + run: uv run inv lint all diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 493d5d4..57e92d5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,15 +21,17 @@ jobs: uses: actions/setup-python@v6 with: python-version: '${{ matrix.python-version }}' + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Create virtual environment + run: uv venv + - name: Install requirements run: | - python -m pip install -U pip - python -m pip install "pytest>=${{ matrix.pytest-version }},<$(( ${{ matrix.pytest-version }} + 1 ))" - python -m pip install flit - - name: Install package - run: | - flit install + uv pip install "pytest>=${{ matrix.pytest-version }},<$(( ${{ matrix.pytest-version }} + 1 ))" + uv pip install --editable . + - name: Run tests # Test folder(s) and other pytest options configured in `pyproject.toml` - run: | - python -m pytest . + run: uv run python -m pytest . diff --git a/README.md b/README.md index c28d27d..9b3e9ac 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,19 @@ Under [docs](https://github.com/joaonc/pytest-params/tree/main/docs): $ pip install pytest-params ``` +## Development and testing + +[`uv`](https://docs.astral.sh/uv/) is required for development. Install it first, then set up +the environment: + +```bash +uv venv +uv pip install -r admin/requirements/requirements-dev.txt +``` + +See [docs/develop.md](https://github.com/joaonc/pytest-params/blob/main/docs/develop.md) for +detailed development instructions. + ## Examples Some examples are provided here. For more examples with advanced usage, please see the test cases under `tests`. diff --git a/WARP.md b/WARP.md new file mode 100644 index 0000000..f76eee2 --- /dev/null +++ b/WARP.md @@ -0,0 +1,109 @@ +# WARP.md + +This file provides guidance to WARP (warp.dev) when working with code in this repository. + +## Project Overview + +A library that provides simplified pytest test case parameters via the `@params` decorator and +the `get_request_param` function. Helps make parametrized tests more readable and declarative. + +## Development Commands + +Always use `uv`. Do not use `pip` or `pip-tools`. + +### Task Runner +This project uses `typer-invoke` to organize development tasks into admin modules. The +configuration is in `pyproject.toml` under `[tool.typer-invoke]`. + +Run tasks using Python module syntax: +```bash +python -m admin.lint --help +python -m admin.build --help +python -m admin.pip --help +python -m admin.test --help +``` + +### Linting and Formatting +All linter configurations are in `pyproject.toml`. + +Run all linters in sequence: +```bash +python -m admin.lint all +``` + +Run individual linters: +```bash +python -m admin.lint ruff . +python -m admin.lint mypy . +``` + +### Testing + +Run unit tests (installs the package temporarily): +```bash +python -m admin.test unit +``` + +### Building and Publishing + +Clean build artifacts: +```bash +python -m admin.build clean +``` + +Update version (interactive or with flags): +```bash +python -m admin.build version --bump patch +python -m admin.build version --version 1.2.3 +``` + +Build and publish package: +```bash +python -m admin.build publish +``` + +### Package Management + +Compile requirements files (uses uv): +```bash +python -m admin.pip compile +python -m admin.pip compile --clean +``` + +Sync environment with requirements: +```bash +python -m admin.pip sync +``` + +Install requirements: +```bash +python -m admin.pip install +``` + +## Code Architecture + +### Module Structure +``` +src/pytest_params/ +├── __init__.py # Package exports and version +├── params.py # @params decorator implementation +└── request_params.py # get_request_param() function + +admin/ # Development task modules +├── __init__.py # Project constants (PROJECT_ROOT, SOURCE_DIR) +├── utils.py # Shared utilities (run, logger, etc.) +├── build.py # Build, version, and publish tasks +├── lint.py # Linting tasks (ruff, mypy) +├── pip.py # Package management tasks +├── test.py # Test tasks +└── requirements/ # Requirements files (.in and .txt) + +tests/ # Test suite +``` + +## Code Style + +- Follow PEP8 with 100 character line limit (enforced by ruff) +- Single quotes for strings, triple double quotes for docstrings +- Python 3.12+ syntax and type hints (use `str | None` not `Optional[str]`) +- 4 spaces for indentation diff --git a/admin/__init__.py b/admin/__init__.py new file mode 100644 index 0000000..55a9659 --- /dev/null +++ b/admin/__init__.py @@ -0,0 +1,5 @@ +from pathlib import Path + +PROJECT_ROOT = Path(__file__).parents[1] +PROJECT_NAME = PROJECT_ROOT.name.replace('-', '_') # 'pytest_params' +SOURCE_DIR = PROJECT_ROOT / 'src' / PROJECT_NAME diff --git a/admin/build.py b/admin/build.py new file mode 100644 index 0000000..a64dcfb --- /dev/null +++ b/admin/build.py @@ -0,0 +1,444 @@ +#!python +""" +Build and publish package. +""" + +from pathlib import Path +from typing import Annotated + +import typer + +from admin import PROJECT_NAME, PROJECT_ROOT, SOURCE_DIR +from admin.utils import DryAnnotation, logger, run + +BUILD_DIST_DIR = PROJECT_ROOT / 'dist' +VERSION_FILES = [ + PROJECT_ROOT / 'pyproject.toml', + SOURCE_DIR / '__init__.py', +] +""" +Files that contain the package version. +This version needs to be updated with each release. +""" + +app = typer.Typer( + help=__doc__, + no_args_is_help=True, + add_completion=False, + rich_markup_mode='markdown', +) + + +def _update_project_version(version: str): + regex = r"""^([ _]*version[ _]*[:=] *['"])(.*)(['"].*)$""" + for file in VERSION_FILES: + _re_sub_file(file, regex, version) + + +def _get_project_version() -> str: + import re + + pattern = re.compile("""^[ _]*version[ _]*[:=] *['"](.*)['"]""", re.MULTILINE) + versions = {} + for file in VERSION_FILES: + with open(file) as f: + text = f.read() + match = pattern.search(text) + if not match: + logger.error(f'Could not find version in `{file.relative_to(PROJECT_ROOT)}`.') + raise typer.Exit(1) + versions[file] = match.group(1) + + if len(set(versions.values())) != 1: + logger.error( + 'Version mismatch in files that contain versions.\n' + + ( + '\n'.join( + f'{file.relative_to(PROJECT_ROOT)}: {version}' + for file, version in versions.items() + ) + ) + ) + raise typer.Exit(1) + + return list(versions.values())[0] + + +def _get_next_version(current_version, part): + from packaging.version import Version + + version = Version(str(current_version)) + + if part == 'major': + new_version = Version(f'{version.major + 1}.0.0') + elif part == 'minor': + new_version = Version(f'{version.major}.{version.minor + 1}.0') + elif part == 'patch': + new_version = Version(f'{version.major}.{version.minor}.{version.micro + 1}') + else: + raise ValueError('`part` must be "major", "minor", or "patch"') + + return new_version + + +def _re_sub_file(file: str | Path, regex: str, repl: str, save: bool = True) -> str: + """ + Regex search/replace text in a file. + + :param file: File to update. + :param regex: Regex pattern, as a string. + The regex needs to return 3 capturing groups: text before, text to replace, text after + (per line). + :param repl: Text to replace with. + :param save: Whether to save the file with the new text. + :return: Updated text. + """ + import re + + pattern = re.compile(regex, re.MULTILINE) + with open(file) as f: + text = f.read() + new_text = pattern.sub(lambda match: f'{match.group(1)}{repl}{match.group(3)}', text) + + if save: + with open(file, 'w') as f: + f.write(new_text) + + return new_text + + +def _get_release_name_and_tag(version: str) -> tuple[str, str]: + """ + Generate release name and tag based on the version. + + :return: Tuple with release name (ex 'v1.2.3') and tag (ex '1.2.3'). + """ + return f'v{version}', version + + +def _get_version_from_release_name(release_name: str) -> str: + if not release_name.startswith('v'): + logger.error(f'Invalid release name: {release_name}') + raise typer.Exit(1) + return release_name[1:] + + +def _get_latest_release() -> tuple[str, str, list[dict]]: + """ + Retrieves the latest release from GitHub. + + :return: Tuple with: release name (ex 'v1.2.3'), tag (ex '1.2.3') and list of assets uploaded. + """ + import json + + release_info_json = run( + 'gh', + 'release', + 'view', + '--json', + 'name,tagName,assets', + dry=False, + capture_output=True, + ).stdout.strip() + release_info = json.loads(release_info_json) + return release_info['name'], release_info['tagName'], release_info['assets'] + + +def _get_branch(): + """Returns the current branch.""" + return run( + 'git', + 'branch', + '--show-current', + dry=False, + capture_output=True, + text=True, + ).stdout.strip() + + +def _get_default_branch(): + """Returns the default branch (usually ``main``).""" + return run( + 'gh', + 'repo', + 'view', + '--json', + 'defaultBranchRef', + '--jq', + '.defaultBranchRef.name', + dry=False, + capture_output=True, + text=True, + ).stdout.strip() + + +def _commit(message: str, dry: bool): + # Commit + run('git', 'add', *VERSION_FILES, dry=dry) + run('git', 'commit', '-m', message, dry=dry) + + # Push current branch + branch = _get_branch() + run('git', 'push', 'origin', branch, dry=dry) + + +def _create_pr(title: str, description: str, dry: bool): + """ + Creates a PR in GitHub and merges it after checks pass. + + If checks fail, the PR will remain open and will need to be dealt with manually. + """ + # Create PR + default_branch = _get_default_branch() + branch = _get_branch() + run( + 'gh', + 'pr', + 'create', + '--title', + title, + '--body', + description, + '--head', + branch, + '--base', + default_branch, + dry=dry, + ) + + # Merge PR after checks pass + run('gh', 'pr', 'merge', branch, '--squash', '--auto', dry=dry) + + +@app.command(name='clean') +def build_clean(dry: DryAnnotation = False): + """ + Clean build files. + """ + from admin.utils import OS, get_os + + paths = [f'{PROJECT_NAME}.egg-info', 'dist', 'build'] + if get_os() is OS.Windows: + # Assuming PowerShell 6.0+ + full_command = ( + 'Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -Path ' + ', '.join(paths) + ) + run('pwsh', '-Command', full_command, dry=dry, check=False) + else: + run('rm', '-rf', *paths, dry=dry) + + +@app.command(name='version') +def build_version( + version: Annotated[ + str, + typer.Option( + help='Version in semantic versioning format (ex 1.5.0). ' + 'If `version` is set, then `bump` cannot be used.', + show_default=False, + ), + ] = '', + bump: Annotated[ + str, + typer.Option( + help='Portion of the version to increase, can be "major", "minor", or "patch". ' + 'If `bump` is set, then `version` cannot be used.', + show_default=False, + ), + ] = '', + mode: Annotated[ + str, + typer.Option( + help='What do do after the files are updated:\n' + '`nothing`: do nothing and the changes are not committed (default).\n' + '`commit`: commit and push the changes with the message "bump version".\n' + '`pr`: Commit, push, create and merge PR after checks pass.' + ), + ] = 'nothing', + yes: Annotated[ + bool, + typer.Option( + help="Don't ask confirmation to create new branch if necessary.", + show_default=False, + ), + ] = False, + dry: DryAnnotation = False, +): + """ + Updates the files that contain the project version to the new version. + + Optionally, commit the changes, create a PR and merge it after checks pass. + """ + from packaging.version import Version + + mode = mode.strip().lower() + if mode not in ['nothing', 'commit', 'pr']: + logger.error('Invalid `mode` choice.') + raise typer.Exit(1) + + v1 = Version(_get_project_version()) + if version and bump: + logger.error('Either `version` or `bump` can be set, not both.') + raise typer.Exit(1) + if not (version or bump): + try: + bump = {'1': 'major', '2': 'minor', '3': 'patch'}[ + input( + f'Current version is `{v1}`, which portion to bump?' + '\n1 - Major\n2 - Minor\n3 - Patch\n> ' + ) + ] + except KeyError: + logger.error('Invalid choice') + raise typer.Exit(1) + + if version: + v2 = Version(version) + if v2 <= v1: + logger.error( + f'New version `{v2}` needs to be greater than the existing version `{v1}`.' + ) + raise typer.Exit(1) + else: + try: + v2 = _get_next_version(v1, bump.strip().lower()) + except AttributeError: + logger.error('Invalid `bump` choice.') + raise typer.Exit(1) + + # Verify branch is not default + branch = _get_branch() + default_branch = _get_default_branch() + if branch == default_branch: + branch_ok = False + if yes or input( + f'Current branch `{branch}` is the default branch, create new branch? [Y/n] ' + ).strip().lower() in ['', 'y', 'yes']: + run('git', 'checkout', '-b', f'release-{v2}', dry=dry) + branch_ok = True + if not branch_ok: + logger.error(f'Cannot make changes in the default branch `{branch}`.') + raise typer.Exit(1) + + # Update files to new version + _update_project_version(str(v2)) + print( + f'New version is `{v2}`. Modified files:\n' + + '\n'.join(f' {file.relative_to(PROJECT_ROOT)}' for file in VERSION_FILES) + ) + + # Commit/push/pr + if mode == 'nothing': + print('Files not committed, PR not created.') + if mode in ['commit', 'pr']: + print('Commit and push changes.') + _commit(f'bump version to {v2}', dry) + if mode == 'pr': + pr_title = f'Release {v2}' + print(f'Create and merge PR `{pr_title}`.') + _create_pr(pr_title, f'Preparing for release {v2}', dry) + + +@app.command(name='publish') +def build_publish( + upload: Annotated[bool, typer.Option(help='Upload to Pypi.')] = True, + yes: Annotated[ + bool, + typer.Option(help="Don't request confirmation to publish to Pypi.", show_default=False), + ] = False, + dry: DryAnnotation = False, +): + """ + Build package and publish (upload) to Pypi. + + Output in ``dist`` folder. + """ + build_clean(dry) + + # Create distribution files (source and wheel) + run('uv', 'build', dry=dry) + + # Upload to pypi + if upload: + if ( + yes + or input(f'Publishing version `{_get_project_version()}` to Pypi. Press Y to confirm. ') + .strip() + .lower() + == 'y' + ): + run('uv', 'publish', dry=dry) + else: + print('Package not published to Pypi.') + + +@app.command(name='release') +def build_release( + notes: Annotated[str, typer.Option(help='Release notes.', show_default=False)] = '', + notes_file: Annotated[ + str, + typer.Option( + help='Read release notes from file. Ignores the `--notes` parameter.', + show_default=False, + ), + ] = '', + dry: DryAnnotation = False, +): + """ + Create a release and tag in GitHub from the current project version. + """ + from packaging.version import Version + + if not notes and not notes_file: + response = input('No release notes or notes file specified, continue? [Y/n]') + response = response.strip().lower() or 'y' + if response not in ['yes', 'y']: + raise typer.Exit('No release notes specified.') + + # Check that there's no release with the current version + version = Version(_get_project_version()) + latest_release, latest_tag, _ = _get_latest_release() + latest_version = Version(_get_version_from_release_name(latest_release)) + if str(latest_version) != latest_tag: + logger.error( + f'Invalid format in latest release or tag: Release: {latest_release}, ' + f'Tag: {latest_tag}' + ) + raise typer.Exit(1) + + if latest_version >= version: + logger.error( + f'Release/tag version being created ({version}) needs to be greater than the current ' + f'latest release version ({latest_version}).' + ) + raise typer.Exit(1) + + # Create release + new_release, new_tag = _get_release_name_and_tag(str(version)) + notes_file_arg = '' + if notes_file: + notes_file_arg = str(Path(notes_file).resolve(strict=True)) + + response = input(f'Creating GitHub release `{new_release}`. Press Y to confirm. ') + if response.lower().strip() == 'y': + run( + 'gh', + 'release', + 'create', + new_tag, + '--title', + new_release, + '--generate-notes', + '--notes' if notes else '', + notes if notes else '', + '--notes-file' if notes_file_arg else '', + notes_file_arg, + dry=dry, + ) + print('GitHub release created.') + else: + print('GitHub release not created.') + + +if __name__ == '__main__': + app() diff --git a/admin/lint.py b/admin/lint.py new file mode 100644 index 0000000..8a9cc35 --- /dev/null +++ b/admin/lint.py @@ -0,0 +1,48 @@ +#!python +""" +Linting and static type checking. +""" + +from typing import Annotated + +import typer + +from admin.utils import DryAnnotation, logger, run + +app = typer.Typer( + help=__doc__, + no_args_is_help=True, + add_completion=False, + rich_markup_mode='markdown', +) + + +@app.command(name='ruff') +def lint_ruff( + path: Annotated[str, typer.Argument(help='Path to directory or file to lint.')] = '.', + dry: DryAnnotation = False, +): + run('ruff', 'format', path, dry=dry) + run('ruff', 'check', '--fix', path, dry=dry) + + +@app.command(name='mypy') +def lint_mypy(path='.', dry: DryAnnotation = False): + run('mypy', path, dry=dry) + + +@app.command(name='all') +def lint_all(dry: DryAnnotation = False): + """ + Run all linters. + + Config for each of the tools is in ``pyproject.toml``. + """ + lint_ruff(dry=dry) + lint_mypy(dry=dry) + + logger.info('Done') + + +if __name__ == '__main__': + app() diff --git a/admin/pip.py b/admin/pip.py new file mode 100644 index 0000000..e502c1d --- /dev/null +++ b/admin/pip.py @@ -0,0 +1,196 @@ +#!python +""" +Python packages related tasks. +""" + +from enum import StrEnum +from pathlib import Path +from typing import Annotated + +import typer + +from admin import PROJECT_ROOT +from admin.utils import DryAnnotation, logger, multiple_parameters, run + +REQUIREMENTS_DIR = PROJECT_ROOT / 'admin' / 'requirements' + +app = typer.Typer( + help=__doc__, + no_args_is_help=True, + add_completion=False, + rich_markup_mode='markdown', +) + + +class Requirements(StrEnum): + """ + Requirements files. + + Order matters as most operations with multiple files need ``requirements.txt`` to be processed + first. + Add new requirements files here. + """ + + MAIN = 'requirements' + DEV = 'requirements-dev' + + +class RequirementsType(StrEnum): + IN = 'in' + OUT = 'txt' + + +REQUIREMENTS_TASK_HELP = { + 'requirements': '`.in` file. Full name not required, just the initial name after the dash ' + f'(ex. "{Requirements.DEV.name}"). For main file use "{Requirements.MAIN.name}". ' + f'Available requirements: {", ".join(Requirements)}.' +} + +RequirementsAnnotation = Annotated[ + list[str] | None, + typer.Argument( + help='Requirement file(s) to compile. If not set, all files are compiled.\nValues can be ' + + ', '.join([f'`{x.name.lower()}`' for x in Requirements]), + show_default=False, + ), +] + + +def _get_requirements_file( + requirements: str | Requirements, requirements_type: str | RequirementsType +) -> Path: + """Return the full requirements file path.""" + if isinstance(requirements, Requirements): + reqs = requirements + else: + try: + reqs = Requirements[requirements.upper()] # noqa + except KeyError: + try: + reqs = Requirements(requirements.lower()) + except ValueError: + logger.error(f'`{requirements}` is an unknown requirements file.') + raise typer.Exit(1) + + if isinstance(requirements_type, RequirementsType): + reqs_type = requirements_type + else: + reqs_type = RequirementsType(requirements_type.lstrip('.').lower()) + + return REQUIREMENTS_DIR / f'{reqs}.{reqs_type}' + + +def _get_requirements_files( + requirements: list[str | Requirements] | None, requirements_type: str | RequirementsType +) -> list[Path]: + """Get full filename+extension and sort by the order defined in ``Requirements``""" + requirements_files = list(Requirements) if requirements is None else requirements + return [_get_requirements_file(r, requirements_type) for r in requirements_files] + + +@app.command(name='compile') +def pip_compile( + requirements: RequirementsAnnotation = None, + clean: Annotated[ + bool, + typer.Option( + help=f'Delete the existing requirements `{RequirementsType.OUT.value}` files, forcing ' + f'a clean compilation.' + ), + ] = False, + dry: DryAnnotation = False, +): + """ + Compile requirements file(s). + """ + if clean and not dry: + for filename in _get_requirements_files(requirements, RequirementsType.OUT): + filename.unlink(missing_ok=True) + + for filename in _get_requirements_files(requirements, RequirementsType.IN): + output_file = filename.with_suffix('.txt') + run( + 'uv', + 'pip', + 'compile', + '--no-header', + '--no-strip-extras', + filename.name, + '-o', + output_file.name, + dry=dry, + cwd=REQUIREMENTS_DIR, + ) + + +@app.command(name='sync') +def pip_sync(requirements: RequirementsAnnotation = None, dry: DryAnnotation = False): + """ + Synchronize environment with requirements file. + """ + run('uv', 'pip', 'sync', *_get_requirements_files(requirements, RequirementsType.OUT), dry=dry) + + +@app.command(name='package') +def pip_package( + requirements: RequirementsAnnotation, + package: Annotated[ + list[str], typer.Option('--package', '-p', help='One or more packages to upgrade.') + ], + dry: DryAnnotation = False, +): + """ + Upgrade one or more packages. + """ + for filename in _get_requirements_files(requirements, RequirementsType.IN): + output_file = filename.with_suffix('.txt') + run( + 'uv', + 'pip', + 'compile', + *multiple_parameters('--upgrade-package', *package), + filename.name, + '-o', + output_file.name, + dry=dry, + cwd=REQUIREMENTS_DIR, + ) + + +@app.command(name='upgrade') +def pip_upgrade(requirements: RequirementsAnnotation = None, dry: DryAnnotation = False): + """ + Try to upgrade all dependencies to their latest versions. + + Equivalent to ``compile`` with ``--clean`` option. + + Use ``package`` to only upgrade individual packages, + Ex ``pip package dev mypy ruff``. + """ + for filename in _get_requirements_files(requirements, RequirementsType.IN): + output_file = filename.with_suffix('.txt') + run( + 'uv', + 'pip', + 'compile', + '--no-strip-extras', + '--upgrade', + filename.name, + '-o', + output_file.name, + dry=dry, + cwd=REQUIREMENTS_DIR, + ) + + +@app.command(name='install') +def pip_install(requirements: RequirementsAnnotation = None, dry: DryAnnotation = False): + """ + Equivalent to ``uv pip install -r ``. + """ + requirements_files = _get_requirements_files(requirements, RequirementsType.OUT) + run('uv', 'pip', 'install', *multiple_parameters('-r', *requirements_files), dry=dry) + + +if __name__ == '__main__': + app() diff --git a/admin/requirements/requirements-dev.in b/admin/requirements/requirements-dev.in new file mode 100644 index 0000000..62a5510 --- /dev/null +++ b/admin/requirements/requirements-dev.in @@ -0,0 +1,11 @@ +-r requirements.txt + +# Dev tools +typer-invoke # Tasks + +# Linting +mypy # Code inspector +ruff # Linter/formatter + +# Build and publish +packaging # For version management diff --git a/admin/requirements/requirements-dev.txt b/admin/requirements/requirements-dev.txt new file mode 100644 index 0000000..0b29609 --- /dev/null +++ b/admin/requirements/requirements-dev.txt @@ -0,0 +1,55 @@ +annotated-doc==0.0.4 + # via typer +click==8.3.1 + # via typer +colorama==0.4.6 + # via + # -r admin/requirements/requirements.txt + # click + # pytest +iniconfig==2.3.0 + # via + # -r admin/requirements/requirements.txt + # pytest +librt==0.8.1 + # via mypy +markdown-it-py==4.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +mypy==1.19.1 + # via -r admin/requirements/requirements-dev.in +mypy-extensions==1.1.0 + # via mypy +packaging==26.0 + # via + # -r admin/requirements/requirements-dev.in + # -r admin/requirements/requirements.txt + # pytest +pathspec==1.0.4 + # via mypy +pluggy==1.6.0 + # via + # -r admin/requirements/requirements.txt + # pytest +pygments==2.19.2 + # via + # -r admin/requirements/requirements.txt + # pytest + # rich +pytest==9.0.2 + # via -r admin/requirements/requirements.txt +rich==14.3.3 + # via + # typer + # typer-invoke +ruff==0.15.6 + # via -r admin/requirements/requirements-dev.in +shellingham==1.5.4 + # via typer +typer==0.24.1 + # via typer-invoke +typer-invoke==0.5.0 + # via -r admin/requirements/requirements-dev.in +typing-extensions==4.15.0 + # via mypy diff --git a/requirements.in b/admin/requirements/requirements.in similarity index 100% rename from requirements.in rename to admin/requirements/requirements.in diff --git a/admin/requirements/requirements.txt b/admin/requirements/requirements.txt new file mode 100644 index 0000000..2123b12 --- /dev/null +++ b/admin/requirements/requirements.txt @@ -0,0 +1,12 @@ +colorama==0.4.6 + # via pytest +iniconfig==2.3.0 + # via pytest +packaging==26.0 + # via pytest +pluggy==1.6.0 + # via pytest +pygments==2.19.2 + # via pytest +pytest==9.0.2 + # via -r admin/requirements/requirements.in diff --git a/admin/test.py b/admin/test.py new file mode 100644 index 0000000..e1d1206 --- /dev/null +++ b/admin/test.py @@ -0,0 +1,31 @@ +#!python +""" +Test tasks. +""" + +import typer + +from admin.utils import DryAnnotation, run + +app = typer.Typer( + help=__doc__, + no_args_is_help=True, + add_completion=False, + rich_markup_mode='markdown', +) + + +@app.command(name='unit') +def test_unit(dry: DryAnnotation = False): + """ + Run unit tests. + + Temporarily installs the ``pytest-params`` package in editable mode. + """ + run('uv', 'pip', 'install', '--editable', '.', dry=dry) + run('python', '-m', 'pytest', dry=dry) + run('uv', 'pip', 'uninstall', 'pytest-params', dry=dry) + + +if __name__ == '__main__': + app() diff --git a/admin/utils.py b/admin/utils.py new file mode 100644 index 0000000..b4b6234 --- /dev/null +++ b/admin/utils.py @@ -0,0 +1,202 @@ +import logging +import os +import subprocess +import sys +from dataclasses import dataclass +from enum import StrEnum +from itertools import chain +from typing import Annotated + +import typer +from rich.console import Console +from rich.logging import RichHandler +from rich.text import Text + +from admin import PROJECT_ROOT + +EMPTY_STR = object() +"""Sentinel object to represent an empty string.""" + + +class OS(StrEnum): + """Operating System.""" + + Linux = 'linux' + MacOS = 'mac' + Windows = 'win' + + +class LogLevel(StrEnum): + """Enum for typer options.""" + + ERROR = 'ERROR' + WARNING = 'WARNING' + INFO = 'INFO' + DEBUG = 'DEBUG' + + +class NoHighlightRichHandler(RichHandler): + """Custom RichHandler that completely disables highlighting.""" + + def render_message(self, record, message): + """Override to disable auto-highlighting while keeping markup.""" + from rich.text import Text + + # Process markup but don't apply highlighting + if self.markup: + return Text.from_markup(message) + return Text(message) + + +@dataclass +class StripOutput: + strip_ansi: bool = True + normal_strip: bool = True + extra_chars: str | None = None + + def strip(self, text: str) -> str: + if self.strip_ansi: + text = strip_ansi(text) + if self.normal_strip: + text = text.strip() + if self.extra_chars: + text = text.strip(self.extra_chars) + + return text + + +LogLevelAnnotation = Annotated[ + LogLevel, + typer.Option( + help='Log level.', + show_default=True, + case_sensitive=True, + show_choices=True, + ), +] + +DryAnnotation = Annotated[ + bool, + typer.Option( + help='Show the command that would be run without running it.', + show_default=False, + ), +] + + +def get_os() -> OS: + """ + Similar to ``sys.platform`` and ``platform.system()``, but less ambiguous by returning an Enum + instead of a string. + + Doesn't make granular distinctions of linux variants, OS versions, etc. + """ + if sys.platform == 'darwin': + return OS.MacOS + if sys.platform == 'win32': + return OS.Windows + return OS.Linux + + +def run( + *args, + dry: bool = False, + extra_env: dict[str, str] | None = None, + strip_output: StripOutput | None = StripOutput(), + **kwargs, +) -> subprocess.CompletedProcess | None: + """ + Run a CLI command synchronously (i.e., wait for the command to finish) and return the result. + + This function is a wrapper around ``subprocess.run(...)``. + + If you need access to the output, add the ``capture_output=True`` argument and do + ``.stdout`` to get the output as a string. + + Notes: + + * Args are converted to strings using ``str(...)``. + * Empty strings and ``None`` are removed from the command. + If you want to explicitly include an empty string, use ``EMPTY_STR`` instead. + * ``stdout`` and ``stderr`` will be stripped of ANSI escape sequences by default. + """ + final_args: list[str] = [] + for arg in args: + if not arg: + continue + if arg == EMPTY_STR: + final_args.append('') + else: + final_args.append(str(arg)) + logger.info(' '.join(f'"{a}"' if (not a or ' ' in a) else a for a in final_args)) + + if dry: + return None + + defaults = dict( + cwd=PROJECT_ROOT, + capture_output=False, + text=True, + check=True, + env=os.environ.copy() | (extra_env or {}), + ) + final_kwargs = defaults | kwargs + + try: + result = subprocess.run(final_args, **final_kwargs) # type: ignore + except subprocess.CalledProcessError as e: + msg = str(e) + if e.stdout: + msg += f'\nSTDOUT:\n{e.stdout}' + if e.stderr: + msg += f'\nSTDERR:\n{e.stderr}' + logger.error(msg) + raise typer.Exit(1) + + if final_kwargs.get('capture_output') and strip_output: + result.stdout = strip_output.strip(result.stdout) + result.stderr = strip_output.strip(result.stderr) + + return result # type: ignore + + +def multiple_parameters(parameter: str, *options) -> list[str]: + return list(chain.from_iterable(zip([parameter] * len(options), map(str, options)))) + + +def strip_ansi(text: str) -> str: + return Text.from_ansi(text).plain + + +def get_logger(name: str | None = 'typer-invoke', level=logging.DEBUG) -> logging.Logger: + """Set up logging configuration with Rich handler and custom formatting.""" + + _logger = logging.getLogger(name) + _logger.setLevel(level) + _logger.handlers.clear() + + console = Console(markup=True) + + handler = NoHighlightRichHandler( + level=level, + console=console, + show_time=False, + show_level=True, + show_path=False, + markup=True, + rich_tracebacks=False, + ) + + formatter = logging.Formatter(fmt='%(message)s', datefmt='[%X]') + handler.setFormatter(formatter) + _logger.addHandler(handler) + _logger.propagate = False + + return _logger + + +def set_log_level(level: LogLevel): + logger.setLevel(level.value) + + +logger = get_logger() diff --git a/docs/develop.md b/docs/develop.md index 9f1e086..35eead3 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -1,43 +1,32 @@ # Development ## Requirements -Start by installing all required packages: -``` -pip install -r requirements-dev.txt +[`uv`](https://docs.astral.sh/uv/) is required. Install it first, then set up the environment: +```bash +uv venv +uv pip install -r admin/requirements/requirements-dev.txt ``` ## Tasks -This project uses **pyinvoke** ([main page](https://www.pyinvoke.org/) | [docs](https://docs.pyinvoke.org/en/stable/) | -[GitHub](https://github.com/pyinvoke/invoke)) to facilitate executing miscellaneous tasks that help -with development (similar to `make`, but in Python). - -### Using invoke -After the installing the dev requirements (which include `invoke`), try the commands below. +This project uses **typer-invoke** ([GitHub](https://github.com/joaonc/typer-invoke)) to +facilitate executing miscellaneous tasks that help with development. -* List all available tasks - ``` - inv --list - ``` +### Using typer-invoke +After installing the dev requirements (which include `typer-invoke`), try the commands below. - Tasks are grouped (those that have a `.`). To see all the _lint_ tasks: +* Help for a module ``` - inv --list lint + python -m admin.lint --help + python -m admin.build --help + python -m admin.pip --help + python -m admin.test --help ``` -* Help with a certain task +* Help with a specific command ``` - inv --help pip.package + python -m admin.pip compile --help ``` -* Use `--dry` to see what the task does without executing it. +* Use `--dry` to see what a command does without executing it. -### Debugging tasks -To debug `tasks.py` (the file used by `invoke`), either add a `breakpoint()` statement or, if using -an IDE (in this example PyCharm), use the configuration below to allow setting breakpoints in the -code and debug `tasks.py` as any other Python file. - -![PyCharm tasks run config](images/pycharm_tasks_run_config.png) - -Replace script path to have the project's virtual environment. - -## Tests +## Development and testing [pytest](https://docs.pytest.org/en/stable/) is used to run the tests. Given that we're testing a fixture for pytest tests, some of the tests (modules ending with @@ -46,6 +35,6 @@ That is done using [pytester](https://docs.pytest.org/en/stable/reference/refere See test cases implementation on how `pytester` is used and the report output analyzed. To run tests: -``` -inv test.unit +```bash +python -m admin.test unit ``` diff --git a/docs/publish.md b/docs/publish.md index a85199a..4013cfe 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -1,19 +1,16 @@ # Publishing package ## Requirements -* `flit` - For the publishing process, [flit](https://flit.pypa.io/en/stable/) is used to make it simpler. - Included in `requirements-dev.txt`. +* `uv` + For the publishing process, [`uv`](https://docs.astral.sh/uv/) is used. + Install it separately (not via pip). * Pypi account and API token To publish to Pypi, an account is needed and an API token generated. Go to [https://pypi.org](https://pypi.org/) and follow instructions. -* `.pypirc` file - Create the file `.pypirc` in the home folder with the token: +* `UV_PUBLISH_TOKEN` environment variable + Set the Pypi token as an environment variable before publishing: ``` - [pypi] - username = __token__ - password = pypi-AhEIc...ktllA + $env:UV_PUBLISH_TOKEN = 'pypi-AhEIc...ktllA' ``` - Note that using username/password has been disabled in Pypi. Need to use token. * GitHub CLI This project uses [GitHub CLI](https://cli.github.com/) ([docs](https://cli.github.com/manual/)) in the release process. @@ -28,28 +25,27 @@ ``` ## Workflow -To further simplify, [invoke](https://www.pyinvoke.org/) tasks were added. For a full list of build related tasks: -``` -inv --list build +```bash +python -m admin.build --help ``` 1. Set/bump the version - ``` - inv build.version + ```bash + python -m admin.build version ``` This will modify the version in the required files but not commit the changes. 2. Create and merge a PR with the new version. Call it, for example, _"Release 1.2.3"_. 3. Build and publish to Pypi. This command builds the package locally (in the `dist` folder) and publish (upload) to Pypi. + ```bash + python -m admin.build publish ``` - inv build.publish - ``` - To only build the package without uploading to Pypi, use the `--no-upload` option. + To only build without uploading to Pypi, use the `--no-upload` option. 4. Create GitHub release and tag. - ``` - inv build.release + ```bash + python -m admin.build release ``` This will: * Create a tag in GitHub with the version, ex `1.2.3`. diff --git a/pyproject.toml b/pyproject.toml index 33b33b1..cdd7e99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,57 +1,47 @@ -[tool.black] +[tool.ruff] line-length = 100 -target-version = ['py310'] -skip-string-normalization = true -exclude=''' -( - \.venv.* - | \venv.* -) -''' +target-version = 'py312' +exclude = [ + '.venv*', + 'venv*', + 'build', + 'dist', +] + +[tool.ruff.lint] +select = ['E', 'F', 'W', 'I'] +# E266: too many leading '#' for block comment +# E501: line too long - handled by the formatter +# E701: multiple statements on one line (colon) - conflicts with empty class definitions +# formatted by ruff as `class FileUpdateError(Exception): ...` +# F811: redefinition of unused name from import (happens when importing pytest fixtures) +extend-ignore = ['E266', 'E501', 'E701', 'F811'] -[tool.isort] -line_length = 100 -profile = 'black' -sections = 'FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER' -skip_glob = ['**/.venv*', '**/venv*'] +[tool.ruff.lint.isort] +# Default section order: FUTURE, STDLIB, THIRDPARTY, FIRSTPARTY, LOCALFOLDER + +[tool.ruff.format] +quote-style = 'single' [tool.mypy] # Technical notes on exclusions: # 1 The regex for all folders needs to be in a one-line string. # 2 The `.` doesn't need to be escaped. Escape with `\\.` for a fully compatible regex. -exclude = '^venv*|^.venv*|.git|.eggs|build|dist|.cache|.pytest_cache|.mypy_cache|.vscode|.idea|tasks.py' -python_version = '3.10' +exclude = '^venv*|^.venv*|.git|.eggs|build|dist|.cache|.pytest_cache|.mypy_cache|.vscode|.idea' +python_version = '3.12' warn_return_any = true warn_unused_configs = true # Disable the warning below, from type hinting variables in a function. # By default, the bodies of untyped functions are not checked, consider using --check-untyped-defs disable_error_code = 'annotation-unchecked' -[[tool.mypy.overrides]] -ignore_missing_imports = true -module = ['invoke'] - -[tool.flake8] -max-line-length = 100 -# Errors being ignored: -# E203 is not PEP8 compliant and clashes with black -# E701,E704 multiple statements on one line (colon) -# Conflict with `black`, where empty class definitions (with Ellipsis) are formatted to be in -# one line in `black`, ex `class FileUpdateError(Exception): ...` -# W503 line break before binary operator clashes with black -# F811 redefinition of unused import happens when importing pytest fixtures in test modules -extend-ignore = ['E203', 'E266', 'E501', 'E701', 'E704', 'W503', 'F811'] -exclude = [ - '.git', '.github', '__pycache__', '.venv*', 'venv*', 'build', 'dist' -] - [tool.pytest.ini_options] testpaths = ['tests'] markers = ['flaky', 'nightly', 'performance', 'pri1'] [build-system] -requires = ['flit_core >=3.2,<4'] -build-backend = 'flit_core.buildapi' +requires = ['uv_build>=0.10.4,<0.11.0'] +build-backend = 'uv_build' [project] name = 'pytest-params' @@ -59,7 +49,9 @@ version = '0.3.0' description = 'Simplified pytest test case parameters.' readme = 'README.md' authors = [{name = 'Joao Coelho'}] -license = {file = 'LICENSE.txt'} +license = 'MIT' +license-files = ['LICENSE.txt'] +requires-python = '>=3.10' dependencies = ['pytest>=7.0.0'] # https://pypi.org/pypi?%3Aaction=list_classifiers classifiers = [ @@ -73,5 +65,10 @@ classifiers = [ [project.urls] Home = 'https://github.com/joaonc/pytest-params' -[tool.flit.module] -name = 'pytest_params' +[tool.typer-invoke] +modules = [ + 'admin.build', + 'admin.lint', + 'admin.pip', + 'admin.test', +] diff --git a/requirements-dev.in b/requirements-dev.in deleted file mode 100644 index 8a35876..0000000 --- a/requirements-dev.in +++ /dev/null @@ -1,18 +0,0 @@ --r requirements.txt - -# Dev tools -invoke # Tasks -pip-tools # Package management - -# Linting -black # Linter -flake8 # Linter -flake8-pyproject # Flake8 in pyproject.toml -isort # Linter -mypy # Code inspector - -# Test -pytest # Test framework - -# Build and publish -flit # Build, install and upload to Pypi diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index db880a8..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,117 +0,0 @@ -black==26.1.0 - # via -r requirements-dev.in -build==1.4.0 - # via pip-tools -certifi==2026.1.4 - # via requests -charset-normalizer==3.4.4 - # via requests -click==8.3.1 - # via - # black - # pip-tools -colorama==0.4.6 - # via - # -r requirements.txt - # build - # click - # pytest -docutils==0.22.4 - # via flit -exceptiongroup==1.3.1 - # via - # -r requirements.txt - # pytest -flake8==7.3.0 - # via - # -r requirements-dev.in - # flake8-pyproject -flake8-pyproject==1.2.4 - # via -r requirements-dev.in -flit==3.12.0 - # via -r requirements-dev.in -flit-core==3.12.0 - # via flit -idna==3.11 - # via requests -iniconfig==2.3.0 - # via - # -r requirements.txt - # pytest -invoke==2.2.1 - # via -r requirements-dev.in -isort==7.0.0 - # via -r requirements-dev.in -librt==0.7.8 - # via mypy -mccabe==0.7.0 - # via flake8 -mypy==1.19.1 - # via -r requirements-dev.in -mypy-extensions==1.1.0 - # via - # black - # mypy -packaging==25.0 - # via - # -r requirements.txt - # black - # build - # pytest -pathspec==1.0.3 - # via - # black - # mypy -pip-tools==7.5.2 - # via -r requirements-dev.in -platformdirs==4.5.1 - # via black -pluggy==1.6.0 - # via - # -r requirements.txt - # pytest -pycodestyle==2.14.0 - # via flake8 -pyflakes==3.4.0 - # via flake8 -pygments==2.19.2 - # via - # -r requirements.txt - # pytest -pyproject-hooks==1.2.0 - # via - # build - # pip-tools -pytest==9.0.2 - # via - # -r requirements-dev.in - # -r requirements.txt -pytokens==0.3.0 - # via black -requests==2.32.5 - # via flit -tomli==2.4.0 - # via - # -r requirements.txt - # black - # build - # flake8-pyproject - # mypy - # pip-tools - # pytest -tomli-w==1.2.0 - # via flit -typing-extensions==4.15.0 - # via - # -r requirements.txt - # black - # exceptiongroup - # mypy -urllib3==2.6.3 - # via requests -wheel==0.45.1 - # via pip-tools - -# The following packages are considered to be unsafe in a requirements file: -# pip -# setuptools diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 8d516d7..0000000 --- a/requirements.txt +++ /dev/null @@ -1,18 +0,0 @@ -colorama==0.4.6 - # via pytest -exceptiongroup==1.3.1 - # via pytest -iniconfig==2.3.0 - # via pytest -packaging==25.0 - # via pytest -pluggy==1.6.0 - # via pytest -pygments==2.19.2 - # via pytest -pytest==9.0.2 - # via -r requirements.in -tomli==2.4.0 - # via pytest -typing-extensions==4.15.0 - # via exceptiongroup diff --git a/tasks.py b/tasks.py deleted file mode 100644 index 195fe84..0000000 --- a/tasks.py +++ /dev/null @@ -1,399 +0,0 @@ -import os -import re -from pathlib import Path - -from invoke import Collection, Exit, task - -os.environ.setdefault('INVOKE_RUN_ECHO', '1') # Show commands by default - - -PROJECT_ROOT = Path(__file__).parent -PROJECT_NAME = PROJECT_ROOT.name.replace('-', '_') # 'pytest_params' -SOURCE_DIR = PROJECT_ROOT / 'src' / PROJECT_NAME - -# Requirements files -REQUIREMENTS_MAIN = 'main' -REQUIREMENTS_FILES = { - REQUIREMENTS_MAIN: 'requirements', - 'dev': 'requirements-dev', -} -""" -Requirements files. -Order matters as most operations with multiple files need ``requirements.txt`` to be processed -first. -Add new requirements files here. -""" - -REQUIREMENTS_TASK_HELP = { - 'requirements': '`.in` file. Full name not required, just the initial name after the dash ' - f'(ex. "dev"). For main file use "{REQUIREMENTS_MAIN}". Available requirements: ' - f'{", ".join(REQUIREMENTS_FILES)}.' -} - -BUILD_DIST_DIR = PROJECT_ROOT / 'dist' - -VERSION_FILES = [ - PROJECT_ROOT / 'pyproject.toml', - SOURCE_DIR / '__init__.py', -] -""" -Files that contain the package version. -This version needs to be updated with each release. -""" - - -def _csstr_to_list(csstr: str) -> list[str]: - """ - Convert a comma-separated string to list. - """ - return [s.strip() for s in csstr.split(',')] - - -def _get_requirements_file(requirements: str, extension: str) -> str: - """ - Return the full requirements file name (with extension). - - :param requirements: The requirements file to retrieve. Can be the whole filename - (no extension), ex `'requirements-dev'` or just the initial portion, ex `'dev'`. - Use `'main'` for the `requirements` file. - :param extension: Requirements file extension. Can be either `'in'` or `'txt'`. - """ - filename = REQUIREMENTS_FILES.get(requirements, requirements) - if filename not in REQUIREMENTS_FILES.values(): - raise Exit(f'`{requirements}` is an unknown requirements file.') - - return f'{filename}.{extension.lstrip(".")}' - - -def _get_requirements_files(requirements: str | None, extension: str) -> list[str]: - extension = extension.lstrip('.') - if requirements is None: - requirements_files = list(REQUIREMENTS_FILES) - else: - requirements_files = _csstr_to_list(requirements) - - # Get full filename+extension and sort by the order defined in `REQUIREMENTS_FILES` - filenames = [ - _get_requirements_file(r, extension) for r in REQUIREMENTS_FILES if r in requirements_files - ] - - return filenames - - -def _get_project_version() -> str: - pattern = re.compile('''^[ _]*version[ _]*[:=] *['"](.*)['"]''', re.MULTILINE) - versions = {} - for file in VERSION_FILES: - with open(file) as f: - text = f.read() - match = pattern.search(text) - if not match: - raise Exit(f'Could not find version in `{file.relative_to(PROJECT_ROOT)}`.') - versions[file] = match.group(1) - - if len(set(versions.values())) != 1: - raise Exit( - 'Version mismatch in files that contain versions.\n' - + ( - '\n'.join( - f'{file.relative_to(PROJECT_ROOT)}: {version}' - for file, version in versions.items() - ) - ) - ) - - return list(versions.values())[0] - - -def _get_next_version(current_version, part): - from packaging.version import Version - - version = Version(str(current_version)) - - if part == 'major': - new_version = Version(f'{version.major + 1}.0.0') - elif part == 'minor': - new_version = Version(f'{version.major}.{version.minor + 1}.0') - elif part == 'patch': - new_version = Version(f'{version.major}.{version.minor}.{version.micro + 1}') - else: - raise ValueError('`part` must be "major", "minor", or "patch"') - - return new_version - - -def _update_project_version(version: str): - pattern = re.compile('''^([ _]*version[ _]*[:=] *['"])(.*)(['"].*)$''', re.MULTILINE) - for file in VERSION_FILES: - with open(file) as f: - text = f.read() - new_text = pattern.sub(lambda match: f'{match.group(1)}{version}{match.group(3)}', text) - with open(file, 'w') as f: - f.write(new_text) - - -def _get_release_name_and_tag(version: str) -> tuple[str, str]: - """ - Generate release name and tag based on the version. - - :return: Tuple with release name (ex 'v1.2.3') and tag (ex '1.2.3'). - """ - return f'v{version}', version - - -def _get_version_from_release_name(release_name: str) -> str: - if not release_name.startswith('v'): - raise Exit(f'Invalid release name: {release_name}') - return release_name[1:] - - -def _get_latest_release() -> tuple[str, str]: - """ - Retrieves the latest release from GitHub. - - :return: Tuple with release name (ex 'v1.2.3') and tag (ex '1.2.3'). - """ - import json - import subprocess - - release_info_json = subprocess.check_output(['gh', 'release', 'view', '--json', 'name,tagName']) - release_info = json.loads(release_info_json) - return release_info['name'], release_info['tagName'] - - -@task( - help={ - 'version': 'Version in semantic versioning format (ex 1.5.0). ' - 'If `version` is set, then `bump` cannot be used.', - 'bump': 'Portion of the version to increase, can be "major", "minor", or "patch".' - 'If `bump` is set, then `version` cannot be used.', - }, -) -def build_version(c, version: str = '', bump: str = ''): - """ - Updates the files that contain the project version to the new version. - """ - from packaging.version import Version - - v1 = Version(_get_project_version()) - if version and bump: - raise Exit('Either `version` or `bump` can be set, not both.') - if not (version or bump): - try: - bump = {'1': 'major', '2': 'minor', '3': 'patch'}[ - input( - f'Current version is `{v1}`, which portion to bump?' - '\n1 - Major\n2 - Minor\n3 - Patch\n> ' - ) - ] - except KeyError: - raise Exit('Invalid choice.') - - if version: - v2 = Version(version) - if v2 <= v1: - raise Exit(f'New version `{v2}` needs to be greater than the existing version `{v1}`.') - else: - try: - v2 = _get_next_version(v1, bump.strip().lower()) - except AttributeError: - raise Exit('Invalid `bump` choice.') - - _update_project_version(str(v2)) - print( - f'New version is `{v2}`. Modified files have not been commited:\n' - + '\n'.join(f' {file.relative_to(PROJECT_ROOT)}' for file in VERSION_FILES) - ) - - -@task -def build_clean(c): - """ - Delete files created from previous builds. - """ - import shutil - - shutil.rmtree(BUILD_DIST_DIR, ignore_errors=True) - - -@task( - build_clean, - help={'no_upload': 'Do not upload to Pypi.'}, -) -def build_publish(c, no_upload: bool = False): - """ - Build package and publish (upload) to Pypi. - """ - # Create distribution files (source and wheel) - c.run('flit build') - - # Upload to pypi - if not no_upload: - version = _get_project_version() - response = input(f'Publishing version `{version}` to Pypi. Press Y to confirm. ') - if response.lower().strip() == 'y': - c.run('flit publish') - else: - print('Package not published to Pypi.') - - -@task( - help={ - 'notes': 'Release notes.', - 'notes_file': 'Read release notes from file. Ignores the `--notes` parameter.', - }, -) -def build_release( - c, - notes: str = '', - notes_file: str = '', -): - """ - Create a release and tag in GitHub from the current project version. - """ - from packaging.version import Version - - if not notes and not notes_file: - response = input('No release notes or notes file specified, continue? [Y/n]') - response = response.strip().lower() or 'y' - if response not in ['yes', 'y']: - raise Exit('No release notes specified.') - - # Check that there's no release with the current version - version = Version(_get_project_version()) - latest_release, latest_tag = _get_latest_release() - latest_version = Version(_get_version_from_release_name(latest_release)) - if str(latest_version) != latest_tag: - raise Exit( - f'Invalid format in latest release or tag: Release: {latest_release}, Tag: {latest_tag}' - ) - - if latest_version >= version: - raise Exit( - f'Release/tag version being created ({version}) needs to be greater than the current ' - f'latest release version ({latest_version}).' - ) - - # Create release - new_release, new_tag = _get_release_name_and_tag(str(version)) - command = f'gh release create "{new_tag}" --title "{new_release}" --generate-notes' - if notes: - command += f' --notes "{notes}"' - if notes_file: - notes_file_path = Path(notes_file) - command += f' --notes-file "{notes_file_path.resolve(strict=True)}"' - - response = input(f'Creating GitHub release `{new_release}`. Press Y to confirm. ') - if response.lower().strip() == 'y': - c.run(command) - print('GitHub release created. Upload artifacts with `build.upload`.') - else: - print('GitHub release not created.') - - -@task -def lint_black(c, path='.'): - c.run(f'black {path}') - - -@task -def lint_flake8(c, path='.'): - c.run(f'flake8 {path}') - - -@task -def lint_isort(c, path='.'): - c.run(f'isort {path}') - - -@task -def lint_mypy(c, path='.'): - c.run(f'mypy {path}') - - -@task(lint_isort, lint_black, lint_flake8, lint_mypy) -def lint_all(c): - """ - Run all linters. - Config for each of the tools is in ``pyproject.toml`` and ``setup.cfg``. - """ - print('Done') - - -@task -def test_unit(c): - """ - Run unit tests. - Temporarily installs the `pytest-params` package. - """ - c.run('flit install') - c.run('python -m pytest') - c.run('pip uninstall pytest-params -y') - - -@task(help=REQUIREMENTS_TASK_HELP) -def pip_compile(c, requirements=None): - """ - Compile requirements file(s). - """ - for filename in _get_requirements_files(requirements, 'in'): - c.run(f'pip-compile {filename}') - - -@task(help=REQUIREMENTS_TASK_HELP) -def pip_sync(c, requirements=None): - """ - Synchronize environment with requirements file. - """ - c.run(f'pip-sync {" ".join(_get_requirements_files(requirements, "txt"))}') - - -@task( - help=REQUIREMENTS_TASK_HELP | {'package': 'Package to upgrade. Can be a comma separated list.'} -) -def pip_package(c, requirements, package): - """ - Upgrade package. - """ - packages = [p.strip() for p in package.split(',')] - for filename in _get_requirements_files(requirements, 'in'): - c.run(f'pip-compile --upgrade-package {" --upgrade-package ".join(packages)} {filename}') - - -@task(help=REQUIREMENTS_TASK_HELP) -def pip_upgrade(c, requirements): - """ - Try to upgrade all dependencies to their latest versions. - """ - for filename in _get_requirements_files(requirements, 'in'): - c.run(f'pip-compile --upgrade {filename}') - - -ns = Collection() # Main namespace - -test_collection = Collection('test') -test_collection.add_task(test_unit, 'unit') - -build_collection = Collection('build') -build_collection.add_task(build_version, 'version') -build_collection.add_task(build_clean, 'clean') -build_collection.add_task(build_publish, 'publish') -build_collection.add_task(build_release, 'release') - -lint_collection = Collection('lint') -lint_collection.add_task(lint_all, 'all') -lint_collection.add_task(lint_black, 'black') -lint_collection.add_task(lint_flake8, 'flake8') -lint_collection.add_task(lint_isort, 'isort') -lint_collection.add_task(lint_mypy, 'mypy') - -pip_collection = Collection('pip') -pip_collection.add_task(pip_compile, 'compile') -pip_collection.add_task(pip_package, 'package') -pip_collection.add_task(pip_sync, 'sync') -pip_collection.add_task(pip_upgrade, 'upgrade') - -ns.add_collection(build_collection) -ns.add_collection(lint_collection) -ns.add_collection(pip_collection) -ns.add_collection(test_collection) From 941d7dd4063c045f18e95650a6489f7e73a99888 Mon Sep 17 00:00:00 2001 From: Joao Coelho Date: Fri, 13 Mar 2026 20:44:13 -0500 Subject: [PATCH 2/6] admin scripts updates --- WARP.md | 109 --------------------------- admin/lint.py | 33 ++++++-- admin/pip.py | 20 +++-- admin/utils.py | 200 ++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 236 insertions(+), 126 deletions(-) delete mode 100644 WARP.md diff --git a/WARP.md b/WARP.md deleted file mode 100644 index f76eee2..0000000 --- a/WARP.md +++ /dev/null @@ -1,109 +0,0 @@ -# WARP.md - -This file provides guidance to WARP (warp.dev) when working with code in this repository. - -## Project Overview - -A library that provides simplified pytest test case parameters via the `@params` decorator and -the `get_request_param` function. Helps make parametrized tests more readable and declarative. - -## Development Commands - -Always use `uv`. Do not use `pip` or `pip-tools`. - -### Task Runner -This project uses `typer-invoke` to organize development tasks into admin modules. The -configuration is in `pyproject.toml` under `[tool.typer-invoke]`. - -Run tasks using Python module syntax: -```bash -python -m admin.lint --help -python -m admin.build --help -python -m admin.pip --help -python -m admin.test --help -``` - -### Linting and Formatting -All linter configurations are in `pyproject.toml`. - -Run all linters in sequence: -```bash -python -m admin.lint all -``` - -Run individual linters: -```bash -python -m admin.lint ruff . -python -m admin.lint mypy . -``` - -### Testing - -Run unit tests (installs the package temporarily): -```bash -python -m admin.test unit -``` - -### Building and Publishing - -Clean build artifacts: -```bash -python -m admin.build clean -``` - -Update version (interactive or with flags): -```bash -python -m admin.build version --bump patch -python -m admin.build version --version 1.2.3 -``` - -Build and publish package: -```bash -python -m admin.build publish -``` - -### Package Management - -Compile requirements files (uses uv): -```bash -python -m admin.pip compile -python -m admin.pip compile --clean -``` - -Sync environment with requirements: -```bash -python -m admin.pip sync -``` - -Install requirements: -```bash -python -m admin.pip install -``` - -## Code Architecture - -### Module Structure -``` -src/pytest_params/ -├── __init__.py # Package exports and version -├── params.py # @params decorator implementation -└── request_params.py # get_request_param() function - -admin/ # Development task modules -├── __init__.py # Project constants (PROJECT_ROOT, SOURCE_DIR) -├── utils.py # Shared utilities (run, logger, etc.) -├── build.py # Build, version, and publish tasks -├── lint.py # Linting tasks (ruff, mypy) -├── pip.py # Package management tasks -├── test.py # Test tasks -└── requirements/ # Requirements files (.in and .txt) - -tests/ # Test suite -``` - -## Code Style - -- Follow PEP8 with 100 character line limit (enforced by ruff) -- Single quotes for strings, triple double quotes for docstrings -- Python 3.12+ syntax and type hints (use `str | None` not `Optional[str]`) -- 4 spaces for indentation diff --git a/admin/lint.py b/admin/lint.py index 8a9cc35..24f9f9e 100644 --- a/admin/lint.py +++ b/admin/lint.py @@ -20,25 +20,48 @@ @app.command(name='ruff') def lint_ruff( path: Annotated[str, typer.Argument(help='Path to directory or file to lint.')] = '.', + check: Annotated[ + bool, + typer.Option( + help='Check-only mode: report violations without fixing or reformatting. ' + 'Exits non-zero if any issues are found. Use this in CI.', + ), + ] = False, dry: DryAnnotation = False, ): - run('ruff', 'format', path, dry=dry) - run('ruff', 'check', '--fix', path, dry=dry) + if check: + run('ruff', 'check', path, dry=dry) + run('ruff', 'format', '--check', path, dry=dry) + else: + run('ruff', 'check', '--fix', path, dry=dry) + run('ruff', 'format', path, dry=dry) @app.command(name='mypy') -def lint_mypy(path='.', dry: DryAnnotation = False): +def lint_mypy( + path: Annotated[str, typer.Argument(help='Path to type-check.')] = '.', + dry: DryAnnotation = False, +): run('mypy', path, dry=dry) @app.command(name='all') -def lint_all(dry: DryAnnotation = False): +def lint_all( + check: Annotated[ + bool, + typer.Option( + help='Check-only mode: report violations without fixing or reformatting. ' + 'Exits non-zero if any issues are found. Use this in CI.', + ), + ] = False, + dry: DryAnnotation = False, +): """ Run all linters. Config for each of the tools is in ``pyproject.toml``. """ - lint_ruff(dry=dry) + lint_ruff(check=check, dry=dry) lint_mypy(dry=dry) logger.info('Done') diff --git a/admin/pip.py b/admin/pip.py index e502c1d..7cdb3b7 100644 --- a/admin/pip.py +++ b/admin/pip.py @@ -42,7 +42,7 @@ class RequirementsType(StrEnum): REQUIREMENTS_TASK_HELP = { 'requirements': '`.in` file. Full name not required, just the initial name after the dash ' - f'(ex. "{Requirements.DEV.name}"). For main file use "{Requirements.MAIN.name}". ' + f'(ex. "{Requirements.MAIN.name}"). For main file use "{Requirements.MAIN.name}". ' f'Available requirements: {", ".join(Requirements)}.' } @@ -149,23 +149,22 @@ def pip_package( 'pip', 'compile', *multiple_parameters('--upgrade-package', *package), - filename.name, + str(filename), '-o', - output_file.name, + str(output_file), dry=dry, - cwd=REQUIREMENTS_DIR, ) @app.command(name='upgrade') -def pip_upgrade(requirements: RequirementsAnnotation = None, dry: DryAnnotation = False): +def pip_upgrade(requirements, dry: DryAnnotation = False): """ Try to upgrade all dependencies to their latest versions. Equivalent to ``compile`` with ``--clean`` option. Use ``package`` to only upgrade individual packages, - Ex ``pip package dev mypy ruff``. + Ex ``pip package dev mypy flake8``. """ for filename in _get_requirements_files(requirements, RequirementsType.IN): output_file = filename.with_suffix('.txt') @@ -175,20 +174,19 @@ def pip_upgrade(requirements: RequirementsAnnotation = None, dry: DryAnnotation 'compile', '--no-strip-extras', '--upgrade', - filename.name, + str(filename), '-o', - output_file.name, + str(output_file), dry=dry, - cwd=REQUIREMENTS_DIR, ) @app.command(name='install') -def pip_install(requirements: RequirementsAnnotation = None, dry: DryAnnotation = False): +def pip_install(requirements: RequirementsAnnotation, dry: DryAnnotation = False): """ Equivalent to ``uv pip install -r ``. """ - requirements_files = _get_requirements_files(requirements, RequirementsType.OUT) + requirements_files = _get_requirements_files(requirements, RequirementsType.OUT) # type: ignore run('uv', 'pip', 'install', *multiple_parameters('-r', *requirements_files), dry=dry) diff --git a/admin/utils.py b/admin/utils.py index b4b6234..49be0b5 100644 --- a/admin/utils.py +++ b/admin/utils.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from enum import StrEnum from itertools import chain +from pathlib import Path from typing import Annotated import typer @@ -18,6 +19,20 @@ """Sentinel object to represent an empty string.""" +class Environment(StrEnum): + LOCAL = 'local' + """Run in local machine, no Docker.""" + + DEV = 'dev' + """Run in Docker in local machine.""" + + STAGING = 'staging' + """Run in Docker in AWS (staging).""" + + PROD = 'prod' + """Run in Docker in AWS (prod).""" + + class OS(StrEnum): """Operating System.""" @@ -33,6 +48,7 @@ class LogLevel(StrEnum): WARNING = 'WARNING' INFO = 'INFO' DEBUG = 'DEBUG' + # TRACE = 'TRACE' class NoHighlightRichHandler(RichHandler): @@ -65,6 +81,11 @@ def strip(self, text: str) -> str: return text +EnvironmentAnnotation = Annotated[ + Environment | None, + typer.Argument(help='Environment to use.', show_default=False), +] + LogLevelAnnotation = Annotated[ LogLevel, typer.Option( @@ -84,6 +105,112 @@ def strip(self, text: str) -> str: ] +def read_env_file_from_path(env_path: Path) -> dict[str, str]: + """ + Read a `.env` file. Minimal parser, no dependencies. + Does not update environment. + + Rules: + + - Ignores blank lines and comments. + - Supports `export KEY=VALUE`. + - Strips surrounding single/double quotes. + """ + if not env_path.exists(): + raise FileNotFoundError(f'Env file not found: {env_path}') + + env = {} + for raw_line in env_path.read_text(encoding='utf-8').splitlines(): + line = raw_line.strip() + if not line or line.startswith('#'): + continue + + if line.startswith('export '): + line = line.removeprefix('export ').lstrip() + + key, sep, value = line.partition('=') + if not sep: + continue + + key = key.strip() + value = value.strip() + if (value.startswith("'") and value.endswith("'")) or ( + value.startswith('"') and value.endswith('"') + ): + value = value[1:-1] + + env[key] = value + + return env + + +def get_env_file_path(environment: Environment) -> Path: + return PROJECT_ROOT / f'.env.{environment.value}' + + +def read_env_file(environment: Environment) -> dict[str, str]: + return read_env_file_from_path(get_env_file_path(environment)) + + +# Another version, using `python-dotenv` +# from dotenv import dotenv_values +# def load_env_file(env_file: Path) -> dict[str, str | None]: +# """Load environment variables from the specified .env file.""" +# if not env_file.exists(): +# logger.error(f'Environment file not found: {env_file}') +# raise typer.Exit(1) +# +# env_vars = dotenv_values(env_file) +# logger.info(f'Loaded environment from: {env_file}') +# return env_vars + + +def select_environment( + environment: Environment | None = None, + set_env: bool = True, + **select_enum_kwargs, +) -> Environment: + """ + Select an environment and load its `.env` file into `os.environ`. + + This project used to rely on `python-dotenv` for this. We keep the behavior (populate + `os.environ`) without depending on `python-dotenv`. + """ + + from textual_searchable_selectionlist.options import SelectionStrategy + from textual_searchable_selectionlist.select import select_enum + + if environment: + env = Environment(environment) + else: + try: + selected = select_enum( + Environment, + selection_strategy=SelectionStrategy.ONE, + title='Environment', + select_by='value', + **select_enum_kwargs, + ) + except Exception as e: + logger.error(f'Error selecting environment: {type(e).__name__}: {e}') + raise typer.Exit(1) + + if not selected: + logger.error('No environment selected.') + raise typer.Exit(1) + + env = selected[0] + + if set_env: + logger.debug(f'Setting environment [b]{env.value}[/b]') + env_values = read_env_file(env) + os.environ.update(env_values) + else: + logger.debug(f'Selected environment [b]{env.value}[/b]') + + return env + + def get_os() -> OS: """ Similar to ``sys.platform`` and ``platform.system()``, but less ambiguous by returning an Enum @@ -122,7 +249,7 @@ def run( """ final_args: list[str] = [] for arg in args: - if not arg: + if arg in ['', None]: continue if arg == EMPTY_STR: final_args.append('') @@ -160,6 +287,77 @@ def run( return result # type: ignore +def run_async(*args, dry: bool = False, **kwargs) -> subprocess.Popen | None: + """ + Starts the process and continues code execution. + + Use the following checks:: + + process.poll() # Returns None if still running, else return code + process.wait() # Wait for completion (blocking) + process.terminate() # Send SIGTERM (graceful) + process.kill() # Send SIGKILL (force) + process.returncode # Access return code after completion + + See ``subprocess.Popen(...)`` for more details. + """ + logger.info(' '.join(map(str, args))) + + if dry: + return None + + defaults = dict( + cwd=PROJECT_ROOT, + ) + + try: + return subprocess.Popen(args, **(defaults | kwargs)) + except subprocess.CalledProcessError as e: + logger.error(e) + raise typer.Exit(1) + + +def is_package_installed(package_name: str) -> bool: + """Check if a Python package is installed.""" + import importlib.util + + if importlib.util.find_spec(package_name) is not None: + return True + + try: + import importlib.metadata as metadata + + metadata.version(package_name) + return True + except Exception: # noqa + return False + + +def install_package( + package: str, + package_install: str | None = None, + exit_if_install: bool = True, + dry: bool = False, +): + """ + Install a Python package if not already installed. + + :param package: Name of the package to check/install. + :param package_install: Name of the package to install, if different from the name to check. + :param exit_if_install: Exit the program if the package is installed and `dry` is False. + :param dry: Show the command that would be run without running it. + """ + if is_package_installed(package): + logger.debug(f'Package `{package}` is already installed.') + return + + run(sys.executable, '-m', 'pip', 'install', package_install or package, dry=dry) + + if exit_if_install and not dry: + logger.info(f'Package `{package}` installed successfully.\nRe-run the command.') + raise typer.Exit(1) + + def multiple_parameters(parameter: str, *options) -> list[str]: return list(chain.from_iterable(zip([parameter] * len(options), map(str, options)))) From 876cff3fee7ebf694ba64b703978be8724680056 Mon Sep 17 00:00:00 2001 From: Joao Coelho Date: Fri, 13 Mar 2026 20:46:50 -0500 Subject: [PATCH 3/6] Update minimum Python version to 3.11 - pyproject.toml: requires-python >= 3.11 - pyproject.toml: ruff target-version py311 - pyproject.toml: mypy python_version 3.11 - tests.yml: matrix uses python 3.11 instead of 3.10 Co-Authored-By: Oz --- .github/workflows/tests.yml | 2 +- pyproject.toml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 57e92d5..3b6e50d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: include: - python-version: '3.12' pytest-version: 9 - - python-version: '3.10' + - python-version: '3.11' pytest-version: 7 runs-on: ubuntu-latest name: pytest ${{ matrix.pytest-version }} diff --git a/pyproject.toml b/pyproject.toml index cdd7e99..7a660d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.ruff] line-length = 100 -target-version = 'py312' +target-version = 'py311' exclude = [ '.venv*', 'venv*', @@ -28,7 +28,7 @@ quote-style = 'single' # 1 The regex for all folders needs to be in a one-line string. # 2 The `.` doesn't need to be escaped. Escape with `\\.` for a fully compatible regex. exclude = '^venv*|^.venv*|.git|.eggs|build|dist|.cache|.pytest_cache|.mypy_cache|.vscode|.idea' -python_version = '3.12' +python_version = '3.11' warn_return_any = true warn_unused_configs = true # Disable the warning below, from type hinting variables in a function. @@ -51,7 +51,7 @@ readme = 'README.md' authors = [{name = 'Joao Coelho'}] license = 'MIT' license-files = ['LICENSE.txt'] -requires-python = '>=3.10' +requires-python = '>=3.11' dependencies = ['pytest>=7.0.0'] # https://pypi.org/pypi?%3Aaction=list_classifiers classifiers = [ From 396ede9cd2dfbee143eae2e4441a88707d468702 Mon Sep 17 00:00:00 2001 From: Joao Coelho Date: Fri, 13 Mar 2026 20:50:09 -0500 Subject: [PATCH 4/6] gh actions updates --- .github/workflows/tests.yml | 4 +++- admin/requirements/requirements-dev.txt | 20 ++++++++++---------- admin/requirements/requirements.txt | 2 +- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3b6e50d..56308ba 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: include: - - python-version: '3.12' + - python-version: '3.14' pytest-version: 9 - python-version: '3.11' pytest-version: 7 @@ -17,10 +17,12 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v5 + - name: Set up Python uses: actions/setup-python@v6 with: python-version: '${{ matrix.python-version }}' + - name: Install uv uses: astral-sh/setup-uv@v5 diff --git a/admin/requirements/requirements-dev.txt b/admin/requirements/requirements-dev.txt index 0b29609..301de61 100644 --- a/admin/requirements/requirements-dev.txt +++ b/admin/requirements/requirements-dev.txt @@ -4,12 +4,12 @@ click==8.3.1 # via typer colorama==0.4.6 # via - # -r admin/requirements/requirements.txt + # -r requirements.txt # click # pytest iniconfig==2.3.0 # via - # -r admin/requirements/requirements.txt + # -r requirements.txt # pytest librt==0.8.1 # via mypy @@ -18,38 +18,38 @@ markdown-it-py==4.0.0 mdurl==0.1.2 # via markdown-it-py mypy==1.19.1 - # via -r admin/requirements/requirements-dev.in + # via -r requirements-dev.in mypy-extensions==1.1.0 # via mypy packaging==26.0 # via - # -r admin/requirements/requirements-dev.in - # -r admin/requirements/requirements.txt + # -r requirements-dev.in + # -r requirements.txt # pytest pathspec==1.0.4 # via mypy pluggy==1.6.0 # via - # -r admin/requirements/requirements.txt + # -r requirements.txt # pytest pygments==2.19.2 # via - # -r admin/requirements/requirements.txt + # -r requirements.txt # pytest # rich pytest==9.0.2 - # via -r admin/requirements/requirements.txt + # via -r requirements.txt rich==14.3.3 # via # typer # typer-invoke ruff==0.15.6 - # via -r admin/requirements/requirements-dev.in + # via -r requirements-dev.in shellingham==1.5.4 # via typer typer==0.24.1 # via typer-invoke typer-invoke==0.5.0 - # via -r admin/requirements/requirements-dev.in + # via -r requirements-dev.in typing-extensions==4.15.0 # via mypy diff --git a/admin/requirements/requirements.txt b/admin/requirements/requirements.txt index 2123b12..bfc219c 100644 --- a/admin/requirements/requirements.txt +++ b/admin/requirements/requirements.txt @@ -9,4 +9,4 @@ pluggy==1.6.0 pygments==2.19.2 # via pytest pytest==9.0.2 - # via -r admin/requirements/requirements.in + # via -r requirements.in From e6dfa9b7463f5ce9a7942761c0bfb8b9fb93c4a1 Mon Sep 17 00:00:00 2001 From: Joao Coelho Date: Fri, 13 Mar 2026 20:55:32 -0500 Subject: [PATCH 5/6] fixes --- admin/build.py | 3 +-- admin/requirements/requirements-dev.in | 4 ++++ admin/requirements/requirements-dev.txt | 29 +++++++++++++++++++++---- tests/test_params_output.py | 8 +++---- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/admin/build.py b/admin/build.py index a64dcfb..44e16b0 100644 --- a/admin/build.py +++ b/admin/build.py @@ -401,8 +401,7 @@ def build_release( latest_version = Version(_get_version_from_release_name(latest_release)) if str(latest_version) != latest_tag: logger.error( - f'Invalid format in latest release or tag: Release: {latest_release}, ' - f'Tag: {latest_tag}' + f'Invalid format in latest release or tag: Release: {latest_release}, Tag: {latest_tag}' ) raise typer.Exit(1) diff --git a/admin/requirements/requirements-dev.in b/admin/requirements/requirements-dev.in index 62a5510..4b8b6b5 100644 --- a/admin/requirements/requirements-dev.in +++ b/admin/requirements/requirements-dev.in @@ -1,11 +1,15 @@ -r requirements.txt # Dev tools +textual_searchable_selectionlist # CLI selection widget typer-invoke # Tasks # Linting mypy # Code inspector ruff # Linter/formatter +# Test +pytest # Test framework + # Build and publish packaging # For version management diff --git a/admin/requirements/requirements-dev.txt b/admin/requirements/requirements-dev.txt index 301de61..d732067 100644 --- a/admin/requirements/requirements-dev.txt +++ b/admin/requirements/requirements-dev.txt @@ -13,8 +13,15 @@ iniconfig==2.3.0 # pytest librt==0.8.1 # via mypy -markdown-it-py==4.0.0 - # via rich +linkify-it-py==2.1.0 + # via markdown-it-py +markdown-it-py[linkify]==4.0.0 + # via + # mdit-py-plugins + # rich + # textual +mdit-py-plugins==0.5.0 + # via textual mdurl==0.1.2 # via markdown-it-py mypy==1.19.1 @@ -28,6 +35,8 @@ packaging==26.0 # pytest pathspec==1.0.4 # via mypy +platformdirs==4.9.4 + # via textual pluggy==1.6.0 # via # -r requirements.txt @@ -37,19 +46,31 @@ pygments==2.19.2 # -r requirements.txt # pytest # rich + # textual pytest==9.0.2 - # via -r requirements.txt + # via + # -r requirements-dev.in + # -r requirements.txt rich==14.3.3 # via + # textual # typer # typer-invoke ruff==0.15.6 # via -r requirements-dev.in shellingham==1.5.4 # via typer +textual==8.1.1 + # via textual-searchable-selectionlist +textual-searchable-selectionlist==0.0.7 + # via -r requirements-dev.in typer==0.24.1 # via typer-invoke typer-invoke==0.5.0 # via -r requirements-dev.in typing-extensions==4.15.0 - # via mypy + # via + # mypy + # textual +uc-micro-py==2.0.0 + # via linkify-it-py diff --git a/tests/test_params_output.py b/tests/test_params_output.py index 9784728..2589166 100644 --- a/tests/test_params_output.py +++ b/tests/test_params_output.py @@ -10,7 +10,7 @@ def test_one_param_one_value(pytester): result.assert_outcomes(passed=1, failed=0) result.stdout.fnmatch_lines( [ - f"*{TEST_MODULE}::{test_function}[[]Foo[]]*", + f'*{TEST_MODULE}::{test_function}[[]Foo[]]*', ] ) @@ -22,8 +22,8 @@ def test_one_param_multiple_values(pytester): result.assert_outcomes(passed=2, failed=0) result.stdout.fnmatch_lines( [ - f"*{TEST_MODULE}::{test_function}[[]Foo[]]*", - f"*{TEST_MODULE}::{test_function}[[]Bar[]]*", + f'*{TEST_MODULE}::{test_function}[[]Foo[]]*', + f'*{TEST_MODULE}::{test_function}[[]Bar[]]*', ] ) @@ -35,6 +35,6 @@ def test_multiple_params_one_value(pytester): result.assert_outcomes(passed=1, failed=0) result.stdout.fnmatch_lines( [ - f"*{TEST_MODULE}::{test_function}[[]Foo[]]*", + f'*{TEST_MODULE}::{test_function}[[]Foo[]]*', ] ) From bf21c922f0b98ea1560b9f22008e7f7fb12ba6a8 Mon Sep 17 00:00:00 2001 From: Joao Coelho Date: Fri, 13 Mar 2026 20:57:17 -0500 Subject: [PATCH 6/6] admin script fixes --- admin/test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/test.py b/admin/test.py index e1d1206..7060026 100644 --- a/admin/test.py +++ b/admin/test.py @@ -23,7 +23,7 @@ def test_unit(dry: DryAnnotation = False): Temporarily installs the ``pytest-params`` package in editable mode. """ run('uv', 'pip', 'install', '--editable', '.', dry=dry) - run('python', '-m', 'pytest', dry=dry) + run('uv', 'run', 'pytest', dry=dry) run('uv', 'pip', 'uninstall', 'pytest-params', dry=dry)