diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml index 8ee4a36..6db50ba 100644 --- a/.github/workflows/build-app.yml +++ b/.github/workflows/build-app.yml @@ -41,10 +41,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.11' @@ -65,15 +65,17 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.11' - name: Install requirements - run: pip install -r requirements-dev.txt + run: | + pip install typer-invoke + inv pip install dev - name: Build app run: inv build.app @@ -89,7 +91,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install libEGL # Implementation of the EGL (Embedded-Systems Graphics Library) API. @@ -99,7 +101,7 @@ jobs: sudo apt-get install -y libegl1-mesa - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.11' diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index aff4d7e..d6cee4d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -17,15 +17,17 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.12' - name: Install requirements - run: pip install -U -r requirements-docs.txt + run: | + pip install typer-invoke + inv pip install docs - name: Deploy docs run: mkdocs gh-deploy --force diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 5e88599..6c4d4c9 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -9,15 +9,17 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.11' - name: Install requirements - run: pip install -r requirements-dev.txt + run: | + pip install typer-invoke + inv pip install dev - name: isort run: isort . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b3655d..debcabe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,15 +57,17 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.11' - name: Install requirements - run: pip install -r requirements-dev.txt + run: | + pip install typer-invoke + inv pip install dev - name: Set git user run: | diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3b1dac6..5a08002 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,7 +13,7 @@ jobs: name: Python ${{ matrix.python-version }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install libEGL (Linux only) # Implementation of the EGL (Embedded-Systems Graphics Library) API. @@ -24,14 +24,14 @@ jobs: if: runner.os == 'Linux' - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} architecture: x64 - name: Install requirements run: | - python -m pip install -U pip - python -m pip install -r requirements.txt -r requirements-dev.txt + pip install typer-invoke + inv pip install dev - name: Run tests on Linux and Mac # Test folder(s) configured in `pyproject.toml` # Skip Windows OS tests diff --git a/README.md b/README.md index 1bef8ee..75e8974 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,13 @@ This page doesn't go into how to do that. Install the development packages: ``` -python -m pip install -r requirements-dev.txt +pip typer-invoke && inv pip sync ``` -This project uses [pyinvoke](https://www.pyinvoke.org/) to facilitate common tasks. +This project uses [typer-invoke](https://github.com/joaonc/typer-invoke) to facilitate common tasks. For a list of tasks: ``` -inv --list +inv --help ``` ## Licensing diff --git a/admin/__init__.py b/admin/__init__.py new file mode 100644 index 0000000..057669c --- /dev/null +++ b/admin/__init__.py @@ -0,0 +1,4 @@ +from pathlib import Path + +PROJECT_ROOT = Path(__file__).parents[1] +SOURCE_DIR = PROJECT_ROOT / 'src' diff --git a/admin/build.py b/admin/build.py new file mode 100644 index 0000000..de6a7f4 --- /dev/null +++ b/admin/build.py @@ -0,0 +1,298 @@ +from pathlib import Path +from typing import Annotated + +import typer + +from admin import 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() + + +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(c) -> 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 = c.run('gh release view --json name,tagName,assets').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(False, 'git', 'branch', '--show-current') + + +def _get_default_branch(): + """Returns the default branch (usually ``main``).""" + return run( + False, 'gh', 'repo', 'view', '--json', 'defaultBranchRef', '--jq', '.defaultBranchRef.name' + ) + + +def _commit(message: str, dry: bool): + # Commit + run(dry, 'git', 'add', *VERSION_FILES) + run(dry, 'git', 'commit', '-m', message) + + # Push current branch + branch = _get_branch() + run(dry, 'git', 'push', 'origin', branch) + + +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( + dry, + 'gh', + 'pr', + 'create', + '--title', + title, + '--body', + description, + '--head', + branch, + '--base', + default_branch, + ) + + # Merge PR after checks pass + run(dry, 'gh', 'pr', 'merge', 'branch', '--squash', '--auto') + + +@app.command(name='clean') +def build_clean(): + import shutil + + shutil.rmtree(BUILD_DIST_DIR, ignore_errors=True) + + +@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(dry, 'git', 'checkout', '-b', f'release-{v2}') + 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) + + +if __name__ == '__main__': + app() diff --git a/admin/docs.py b/admin/docs.py new file mode 100644 index 0000000..728ea2f --- /dev/null +++ b/admin/docs.py @@ -0,0 +1,49 @@ +#!python +""" +Create and publish documentation. +""" + +import typer + +from admin import PROJECT_ROOT +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='serve') +def docs_serve(dry: DryAnnotation = False): + """ + Start documentation local server. + """ + run(dry, 'mkdocs', 'serve') + + +@app.command(name='deploy') +def docs_deploy(dry: DryAnnotation = False): + """ + Publish documentation to GitHub Pages at https://joaonc.github.io/hd_active + """ + run(dry, 'mkdocs', 'gh-deploy') + + +@app.command(name='clean') +def docs_clean(dry: DryAnnotation = False): + """ + Delete documentation website static files. + """ + import shutil + + docs_dir = PROJECT_ROOT / 'site' + logger.info(f'Deleting {docs_dir}') + if not dry: + shutil.rmtree(PROJECT_ROOT / 'site', ignore_errors=True) + + +if __name__ == '__main__': + app() diff --git a/admin/lint.py b/admin/lint.py new file mode 100644 index 0000000..d16c13c --- /dev/null +++ b/admin/lint.py @@ -0,0 +1,54 @@ +#!python +""" +Linting and static type checking. +""" + +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='black') +def lint_black(path='.', dry: DryAnnotation = False): + run(dry, 'black', path) + + +@app.command(name='flake8') +def lint_flake8(path='.', dry: DryAnnotation = False): + run(dry, 'flake8', path) + + +@app.command(name='isort') +def lint_isort(path='.', dry: DryAnnotation = False): + run(dry, 'isort', path) + + +@app.command(name='mypy') +def lint_mypy(path='.', dry: DryAnnotation = False): + run(dry, 'mypy', path) + + +@app.command(name='all') +def lint_all(dry: DryAnnotation = False): + """ + Run all linters. + + Config for each of the tools is in ``pyproject.toml``. + """ + lint_isort(dry=dry) + lint_black(dry=dry) + lint_flake8(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..8162477 --- /dev/null +++ b/admin/pip.py @@ -0,0 +1,181 @@ +#!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, install_package, logger, 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' + DOCS = 'requirements-docs' + + +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 ValueError: + 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). + """ + install_package('pip-tools', dry=dry) + + if clean and not dry: + for filename in _get_requirements_files(requirements, RequirementsType.OUT): + filename.unlink(missing_ok=True) + + dry_option = ['--dry-run'] if dry else [] + for filename in _get_requirements_files(requirements, RequirementsType.IN): + run(False, 'pip-compile', *dry_option, str(filename)) + + +@app.command(name='install') +def pip_install(requirements: RequirementsAnnotation = None, dry: DryAnnotation = False): + """ + Install packages from the requirements file(s). + + Equivalent to ``pip install -r ``. Making it easier to point to the correct file. + """ + from itertools import chain + + files = _get_requirements_files(requirements, RequirementsType.OUT) + run( + dry, + 'pip', + 'install', + *list(chain.from_iterable(zip(['-r'] * len(files), map(str, files)))), + ) + + +@app.command(name='sync') +def pip_sync(requirements: RequirementsAnnotation = None, dry: DryAnnotation = False): + """ + Synchronize environment with requirements file. + """ + install_package('pip-tools', dry=dry) + run(dry, 'pip-sync', *_get_requirements_files(requirements, RequirementsType.OUT)) + + +@app.command(name='package') +def pip_package( + requirements: RequirementsAnnotation, + packages: Annotated[ + list[str], typer.Option('--packages', '-p', help='One or more packages to upgrade.') + ], + dry: DryAnnotation = False, +): + """ + Upgrade one or more packages. + """ + install_package('pip-tools', dry=dry) + + for filename in _get_requirements_files(requirements, RequirementsType.IN): + run( + dry, 'pip-compile', '--upgrade-package', *' --upgrade-package '.join(packages), filename + ) + + +@app.command(name='upgrade') +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 flake8``. + """ + install_package('pip-tools', dry=dry) + + for filename in _get_requirements_files(requirements, RequirementsType.IN): + run(dry, ['pip-compile', '--upgrade', filename]) + + +if __name__ == '__main__': + app() diff --git a/admin/precommit.py b/admin/precommit.py new file mode 100644 index 0000000..45de924 --- /dev/null +++ b/admin/precommit.py @@ -0,0 +1,53 @@ +#!python +""" +Precommit linting and static type checking. +""" +from typing import Annotated + +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='install') +def precommit_install(dry: DryAnnotation = False): + """ + Install pre-commit into the git hooks, which will cause pre-commit to run on automatically. + This should be the first thing to do after cloning this project and installing requirements. + """ + run(dry, 'pre-commit', 'install') + + +# `upgrade` instead of `update` to maintain similar naming to `pip-compile upgrade` +@app.command(name='upgrade') +def precommit_upgrade(dry: DryAnnotation = False): + """ + Upgrade pre-commit config to the latest repos' versions. + """ + run(dry, 'pre-commit', 'autoupdate') + + +@app.command(name='run') +def precommit_run( + hook: Annotated[ + str | None, + typer.Option(help='Name of hook to run. Default is to run all.', show_default=False), + ] = None, + dry: DryAnnotation = False, +): + """ + Manually run pre-commit hooks. + """ + hook = hook or '--all-files' + run(dry, 'pre-commit' 'run', hook) + + +if __name__ == '__main__': + app() diff --git a/requirements-dev.in b/admin/requirements/requirements-dev.in similarity index 95% rename from requirements-dev.in rename to admin/requirements/requirements-dev.in index 06a8eed..e557958 100644 --- a/requirements-dev.in +++ b/admin/requirements/requirements-dev.in @@ -1,9 +1,9 @@ -r requirements.txt # Dev tools -invoke # Tasks pip-tools # Package management pre-commit # Pre-commit hooks +typer-invoke # Tasks # Linting bandit # Linter diff --git a/requirements-dev.txt b/admin/requirements/requirements-dev.txt similarity index 88% rename from requirements-dev.txt rename to admin/requirements/requirements-dev.txt index b31ece1..357b145 100644 --- a/requirements-dev.txt +++ b/admin/requirements/requirements-dev.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile requirements-dev.in +# pip-compile 'requirements-dev.in' # altgraph==0.17.4 # via pyinstaller @@ -22,6 +22,7 @@ click==8.3.0 # via # black # pip-tools + # typer colorama==0.4.6 # via # bandit @@ -30,7 +31,7 @@ colorama==0.4.6 # pytest distlib==0.4.0 # via virtualenv -docutils==0.22.2 +docutils==0.22.3 # via flit filelock==3.20.0 # via virtualenv @@ -50,8 +51,6 @@ idna==3.11 # via requests iniconfig==2.3.0 # via pytest -invoke==2.2.1 - # via -r requirements-dev.in isort==7.0.0 # via -r requirements-dev.in markdown-it-py==4.0.0 @@ -91,7 +90,7 @@ pluggy==1.6.0 # via # pytest # pytest-qt -pre-commit==4.3.0 +pre-commit==4.4.0 # via -r requirements-dev.in pycodestyle==2.14.0 # via flake8 @@ -109,18 +108,18 @@ pyproject-hooks==1.2.0 # via # build # pip-tools -pyside6==6.9.1 +pyside6==6.10.0 # via -r requirements.txt -pyside6-addons==6.9.1 +pyside6-addons==6.10.0 # via # -r requirements.txt # pyside6 -pyside6-essentials==6.9.1 +pyside6-essentials==6.10.0 # via # -r requirements.txt # pyside6 # pyside6-addons -pytest==8.4.2 +pytest==9.0.0 # via # -r requirements-dev.in # pytest-params @@ -129,7 +128,7 @@ pytest-params==0.3.0 # via -r requirements-dev.in pytest-qt==4.5.0 # via -r requirements-dev.in -pytokens==0.2.0 +pytokens==0.3.0 # via black pywin32-ctypes==0.2.3 # via pyinstaller @@ -140,8 +139,13 @@ pyyaml==6.0.3 requests==2.32.5 # via flit rich==14.2.0 - # via bandit -shiboken6==6.9.1 + # via + # bandit + # typer + # typer-invoke +shellingham==1.5.4 + # via typer +shiboken6==6.10.0 # via # -r requirements.txt # pyside6 @@ -151,13 +155,18 @@ stevedore==5.5.0 # via bandit tomli-w==1.2.0 # via flit +typer==0.20.0 + # via typer-invoke +typer-invoke==0.3.0 + # via -r requirements-dev.in typing-extensions==4.15.0 # via # mypy # pytest-qt + # typer urllib3==2.5.0 # via requests -virtualenv==20.35.3 +virtualenv==20.35.4 # via pre-commit wheel==0.45.1 # via pip-tools diff --git a/requirements-docs.in b/admin/requirements/requirements-docs.in similarity index 100% rename from requirements-docs.in rename to admin/requirements/requirements-docs.in diff --git a/requirements-docs.txt b/admin/requirements/requirements-docs.txt similarity index 94% rename from requirements-docs.txt rename to admin/requirements/requirements-docs.txt index 50dd204..3e300b1 100644 --- a/requirements-docs.txt +++ b/admin/requirements/requirements-docs.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile requirements-docs.in +# pip-compile 'requirements-docs.in' # babel==2.17.0 # via mkdocs-material @@ -36,7 +36,7 @@ jinja2==3.1.6 # via # mkdocs # mkdocs-material -markdown==3.9 +markdown==3.10 # via # mdx-truly-sane-lists # mkdocs @@ -58,9 +58,9 @@ mkdocs==1.6.1 # mkdocs-material mkdocs-get-deps==0.2.0 # via mkdocs -mkdocs-glightbox==0.5.1 +mkdocs-glightbox==0.5.2 # via -r requirements-docs.in -mkdocs-material==9.6.22 +mkdocs-material==9.6.23 # via -r requirements-docs.in mkdocs-material-extensions==1.3.1 # via mkdocs-material @@ -99,7 +99,7 @@ requests==2.32.5 # via # -c requirements-dev.txt # mkdocs-material -selectolax==0.3.29 +selectolax==0.4.0 # via mkdocs-glightbox six==1.17.0 # via python-dateutil 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/requirements.txt b/admin/requirements/requirements.txt similarity index 69% rename from requirements.txt rename to admin/requirements/requirements.txt index 0a6e3cc..2b14004 100644 --- a/requirements.txt +++ b/admin/requirements/requirements.txt @@ -2,17 +2,17 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile requirements.in +# pip-compile 'requirements.in' # -pyside6==6.9.2 +pyside6==6.10.0 # via -r requirements.in -pyside6-addons==6.9.2 +pyside6-addons==6.10.0 # via pyside6 -pyside6-essentials==6.9.2 +pyside6-essentials==6.10.0 # via # pyside6 # pyside6-addons -shiboken6==6.9.2 +shiboken6==6.10.0 # via # pyside6 # pyside6-addons diff --git a/admin/test.py b/admin/test.py new file mode 100644 index 0000000..f4c6d3b --- /dev/null +++ b/admin/test.py @@ -0,0 +1,29 @@ +#!python +""" +Testing with `pytest`. +""" + +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. + + Unit test configuration in ``pyproject.toml``. + """ + run(dry, 'pytest', '.') + + +if __name__ == '__main__': + app() diff --git a/admin/utils.py b/admin/utils.py new file mode 100644 index 0000000..a236d4f --- /dev/null +++ b/admin/utils.py @@ -0,0 +1,98 @@ +import logging +import subprocess +import sys +from enum import Enum +from typing import Annotated + +import typer +from rich.logging import RichHandler + +from admin import PROJECT_ROOT + +DryAnnotation = Annotated[ + bool, + typer.Option( + help='Show the command that would be run without running it.', + show_default=False, + ), +] + + +class OS(str, Enum): + """Operating System.""" + + Linux = 'linux' + MacOS = 'mac' + Windows = 'win' + + +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(dry: bool, *args) -> subprocess.CompletedProcess | None: + logger.info(' '.join(map(str, args))) + + if dry: + return None + + try: + return subprocess.run(args, cwd=PROJECT_ROOT, check=True) + 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 + + return importlib.util.find_spec(package_name) is not None + + +def install_package(package: str, dry: bool = False): + """Install a Python package if not already installed.""" + if is_package_installed(package): + logger.debug(f'Package `{package}` is already installed.') + return + + run(dry, sys.executable, '-m', 'pip', 'install', package) + + +def get_logger(name=None, level=logging.DEBUG) -> logging.Logger: + """Set up logging configuration with Rich handler and custom formatting.""" + + # Create logger + _logger = logging.getLogger('typer-invoke') + _logger.setLevel(level) + _logger.handlers.clear() + handler = RichHandler( + level=level, + show_time=False, + show_level=True, + markup=True, + rich_tracebacks=False, + ) + + # Set custom format string and add handler + formatter = logging.Formatter(fmt='%(message)s', datefmt='[%X]') # Time format: [HH:MM:SS] + handler.setFormatter(formatter) + _logger.addHandler(handler) + + # Prevent logs from being handled by root logger (avoid duplicate output) + _logger.propagate = False + + return _logger + + +logger = get_logger() diff --git a/pyproject.toml b/pyproject.toml index 859c713..0cd1fe8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,22 @@ disable_error_code = 'annotation-unchecked' ignore_missing_imports = true module = ['invoke', 'factory', 'pytest_check', 'pytest_params', 'PySide6.*'] +[tool.typer-invoke] +# Paths from where `pyproject.toml` is located (root of project) +modules = [ + 'admin.build', + 'admin.docs', + 'admin.lint', + 'admin.pip', + 'admin.precommit', + 'admin.test', +] +no_args_is_help = true +add_completion = false +rich_markup_mode = 'markdown' +logging_level = 'INFO' +logging_format = '%(message)s' + [build-system] requires = ['flit_core >=3.2,<4'] build-backend = 'flit_core.buildapi' diff --git a/tasks.py b/tasks.py index a3adfc9..d40bf93 100644 --- a/tasks.py +++ b/tasks.py @@ -776,7 +776,7 @@ def lint_mypy(c, path='.'): def lint_all(c): """ Run all linters. - Config for each of the tools is in ``pyproject.toml`` and ``setup.cfg``. + Config for each of the tools is in ``pyproject.toml``. """ lint_isort(c) lint_black(c)