diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 00623fd..18a969c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -33,58 +33,34 @@ jobs: if: steps.changes.outputs.docs == 'true' || steps.changes.outputs.root_docs == 'true' || steps.changes.outputs.python_files == 'true' run: echo "PUBLISH=$(echo true)" >> $GITHUB_ENV - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - - name: Get full Python version - id: full-python-version - shell: bash - run: echo ::set-output name=version::$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info))") - - name: Install poetry - run: | - curl -O -sSL https://install.python-poetry.org/install-poetry.py - python install-poetry.py -y --version 1.1.14 - echo "PATH=${HOME}/.poetry/bin:${PATH}" >> $GITHUB_ENV - rm install-poetry.py + if: env.PUBLISH == 'true' + run: pipx install "poetry==1.1.14" - - name: Add ~/.local/bin to PATH - run: echo "$HOME/.local/bin" >> $GITHUB_PATH - - - name: Get poetry cache paths from config - run: | - echo "poetry_virtualenvs_path=$(poetry config --list | sed -n 's/.*virtualenvs.path = .* # //p' | sed -e 's/^\"//' -e 's/\"$//')" >> $GITHUB_ENV - echo "poetry_virtualenvs_path=$(poetry config --list | sed -n 's/.*virtualenvs.path = .* # //p' | sed -e 's/^\"//' -e 's/\"$//')" >> $GITHUB_ENV - - - name: Configure poetry - shell: bash - run: poetry config virtualenvs.in-project true - - - name: Set up cache - uses: actions/cache@v3 - id: cache + - name: Set up Python ${{ matrix.python-version }} + if: env.PUBLISH == 'true' + uses: actions/setup-python@v4 with: - path: | - .venv - {{ env.poetry_cache_dir }} - {{ env.poetry_virtualenvs_path }} - key: venv-${{ runner.os }}-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('**/poetry.lock') }} - - - name: Ensure cache is healthy - if: steps.cache.outputs.cache-hit == 'true' - shell: bash - run: poetry run pip --version >/dev/null 2>&1 || rm -rf .venv + python-version: ${{ matrix.python-version }} + cache: 'poetry' - name: Install dependencies [w/ docs] + if: env.PUBLISH == 'true' run: poetry install --extras "docs lint" + - name: Print python versions + if: env.PUBLISH == 'true' + run: | + python -V + poetry run python -V + - name: Build documentation + if: env.PUBLISH == 'true' run: | pushd docs; make SPHINXBUILD='poetry run sphinx-build' html; popd - name: Push documentation to S3 + if: env.PUBLISH == 'true' uses: jakejarvis/s3-sync-action@v0.5.1 with: args: --acl public-read --follow-symlinks --delete @@ -96,6 +72,7 @@ jobs: SOURCE_DIR: "docs/_build/html" # optional: defaults to entire repository - name: Purge cache on Cloudflare + if: env.PUBLISH == 'true' uses: jakejarvis/cloudflare-purge-action@master env: CLOUDFLARE_TOKEN: ${{ secrets.CLOUDFLARE_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cdd0ba9..aea91ea 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,56 +12,30 @@ jobs: python-version: ["3.7", "3.10"] steps: - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - - name: Get full Python version - id: full-python-version - shell: bash - run: echo ::set-output name=version::$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info))") - name: Install poetry - run: | - curl -O -sSL https://install.python-poetry.org/install-poetry.py - python install-poetry.py -y --version 1.1.14 - echo "PATH=${HOME}/.poetry/bin:${PATH}" >> $GITHUB_ENV - rm install-poetry.py - - - name: Add ~/.local/bin to PATH - run: echo "$HOME/.local/bin" >> $GITHUB_PATH - - - name: Get poetry cache paths from config - run: | - echo "poetry_virtualenvs_path=$(poetry config --list | sed -n 's/.*virtualenvs.path = .* # //p' | sed -e 's/^\"//' -e 's/\"$//')" >> $GITHUB_ENV - echo "poetry_virtualenvs_path=$(poetry config --list | sed -n 's/.*virtualenvs.path = .* # //p' | sed -e 's/^\"//' -e 's/\"$//')" >> $GITHUB_ENV + run: pipx install "poetry==1.1.14" - - name: Configure poetry - shell: bash - run: poetry config virtualenvs.in-project true - - - name: Set up cache - uses: actions/cache@v3 - id: cache + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 with: - path: | - .venv - ${{ env.poetry_cache_dir }} - ${{ env.poetry_virtualenvs_path }} - key: venv-${{ runner.os }}-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('**/poetry.lock') }} - - - name: Ensure cache is healthy - if: steps.cache.outputs.cache-hit == 'true' - shell: bash - run: poetry run pip --version >/dev/null 2>&1 || rm -rf .venv + python-version: ${{ matrix.python-version }} + cache: 'poetry' - name: Install dependencies run: poetry install -E "docs test coverage lint format" + - name: Print python versions + run: | + python -V + poetry run python -V + - name: Lint with flake8 run: poetry run flake8 + - name: Lint with mypy + run: poetry run mypy . + - name: Test with pytest run: poetry run py.test --cov=./ --cov-report=xml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 8d90136..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,15 +0,0 @@ -repos: -- repo: https://github.com/psf/black - rev: 22.1.0 - hooks: - - id: black - language_version: python3.10 -- repo: https://github.com/pycqa/isort - rev: 5.10.1 - hooks: - - id: isort - name: isort (python) -- repo: https://github.com/PyCQA/flake8 - rev: 4.0.1 - hooks: - - id: flake8 diff --git a/CHANGES b/CHANGES index 6b4ef59..b9144f9 100644 --- a/CHANGES +++ b/CHANGES @@ -15,10 +15,31 @@ $ pipx install --suffix=@next 'g' --pip-args '\--pre' --force // Usage: g@next --help ``` -## current - unrelased +## g 0.0.1 (unreleased) - _Add your latest changes from PRs here_ +### Development + +Infrastructure updates for static type checking and doctest examples. + +- Update development packages (black, isort) +- Add .tool-versions, .python-version +- Run code through black w/o `--skip-string-normalization` + +- Initial [doctests] support added, via #2 + + [doctests]: https://docs.python.org/3/library/doctest.html + +- Initial [mypy] validation, via #2 + + [mypy]: https://github.com/python/mypy + +- CI (tests, docs): Improve caching of python dependencies via + `action/setup-python`'s v3/4's new poetry caching, via #2 + +- CI (docs): Skip if no `PUBLISH` condition triggered, via #2 + ## g 0.0.0 (2022-02-26) ### Documentation diff --git a/Makefile b/Makefile index 4636709..2ec2592 100644 --- a/Makefile +++ b/Makefile @@ -49,3 +49,9 @@ watch_mypy: format_markdown: prettier --parser=markdown -w *.md docs/*.md docs/**/*.md CHANGES + +monkeytype_create: + poetry run monkeytype run `poetry run which py.test` + +monkeytype_apply: + poetry run monkeytype list-modules | xargs -n1 -I{} sh -c 'poetry run monkeytype apply {}' diff --git a/docs/conf.py b/docs/conf.py index be89af4..a981595 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,18 +1,23 @@ # flake8: noqa E501 +import inspect import os import sys +import typing as t +from os.path import dirname, relpath from pathlib import Path +import g + # Get the project root dir, which is the parent dir of this -cwd = Path.cwd() +cwd = Path(__file__).parent project_root = cwd.parent sys.path.insert(0, str(project_root)) sys.path.insert(0, str(cwd / "_ext")) # package data -about = {} -with open("../g/__about__.py") as fp: +about: t.Dict[str, str] = {} +with open(project_root / "g" / "__about__.py") as fp: exec(fp.read(), about) extensions = [ @@ -31,11 +36,6 @@ ] myst_enable_extensions = ["colon_fence", "substitution", "replacements"] -# app setup hook -def setup(app): - pass - - issues_github_path = about["__github__"].replace("https://github.com/", "") templates_path = ["_templates"] @@ -59,8 +59,8 @@ def setup(app): html_css_files = ["css/custom.css"] html_extra_path = ["manifest.json"] html_theme = "furo" -html_theme_path = [] -html_theme_options = { +html_theme_path: t.List[str] = [] +html_theme_options: t.Dict[str, t.Union[str, t.List[t.Dict[str, str]]]] = { "light_logo": "img/g.svg", "dark_logo": "img/g-dark.svg", "footer_icons": [ @@ -144,13 +144,9 @@ def setup(app): } -def linkcode_resolve(domain, info): # NOQA: C901 - import inspect - import sys - from os.path import dirname, relpath - - import g - +def linkcode_resolve( + domain: str, info: t.Dict[str, str] +) -> t.Union[None, str]: # NOQA: C901 """ Determine the URL corresponding to Python object @@ -183,7 +179,8 @@ def linkcode_resolve(domain, info): # NOQA: C901 except AttributeError: pass else: - obj = unwrap(obj) + if callable(obj): + obj = unwrap(obj) try: fn = inspect.getsourcefile(obj) @@ -205,14 +202,14 @@ def linkcode_resolve(domain, info): # NOQA: C901 fn = relpath(fn, start=dirname(g.__file__)) if "dev" in about["__version__"]: - return "%s/blob/master/%s/%s%s" % ( + return "{}/blob/master/{}/{}{}".format( about["__github__"], about["__package_name__"], fn, linespec, ) else: - return "%s/blob/v%s/%s/%s%s" % ( + return "{}/blob/v{}/{}/{}{}".format( about["__github__"], about["__version__"], about["__package_name__"], diff --git a/g/__init__.py b/g/__init__.py index 8241f56..56098f1 100755 --- a/g/__init__.py +++ b/g/__init__.py @@ -1,33 +1,51 @@ #!/usr/bin/env python - import pathlib import subprocess import sys +import typing as t +from os import PathLike + +__all__ = ["sys", "vcspath_registry", "DEFAULT", "run"] vcspath_registry = {".git": "git", ".svn": "svn", ".hg": "hg"} -def find_repo_type(path): +def find_repo_type(path: t.Union[pathlib.Path, str]) -> t.Optional[str]: for path in list(pathlib.Path(path).parents) + [pathlib.Path(path)]: for p in path.iterdir(): if p.is_dir(): if p.name in vcspath_registry: return vcspath_registry[p.name] + return None DEFAULT = object() -def run(cmd=DEFAULT, cmd_args=DEFAULT, *args, **kwargs): +def run( + cmd: t.Union[str, bytes, "PathLike[str]", "PathLike[bytes]", object] = DEFAULT, + cmd_args: object = DEFAULT, + wait: bool = False, + *args: object, + **kwargs: t.Any +) -> t.Optional["subprocess.Popen[str]"]: # Interpret default kwargs lazily for mockability of argv if cmd is DEFAULT: cmd = find_repo_type(pathlib.Path.cwd()) if cmd_args is DEFAULT: cmd_args = sys.argv[1:] - proc = subprocess.Popen([cmd, *cmd_args]) - proc.communicate() + + assert isinstance(cmd_args, (tuple, list)) + assert isinstance(cmd, (str, bytes, pathlib.Path)) + + proc = subprocess.Popen([cmd, *cmd_args], **kwargs) + if wait: + proc.wait() + else: + proc.communicate() if __name__ != "__main__": return proc + return None if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index d3ed80d..8ae2698 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,6 +105,9 @@ coverage = ["codecov", "coverage", "pytest-cov"] format = ["black", "isort"] lint = ["flake8", "mypy"] +[tool.mypy] +strict = true + [build-system] requires = ["poetry_core>=1.0.0", "setuptools>60"] build-backend = "poetry.core.masonry.api" diff --git a/setup.cfg b/setup.cfg index cb694ae..100c66e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,3 +15,11 @@ known_pytest = pytest,py known_first_party = g sections = FUTURE,STDLIB,PYTEST,THIRDPARTY,FIRSTPARTY,LOCALFOLDER line_length = 88 + +[tool:pytest] +addopts = --tb=short --no-header --showlocals --doctest-modules +doctest_optionflags = ELLIPSIS NORMALIZE_WHITESPACE +testpaths = + g + tests + docs diff --git a/tests/test_cli.py b/tests/test_cli.py index 9debb10..aec34dd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,5 @@ import subprocess +import typing as t from unittest.mock import patch import pytest @@ -6,7 +7,9 @@ from g import run -def get_output(*args, **kwargs): +def get_output( + *args: t.Any, **kwargs: t.Any +) -> t.Union[subprocess.CalledProcessError, t.Any]: try: result = subprocess.check_output(*args, **kwargs) return result @@ -21,11 +24,17 @@ def get_output(*args, **kwargs): (["g", "--help"], "git --help"), ], ) -def test_command_line(capsys: pytest.CaptureFixture[str], argv_args, expect_cmd): +def test_command_line( + # capsys: pytest.CaptureFixture[str], + argv_args: t.List[str], + expect_cmd: str, +) -> None: from g import sys as gsys with patch.object(gsys, "argv", argv_args): - run(stdout=subprocess.STDOUT, stderr=subprocess.STDOUT) - captured = capsys.readouterr().out - with capsys.disabled(): + proc = run(wait=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + assert proc is not None + assert proc.stdout is not None + captured = proc.stdout.read() + assert captured == get_output(expect_cmd, shell=True, stderr=subprocess.STDOUT)