From ee46c6231b719a0d55e47737c0fc284d6fc36f7a Mon Sep 17 00:00:00 2001 From: nstarman Date: Mon, 9 Sep 2024 11:29:29 -0400 Subject: [PATCH] initial commit Signed-off-by: nstarman --- .copier-answers.yml | 12 +++ .git_archival.txt | 4 + .gitattributes | 1 + .github/CONTRIBUTING.md | 101 +++++++++++++++++++++ .github/dependabot.yml | 11 +++ .github/matchers/pylint.json | 32 +++++++ .github/workflows/cd.yml | 54 +++++++++++ .github/workflows/ci.yml | 73 +++++++++++++++ .gitignore | 158 +++++++++++++++++++++++++++++++++ .pre-commit-config.yaml | 91 +++++++++++++++++++ .readthedocs.yaml | 18 ++++ LICENSE | 19 ++++ README.md | 27 ++++++ noxfile.py | 116 ++++++++++++++++++++++++ pyproject.toml | 143 +++++++++++++++++++++++++++++ src/optional_deps/__init__.py | 13 +++ src/optional_deps/_core.py | 118 ++++++++++++++++++++++++ src/optional_deps/_version.pyi | 2 + src/optional_deps/py.typed | 0 tests/__init__.py | 1 + tests/test_misc.py | 34 +++++++ tests/test_package.py | 10 +++ 22 files changed, 1038 insertions(+) create mode 100644 .copier-answers.yml create mode 100644 .git_archival.txt create mode 100644 .gitattributes create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/dependabot.yml create mode 100644 .github/matchers/pylint.json create mode 100644 .github/workflows/cd.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .readthedocs.yaml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 noxfile.py create mode 100644 pyproject.toml create mode 100644 src/optional_deps/__init__.py create mode 100644 src/optional_deps/_core.py create mode 100644 src/optional_deps/_version.pyi create mode 100644 src/optional_deps/py.typed create mode 100644 tests/__init__.py create mode 100644 tests/test_misc.py create mode 100644 tests/test_package.py diff --git a/.copier-answers.yml b/.copier-answers.yml new file mode 100644 index 0000000..4014e19 --- /dev/null +++ b/.copier-answers.yml @@ -0,0 +1,12 @@ +# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY +_commit: 2024.04.23 +_src_path: gh:scientific-python/cookie +backend: hatch +email: nstarman@users.noreply.github.com +full_name: GalacticDynamics Maintainers +license: MIT +org: GalacticDynamics +project_name: optional_deps +project_short_description: Check for Optional Dependencies +url: https://github.com/GalacticDynamics/optional_deps +vcs: true diff --git a/.git_archival.txt b/.git_archival.txt new file mode 100644 index 0000000..8fb235d --- /dev/null +++ b/.git_archival.txt @@ -0,0 +1,4 @@ +node: $Format:%H$ +node-date: $Format:%cI$ +describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$ +ref-names: $Format:%D$ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..00a7b00 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.git_archival.txt export-subst diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..965b2f0 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,101 @@ +See the [Scientific Python Developer Guide][spc-dev-intro] for a detailed +description of best practices for developing scientific packages. + +[spc-dev-intro]: https://learn.scientific-python.org/development/ + +# Quick development + +The fastest way to start with development is to use nox. If you don't have nox, +you can use `pipx run nox` to run it without installing, or `pipx install nox`. +If you don't have pipx (pip for applications), then you can install with +`pip install pipx` (the only case were installing an application with regular +pip is reasonable). If you use macOS, then pipx and nox are both in brew, use +`brew install pipx nox`. + +To use, run `nox`. This will lint and test using every installed version of +Python on your system, skipping ones that are not installed. You can also run +specific jobs: + +```console +$ nox -s lint # Lint only +$ nox -s tests # Python tests +$ nox -s docs -- --serve # Build and serve the docs +$ nox -s build # Make an SDist and wheel +``` + +Nox handles everything for you, including setting up an temporary virtual +environment for each run. + +# Setting up a development environment manually + +You can set up a development environment by running: + +```bash +python3 -m venv .venv +source ./.venv/bin/activate +pip install -v -e .[dev] +``` + +If you have the +[Python Launcher for Unix](https://github.com/brettcannon/python-launcher), you +can instead do: + +```bash +py -m venv .venv +py -m install -v -e .[dev] +``` + +# Post setup + +You should prepare pre-commit, which will help you by checking that commits pass +required checks: + +```bash +pip install pre-commit # or brew install pre-commit on macOS +pre-commit install # Will install a pre-commit hook into the git repo +``` + +You can also/alternatively run `pre-commit run` (changes only) or +`pre-commit run --all-files` to check even without installing the hook. + +# Testing + +Use pytest to run the unit checks: + +```bash +pytest +``` + +# Coverage + +Use pytest-cov to generate coverage reports: + +```bash +pytest --cov=optional_deps +``` + +# Building docs + +You can build the docs using: + +```bash +nox -s docs +``` + +You can see a preview with: + +```bash +nox -s docs -- --serve +``` + +# Pre-commit + +This project uses pre-commit for all style checking. While you can run it with +nox, this is such an important tool that it deserves to be installed on its own. +Install pre-commit and run: + +```bash +pre-commit run -a +``` + +to check all files. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6c4b369 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + actions: + patterns: + - "*" diff --git a/.github/matchers/pylint.json b/.github/matchers/pylint.json new file mode 100644 index 0000000..e3a6bd1 --- /dev/null +++ b/.github/matchers/pylint.json @@ -0,0 +1,32 @@ +{ + "problemMatcher": [ + { + "severity": "warning", + "pattern": [ + { + "regexp": "^([^:]+):(\\d+):(\\d+): ([A-DF-Z]\\d+): \\033\\[[\\d;]+m([^\\033]+).*$", + "file": 1, + "line": 2, + "column": 3, + "code": 4, + "message": 5 + } + ], + "owner": "pylint-warning" + }, + { + "severity": "error", + "pattern": [ + { + "regexp": "^([^:]+):(\\d+):(\\d+): (E\\d+): \\033\\[[\\d;]+m([^\\033]+).*$", + "file": 1, + "line": 2, + "column": 3, + "code": 4, + "message": 5 + } + ], + "owner": "pylint-error" + } + ] +} diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..ead459d --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,54 @@ +name: CD + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + release: + types: + - published + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + # Many color libraries just need this to be set to any value, but at least + # one distinguishes color depth, where "3" -> "256-bit color". + FORCE_COLOR: 3 + +jobs: + dist: + name: Distribution build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: hynek/build-and-inspect-python-package@v2 + + publish: + needs: [dist] + name: Publish to PyPI + environment: pypi + permissions: + id-token: write + runs-on: ubuntu-latest + if: github.event_name == 'release' && github.event.action == 'published' + + steps: + - uses: actions/download-artifact@v4 + with: + name: Packages + path: dist + + - uses: pypa/gh-action-pypi-publish@release/v1 + if: github.event_name == 'release' && github.event.action == 'published' + with: + # Remember to tell (test-)pypi about this repo before publishing + # Remove this line to publish to PyPI + repository-url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..965e85a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + # Many color libraries just need this to be set to any value, but at least + # one distinguishes color depth, where "3" -> "256-bit color". + FORCE_COLOR: 3 + +jobs: + pre-commit: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - uses: pre-commit/action@v3.0.1 + with: + extra_args: --hook-stage manual --all-files + - name: Run PyLint + run: | + echo "::add-matcher::$GITHUB_WORKSPACE/.github/matchers/pylint.json" + pipx run nox -s pylint + + checks: + name: Check Python ${{ matrix.python-version }} on ${{ matrix.runs-on }} + runs-on: ${{ matrix.runs-on }} + needs: [pre-commit] + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.12"] + runs-on: [ubuntu-latest, macos-latest, windows-latest] + + include: + - python-version: pypy-3.10 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + + - name: Install package + run: python -m pip install .[test] + + - name: Test package + run: >- + python -m pytest -ra --cov --cov-report=xml --cov-report=term + --durations=20 + + - name: Upload coverage report + uses: codecov/codecov-action@v4.5.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25cf9a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,158 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# setuptools_scm +src/*/_version.py + + +# ruff +.ruff_cache/ + +# OS specific stuff +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Common editor files +*~ +*.swp diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b84d539 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,91 @@ +ci: + autoupdate_commit_msg: "chore: update pre-commit hooks" + autofix_commit_msg: "style: pre-commit fixes" + +repos: + - repo: https://github.com/adamchainz/blacken-docs + rev: "1.18.0" + hooks: + - id: blacken-docs + additional_dependencies: [black==24.*] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: "v4.6.0" + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-merge-conflict + - id: check-symlinks + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: mixed-line-ending + - id: name-tests-test + args: ["--pytest-test-first"] + - id: trailing-whitespace + + - repo: https://github.com/pre-commit/pygrep-hooks + rev: "v1.10.0" + hooks: + - id: rst-backticks + - id: rst-directive-colons + - id: rst-inline-touching-normal + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: "v4.0.0-alpha.8" + hooks: + - id: prettier + types_or: [yaml, markdown, html, css, scss, javascript, json] + args: [--prose-wrap=always] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: "v0.6.4" + hooks: + # Run the linter + - id: ruff + types_or: [python, pyi, jupyter] + args: ["--fix", "--show-fixes"] + # Run the formatter + - id: ruff-format + types_or: [python, pyi, jupyter] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: "v1.11.2" + hooks: + - id: mypy + files: src + exclude: ^(src/optional_deps/__init__\.py)$ + args: [] + additional_dependencies: + - pytest + + - repo: https://github.com/codespell-project/codespell + rev: "v2.3.0" + hooks: + - id: codespell + + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: "v0.10.0.1" + hooks: + - id: shellcheck + + - repo: local + hooks: + - id: disallow-caps + name: Disallow improper capitalization + language: pygrep + entry: PyBind|Numpy|Cmake|CCache|Github|PyTest + exclude: .pre-commit-config.yaml + + - repo: https://github.com/abravalheri/validate-pyproject + rev: "v0.19" + hooks: + - id: validate-pyproject + additional_dependencies: ["validate-pyproject-schema-store[all]"] + + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: "0.29.2" + hooks: + - id: check-dependabot + - id: check-github-workflows + - id: check-readthedocs diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..7e49657 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,18 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.11" +sphinx: + configuration: docs/conf.py + +python: + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0e698c9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright 2024 GalacticDynamics Maintainers + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f885e0 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# optional_deps + +[![Actions Status][actions-badge]][actions-link] +[![Documentation Status][rtd-badge]][rtd-link] + +[![PyPI version][pypi-version]][pypi-link] +[![Conda-Forge][conda-badge]][conda-link] +[![PyPI platforms][pypi-platforms]][pypi-link] + +[![GitHub Discussion][github-discussions-badge]][github-discussions-link] + + + + +[actions-badge]: https://github.com/GalacticDynamics/optional_deps/workflows/CI/badge.svg +[actions-link]: https://github.com/GalacticDynamics/optional_deps/actions +[conda-badge]: https://img.shields.io/conda/vn/conda-forge/optional_deps +[conda-link]: https://github.com/conda-forge/optional_deps-feedstock +[github-discussions-badge]: https://img.shields.io/static/v1?label=Discussions&message=Ask&color=blue&logo=github +[github-discussions-link]: https://github.com/GalacticDynamics/optional_deps/discussions +[pypi-link]: https://pypi.org/project/optional_deps/ +[pypi-platforms]: https://img.shields.io/pypi/pyversions/optional_deps +[pypi-version]: https://img.shields.io/pypi/v/optional_deps +[rtd-badge]: https://readthedocs.org/projects/optional_deps/badge/?version=latest +[rtd-link]: https://optional_deps.readthedocs.io/en/latest/?badge=latest + + diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..d6ad7ae --- /dev/null +++ b/noxfile.py @@ -0,0 +1,116 @@ +"""Nox configuration.""" + +import argparse +import shutil +from pathlib import Path + +import nox + +DIR = Path(__file__).parent.resolve() + +nox.needs_version = ">=2024.3.2" +nox.options.sessions = ["lint", "pylint", "tests"] +nox.options.default_venv_backend = "uv|virtualenv" + + +@nox.session +def lint(session: nox.Session) -> None: + """Run the linter.""" + session.install("pre-commit") + session.run( + "pre-commit", + "run", + "--all-files", + "--show-diff-on-failure", + *session.posargs, + ) + + +@nox.session +def pylint(session: nox.Session) -> None: + """Run PyLint.""" + # This needs to be installed into the package environment, and is slower + # than a pre-commit check + session.install(".", "pylint") + session.run("pylint", "optional_deps", *session.posargs) + + +@nox.session +def tests(session: nox.Session) -> None: + """Run the unit and regular tests.""" + session.install(".[test]") + session.run("pytest", *session.posargs) + + +@nox.session(reuse_venv=True) +def docs(session: nox.Session) -> None: + """Build the docs. Pass "--serve" to serve. Pass "-b linkcheck" to check links.""" + parser = argparse.ArgumentParser() + parser.add_argument("--serve", action="store_true", help="Serve after building") + parser.add_argument( + "-b", + dest="builder", + default="html", + help="Build target (default: html)", + ) + args, posargs = parser.parse_known_args(session.posargs) + + if args.builder != "html" and args.serve: + session.error("Must not specify non-HTML builder with --serve") + + extra_installs = ["sphinx-autobuild"] if args.serve else [] + + session.install("-e.[docs]", *extra_installs) + session.chdir("docs") + + if args.builder == "linkcheck": + session.run( + "sphinx-build", + "-b", + "linkcheck", + ".", + "_build/linkcheck", + *posargs, + ) + return + + shared_args = ( + "-n", # nitpicky mode + "-T", # full tracebacks + f"-b={args.builder}", + ".", + f"_build/{args.builder}", + *posargs, + ) + + if args.serve: + session.run("sphinx-autobuild", *shared_args) + else: + session.run("sphinx-build", "--keep-going", *shared_args) + + +@nox.session +def build_api_docs(session: nox.Session) -> None: + """Build (regenerate) API docs.""" + session.install("sphinx") + session.chdir("docs") + session.run( + "sphinx-apidoc", + "-o", + "api/", + "--module-first", + "--no-toc", + "--force", + "../src/optional_deps", + ) + + +@nox.session +def build(session: nox.Session) -> None: + """Build an SDist and wheel.""" + build_path = DIR.joinpath("build") + if build_path.exists(): + shutil.rmtree(build_path) + + session.install("build") + session.run("python", "-m", "build") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5dbedc5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,143 @@ +[build-system] + build-backend = "hatchling.build" + requires = ["hatch-vcs", "hatchling"] + + +[project] + authors = [ + { name = "GalacticDynamics Maintainers", email = "nstarman@users.noreply.github.com" }, + ] + classifiers = [ + "Development Status :: 1 - Planning", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python", + "Topic :: Scientific/Engineering", + "Typing :: Typed", + ] + dependencies = [ + "packaging", + ] + description = "Check for Optional Dependencies" + dynamic = ["version"] + license.file = "LICENSE" + name = "optional_deps" + readme = "README.md" + requires-python = ">=3.8" + + [project.optional-dependencies] + dev = ["pytest >=6", "pytest-cov >=3"] + docs = [ + "furo>=2023.08.17", + "myst_parser>=0.13", + "sphinx>=7.0", + "sphinx_autodoc_typehints", + "sphinx_copybutton", + ] + test = ["pytest >=6", "pytest-cov >=3"] + + [project.urls] + "Bug Tracker" = "https://github.com/GalacticDynamics/optional_deps/issues" + Changelog = "https://github.com/GalacticDynamics/optional_deps/releases" + Discussions = "https://github.com/GalacticDynamics/optional_deps/discussions" + Homepage = "https://github.com/GalacticDynamics/optional_deps" + + +[tool.hatch] + build.hooks.vcs.version-file = "src/optional_deps/_version.py" + version.source = "vcs" + + [tool.hatch.envs.default] + features = ["test"] + scripts.test = "pytest {args}" + + +[tool.pytest.ini_options] + addopts = ["--showlocals", "--strict-config", "--strict-markers", "-ra"] + filterwarnings = ["error"] + log_cli_level = "INFO" + minversion = "6.0" + testpaths = ["tests"] + xfail_strict = true + + +[tool.coverage] + report.exclude_also = ['\.\.\.', 'if typing.TYPE_CHECKING:'] + run.source = ["optional_deps"] + +[tool.mypy] + disallow_incomplete_defs = false + disallow_untyped_defs = false + enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] + exclude = [ + '(^/)docs/', # docs + '(^|/)tests/', # tests + '^conftest\.py$', # nox test configuration + ] + files = ["src"] + python_version = "3.8" + strict = true + warn_unreachable = true + warn_unused_configs = true + + [[tool.mypy.overrides]] + disallow_incomplete_defs = true + disallow_untyped_defs = true + module = "optional_deps.*" + + +[tool.ruff] + src = ["src"] + target-version = "py38" + + [tool.ruff.lint] + extend-select = ["ALL"] + ignore = [ + "ANN101", # Missing type annotation for self in method + "COM812", + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "ISC001", # Conflicts with formatter + "PLR09", # Too many <...> + "PLR2004", # Magic value used in comparison + ] + + [tool.ruff.lint.per-file-ignores] + "docs/conf.py" = [ + "A001", # Variable `copyright` is shadowing a Python builtin + "INP001", # implicit namespace package + ] + "noxfile.py" = ["T20"] + "tests/**" = [ + "ANN", + "D10", + "S101", # Use of assert detected, + "T20", + ] + + [tool.ruff.lint.isort] + combine-as-imports = true + + +[tool.pylint] + ignore-paths = [".*/__init__.pyi", ".*/_version.py"] + messages_control.disable = [ + "design", + "fixme", + "line-too-long", + "missing-function-docstring", + "missing-module-docstring", + "wrong-import-position", + ] + py-version = "3.8" + reports.output-format = "colorized" + similarities.ignore-imports = "yes" diff --git a/src/optional_deps/__init__.py b/src/optional_deps/__init__.py new file mode 100644 index 0000000..f064d04 --- /dev/null +++ b/src/optional_deps/__init__.py @@ -0,0 +1,13 @@ +"""Copyright (c) 2024 GalacticDynamics Maintainers. All rights reserved. + +optional_deps: Check for Optional Dependencies +""" +# pylint: disable=import-outside-toplevel + +__all__ = ["OptionalDependencyEnum", "auto"] + + +from enum import auto + +from ._core import OptionalDependencyEnum +from ._version import __version__ # noqa: F401 diff --git a/src/optional_deps/_core.py b/src/optional_deps/_core.py new file mode 100644 index 0000000..81714db --- /dev/null +++ b/src/optional_deps/_core.py @@ -0,0 +1,118 @@ +"""Optional dependencies.""" + +from __future__ import annotations + +__all__: list[str] = [] + +import importlib.metadata +from enum import Enum +from typing import Literal, cast + +from packaging.utils import canonicalize_name +from packaging.version import Version, parse + + +def _get_version(package_name: str, /) -> Version | Literal[False]: + """Get the version of a package if it is installed. + + Parameters + ---------- + package_name : str + The name of the package to check. + + Returns + ------- + Version | Literal[False] + The version of the package if it is installed, or False if it is not. + + Examples + -------- + >>> _get_version("packaging") + + + """ + try: + # Get the version string of the package + version_str = importlib.metadata.version(package_name) + except importlib.metadata.PackageNotFoundError: + return False + # Parse the version string using packaging.version.parse + return parse(version_str) + + +class OptionalDependencyEnum(Enum): + """An enumeration of optional dependencies.""" + + @staticmethod + def _generate_next_value_( + name: str, + start: int, # noqa: ARG004 + count: int, # noqa: ARG004 + last_values: list[Version | Literal[False]], # noqa: ARG004 + ) -> Version | Literal[False]: + """Generate the next value (optional dependency info) for the Enum. + + Parameters + ---------- + name : str + The name of the package to check. + start : int + The starting value for the enumeration. + count : int + The number of values in the enumeration. + last_values : list[Version | Literal[False]] + The last values generated for the enumeration + + """ + return _get_version(canonicalize_name(name)) + + @property + def is_installed(self) -> bool: + """Check if the optional dependency is installed. + + Returns + ------- + bool + True if the dependency is installed, False otherwise + + Examples + -------- + >>> from enum import auto + >>> class OptDeps(OptionalDependencyEnum): + ... PACKAGING = auto() + + >>> OptDeps.PACKAGING.is_installed + True + + """ + return self.value is not False + + @property + def version(self) -> Version: + """Get the version of the optional dependency. + + Returns + ------- + Version + The version of the optional dependency if it is installed + + Raises + ------ + ImportError + If the optional dependency is not installed + + Examples + -------- + >>> from enum import auto + >>> class OptDeps(OptionalDependencyEnum): + ... PACKAGING = auto() + + >>> OptDeps.PACKAGING.version + + + """ + if not self.is_installed: + msg = f"{self.name} is not installed" + raise ImportError(msg) + + return cast(Version, self.value) diff --git a/src/optional_deps/_version.pyi b/src/optional_deps/_version.pyi new file mode 100644 index 0000000..5bb2b22 --- /dev/null +++ b/src/optional_deps/_version.pyi @@ -0,0 +1,2 @@ +version: str +version_tuple: tuple[int, int, int] | tuple[int, int, int, str, str] diff --git a/src/optional_deps/py.typed b/src/optional_deps/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d420712 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests.""" diff --git a/tests/test_misc.py b/tests/test_misc.py new file mode 100644 index 0000000..b36c0ab --- /dev/null +++ b/tests/test_misc.py @@ -0,0 +1,34 @@ +"""Test the package.""" + +import pytest +from packaging.version import Version + +from optional_deps import OptionalDependencyEnum, auto + + +class OptDeps(OptionalDependencyEnum): + PACKAGING = auto() # Runtime dependency + PYTEST = auto() # Test dependency + NOTINSTALLED = auto() # Not installed + + +def test_enum_member_exists(): + assert hasattr(OptDeps, "PACKAGING"), "PACKAGING member should exist in OptDeps" + assert hasattr(OptDeps, "PYTEST"), "PYTEST member should exist in OptDeps" + assert hasattr( + OptDeps, "NOTINSTALLED" + ), "NOTINSTALLED member should exist in OptDeps" + + +def test_is_installed(): + assert OptDeps.PACKAGING.is_installed, "PACKAGING should be installed" + assert OptDeps.PYTEST.is_installed, "PYTEST should be installed" + assert not OptDeps.NOTINSTALLED.is_installed, "NOTINSTALLED should not be installed" + + +def test_version(): + assert isinstance(OptDeps.PACKAGING.version, Version) + assert isinstance(OptDeps.PYTEST.version, Version) + + with pytest.raises(ImportError): + _ = OptDeps.NOTINSTALLED.version diff --git a/tests/test_package.py b/tests/test_package.py new file mode 100644 index 0000000..bfb970c --- /dev/null +++ b/tests/test_package.py @@ -0,0 +1,10 @@ +"""Test the package itself.""" + +import importlib.metadata + +import optional_deps as pkg + + +def test_version(): + """Test the version.""" + assert importlib.metadata.version("optional_deps") == pkg.__version__