From d1fe2a51ca7255c467266cedaa6cf2670cd66668 Mon Sep 17 00:00:00 2001 From: Dima Gerasimov Date: Wed, 1 Jul 2026 21:51:02 +0100 Subject: [PATCH] chore: update all CI files to latest template --- .ci/release | 69 +++---- .ci/release-uv | 56 ----- .ci/run | 4 +- .github/workflows/main.yml | 76 +++---- conftest.py | 58 ------ mypy.ini | 18 -- pyproject.toml | 294 ++++++++++++++++++++++++++- pytest.ini | 20 -- ruff.toml | 102 ---------- src/orgparse/__init__.py | 8 +- src/orgparse/date.py | 27 ++- src/orgparse/extra.py | 7 +- src/orgparse/node.py | 31 ++- src/orgparse/tests/data/00_simple.py | 2 +- src/orgparse/tests/test_data.py | 2 +- tox.ini | 67 ------ 16 files changed, 387 insertions(+), 454 deletions(-) delete mode 100755 .ci/release-uv delete mode 100644 conftest.py delete mode 100644 mypy.ini delete mode 100644 pytest.ini delete mode 100644 ruff.toml delete mode 100644 tox.ini diff --git a/.ci/release b/.ci/release index 6cff663..fbb74c0 100755 --- a/.ci/release +++ b/.ci/release @@ -1,65 +1,48 @@ #!/usr/bin/env python3 ''' -Run [[file:scripts/release][scripts/release]] to deploy Python package onto [[https://pypi.org][PyPi]] and [[https://test.pypi.org][test PyPi]]. +Deploys Python package onto [[https://pypi.org][PyPi]] or [[https://test.pypi.org][test PyPi]]. -The script expects =TWINE_PASSWORD= environment variable to contain the [[https://pypi.org/help/#apitoken][PyPi token]] (not the password!). +- running manually -The script can be run manually. -It's also running as =pypi= job in [[file:.github/workflows/main.yml][Github Actions config]]. Packages are deployed on: -- every master commit, onto test pypi -- every new tag, onto production pypi + You'll need =UV_PUBLISH_TOKEN= env variable -You'll need to set =TWINE_PASSWORD= and =TWINE_PASSWORD_TEST= in [[https://help.github.com/en/actions/configuring-and-managing-workflows/creating-and-storing-encrypted-secrets#creating-encrypted-secrets][secrets]] -for Github Actions deployment to work. +- running on Github Actions + + Instead of env variable, relies on configuring github as Trusted publisher (https://docs.pypi.org/trusted-publishers/) -- both for test and regular pypi + + It's running as =pypi= job in [[file:.github/workflows/main.yml][Github Actions config]]. + Packages are deployed on: + - every master commit, onto test pypi + - every new tag, onto production pypi ''' +UV_PUBLISH_TOKEN = 'UV_PUBLISH_TOKEN' + +import argparse import os -import sys from pathlib import Path from subprocess import check_call -import shutil is_ci = os.environ.get('CI') is not None + def main() -> None: - import argparse p = argparse.ArgumentParser() - p.add_argument('--test', action='store_true', help='use test pypi') + p.add_argument('--use-test-pypi', action='store_true') args = p.parse_args() - extra = [] - if args.test: - extra.extend(['--repository', 'testpypi']) + publish_url = ['--publish-url', 'https://test.pypi.org/legacy/'] if args.use_test_pypi else [] root = Path(__file__).absolute().parent.parent - os.chdir(root) # just in case - - if is_ci: - # see https://github.com/actions/checkout/issues/217 - check_call('git fetch --prune --unshallow'.split()) - - dist = root / 'dist' - if dist.exists(): - shutil.rmtree(dist) - - check_call(['python3', '-m', 'build']) - - TP = 'TWINE_PASSWORD' - password = os.environ.get(TP) - if password is None: - print(f"WARNING: no {TP} passed", file=sys.stderr) - import pip_secrets - password = pip_secrets.token_test if args.test else pip_secrets.token # meh - - check_call([ - 'python3', '-m', 'twine', - 'upload', *dist.iterdir(), - *extra, - ], env={ - 'TWINE_USERNAME': '__token__', - TP: password, - **os.environ, - }) + os.chdir(root) # just in case + + check_call(['uv', 'build', '--clear']) + + if not is_ci: + # CI relies on trusted publishers so doesn't need env variable + assert UV_PUBLISH_TOKEN in os.environ, f'no {UV_PUBLISH_TOKEN} passed' + + check_call(['uv', 'publish', *publish_url]) if __name__ == '__main__': diff --git a/.ci/release-uv b/.ci/release-uv deleted file mode 100755 index 4da39b7..0000000 --- a/.ci/release-uv +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -''' -Deploys Python package onto [[https://pypi.org][PyPi]] or [[https://test.pypi.org][test PyPi]]. - -- running manually - - You'll need =UV_PUBLISH_TOKEN= env variable - -- running on Github Actions - - Instead of env variable, relies on configuring github as Trusted publisher (https://docs.pypi.org/trusted-publishers/) -- both for test and regular pypi - - It's running as =pypi= job in [[file:.github/workflows/main.yml][Github Actions config]]. - Packages are deployed on: - - every master commit, onto test pypi - - every new tag, onto production pypi -''' - -UV_PUBLISH_TOKEN = 'UV_PUBLISH_TOKEN' - -import argparse -import os -import shutil -from pathlib import Path -from subprocess import check_call - -is_ci = os.environ.get('CI') is not None - - -def main() -> None: - p = argparse.ArgumentParser() - p.add_argument('--use-test-pypi', action='store_true') - args = p.parse_args() - - publish_url = ['--publish-url', 'https://test.pypi.org/legacy/'] if args.use_test_pypi else [] - - root = Path(__file__).absolute().parent.parent - os.chdir(root) # just in case - - # TODO ok, for now uv won't remove dist dir if it already exists - # https://github.com/astral-sh/uv/issues/10293 - dist = root / 'dist' - if dist.exists(): - shutil.rmtree(dist) - - check_call(['uv', 'build']) - - if not is_ci: - # CI relies on trusted publishers so doesn't need env variable - assert UV_PUBLISH_TOKEN in os.environ, f'no {UV_PUBLISH_TOKEN} passed' - - check_call(['uv', 'publish', *publish_url]) - - -if __name__ == '__main__': - main() diff --git a/.ci/run b/.ci/run index c881818..d7659f5 100755 --- a/.ci/run +++ b/.ci/run @@ -16,7 +16,7 @@ tox_cmd='run-parallel --parallel-live' if [ -n "${CI-}" ]; then # install OS specific stuff here case "$OSTYPE" in - darwin*) + darwin*) # macos : ;; @@ -34,3 +34,5 @@ fi # NOTE: expects uv installed uv tool run --with tox-uv tox $tox_cmd "$@" +# NOTE: experimenting for now... might switch later for good +# uv tool run nox "$@" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e51bcf6..ab9f9be 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -27,9 +27,13 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] - # vvv just an example of excluding stuff from matrix - # exclude: [{platform: macos-latest, python-version: '3.6'}] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] + exclude: [ + # windows runners are pretty scarce, so let's only run lowest and highest python version + {platform: windows-latest, python-version: '3.11'}, + {platform: windows-latest, python-version: '3.12'}, + {platform: windows-latest, python-version: '3.13'}, + ] runs-on: ${{ matrix.platform }} @@ -37,44 +41,29 @@ jobs: # continue-on-error: ${{ matrix.platform == 'windows-latest' }} steps: - # ugh https://github.com/actions/toolkit/blob/main/docs/commands.md#path-manipulation - - run: echo "$HOME/.local/bin" >> $GITHUB_PATH - - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: submodules: recursive fetch-depth: 0 # nicer to have all git history when debugging/for tests - - uses: actions/setup-python@v6 + - uses: astral-sh/setup-uv@v8.1.0 with: python-version: ${{ matrix.python-version }} - - - uses: astral-sh/setup-uv@v7 - with: - enable-cache: false # we don't have lock files, so can't use them as cache key + enable-cache: false # we don't have lock files during initial CI checkout, so can't use them as cache key - uses: mxschmitt/action-tmate@v3 + with: + limit-access-to-actor: true # restrict to the user who kicked off pipeline if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }} # explicit bash command is necessary for Windows CI runner, otherwise it thinks it's cmd... - run: bash .ci/run - env: - # only compute lxml coverage on ubuntu; it crashes on windows - CI_MYPY_COVERAGE: ${{ matrix.platform == 'ubuntu-latest' && '--cobertura-xml-report .coverage.mypy' || '' }} - - - if: matrix.platform == 'ubuntu-latest' # no need to compute coverage for other platforms - uses: codecov/codecov-action@v5 - with: - fail_ci_if_error: true # default false - token: ${{ secrets.CODECOV_TOKEN }} - flags: mypy-${{ matrix.python-version }} - files: .coverage.mypy/cobertura.xml pypi: # Do not run it for PRs/cron schedule etc. # NOTE: release tags are guarded by on: push: tags on the top. - if: github.event_name == 'push' && (startsWith(github.event.ref, 'refs/tags/') || (github.event.ref == format('refs/heads/{0}', github.event.repository.master_branch))) + if: github.event_name == 'push' && (github.ref_type == 'tag' || github.ref_name == github.event.repository.master_branch) # Ugh, I tried using matrix or something to explicitly generate only test pypi or prod pypi pipelines. # But github actions is so shit, it's impossible to do any logic at all, e.g. doesn't support conditional matrix, if/else statements for variables etc. @@ -85,30 +74,31 @@ jobs: permissions: # necessary for Trusted Publishing id-token: write - + env: + # always deploy merged master to test pypi + # always deploy tags to release pypi + TARGET: ${{ github.ref_type == 'tag' && 'pypi' || 'testpypi' }} + environment: + # for "deployments" tab on github + # sadly can't reuse env.TARGET here... + name: ${{ github.ref_type == 'tag' && 'pypi' || 'testpypi' }} + url: https://${{ github.ref_type == 'tag' && 'pypi.org' || 'test.pypi.org' }}/project/${{ steps.meta.outputs.pypi_name }}/ steps: - # ugh https://github.com/actions/toolkit/blob/main/docs/commands.md#path-manipulation - - run: echo "$HOME/.local/bin" >> $GITHUB_PATH - - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: submodules: recursive fetch-depth: 0 # pull all commits to correctly infer vcs version - - uses: actions/setup-python@v6 - with: - python-version: '3.10' - - - uses: astral-sh/setup-uv@v7 + - uses: astral-sh/setup-uv@v8.1.0 with: - enable-cache: false # we don't have lock files, so can't use them as cache key + python-version: '3.12' + enable-cache: false # we don't have lock files during initial CI checkout, so can't use them as cache key - - name: 'release to test pypi' - # always deploy merged master to test pypi - if: github.event.ref == format('refs/heads/{0}', github.event.repository.master_branch) - run: .ci/release-uv --use-test-pypi + - name: 'release ${{ steps.meta.outputs.pypi_name }} to ${{ env.TARGET }}' + run: .ci/release ${{ env.TARGET == 'testpypi' && '--use-test-pypi' || '' }} - - name: 'release to prod pypi' - # always deploy tags to release pypi - if: startsWith(github.event.ref, 'refs/tags/') - run: .ci/release-uv + - id: meta + shell: bash + run: | + pypi_name=$(unzip -p dist/*.whl '*.dist-info/METADATA' | awk '/^Name:/ {print $2; exit}') + echo "pypi_name=$pypi_name" >> "$GITHUB_OUTPUT" diff --git a/conftest.py b/conftest.py deleted file mode 100644 index 627def8..0000000 --- a/conftest.py +++ /dev/null @@ -1,58 +0,0 @@ -# this is a hack to monkey patch pytest so it handles tests inside namespace packages without __init__.py properly -# without it, pytest can't discover the package root for some reason -# also see https://github.com/karlicoss/pytest_namespace_pkgs for more - -import os -import pathlib -from typing import Optional - -import _pytest.main -import _pytest.pathlib - -# we consider all dirs in repo/ to be namespace packages -root_dir = pathlib.Path(__file__).absolute().parent.resolve() / 'src' -assert root_dir.exists(), root_dir - -# TODO assert it contains package name?? maybe get it via setuptools.. - -namespace_pkg_dirs = [str(d) for d in root_dir.iterdir() if d.is_dir()] - -# resolve_package_path is called from _pytest.pathlib.import_path -# takes a full abs path to the test file and needs to return the path to the 'root' package on the filesystem -resolve_pkg_path_orig = _pytest.pathlib.resolve_package_path - - -def resolve_package_path(path: pathlib.Path) -> Optional[pathlib.Path]: - result = path # search from the test file upwards - for parent in result.parents: - if str(parent) in namespace_pkg_dirs: - return parent - if os.name == 'nt': - # ??? for some reason on windows it is trying to call this against conftest? but not on linux/osx - if path.name == 'conftest.py': - return resolve_pkg_path_orig(path) - raise RuntimeError("Couldn't determine path for ", path) - - -# NOTE: seems like it's not necessary anymore? -# keeping it for now just in case -# after https://github.com/pytest-dev/pytest/pull/13426 we should be able to remove the whole conftest -# _pytest.pathlib.resolve_package_path = resolve_package_path - - -# without patching, the orig function returns just a package name for some reason -# (I think it's used as a sort of fallback) -# so we need to point it at the absolute path properly -# not sure what are the consequences.. maybe it wouldn't be able to run against installed packages? not sure.. -search_pypath_orig = _pytest.main.search_pypath - - -def search_pypath(module_name: str) -> str: - mpath = root_dir / module_name.replace('.', os.sep) - if not mpath.is_dir(): - mpath = mpath.with_suffix('.py') - assert mpath.exists(), mpath # just in case - return str(mpath) - - -_pytest.main.search_pypath = search_pypath # ty: ignore[invalid-assignment] diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 7b1e535..0000000 --- a/mypy.ini +++ /dev/null @@ -1,18 +0,0 @@ -[mypy] -pretty = True -show_error_context = True -show_column_numbers = True -show_error_end = True - -check_untyped_defs = True - -# see https://mypy.readthedocs.io/en/stable/error_code_list2.html -warn_redundant_casts = True -strict_equality = True -warn_unused_ignores = True -enable_error_code = deprecated,redundant-expr,possibly-undefined,truthy-bool,truthy-iterable,ignore-without-code,unused-awaitable - - -# an example of suppressing -# [mypy-my.config.repos.pdfannots.pdfannots] -# ignore_errors = True diff --git a/pyproject.toml b/pyproject.toml index 0aa9ba0..cb25a54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,12 @@ dynamic = ["version"] # version is managed by build backend name = "orgparse" dependencies = [ ] -requires-python = ">=3.9" -description = "orgparse - Emacs org-mode parser in Python" -license = {file = "LICENSE"} +requires-python = ">=3.10" + +## these are only useful for pypi +description = "orgparse - Emacs org-mode parser in Python" # shows on project page/in search +readme = "README.rst" +license = {file = "LICENSE"} authors = [ {name = "Takafumi Arakaki (@tkf)", email = "aka.tkf@gmail.com"}, {name = "Dmitrii Gerasimov (@karlicoss)", email = "karlicoss@gmail.com"}, @@ -23,17 +26,29 @@ classifiers = [ # TODO add it back later, perhaps via ast? # long_description=orgparse.__doc__, -[project.urls] +[project.urls] # pypi will fetch some github stats/link, so somewhat useful Homepage = "https://github.com/karlicoss/orgparse" +## + [project.optional-dependencies] +optional = [ +] + [dependency-groups] +# TODO: not sure, on the one hand could just use 'standard' dev dependency group +# On the other hand, it's a bit annoying that it's always included by default? +# To make sure it's not included, need to use `uv run --exact --no-default-groups ...` testing = [ "pytest", "ruff", +] +typecheck = [ "mypy", "lxml", # for mypy html coverage - "ty>=0.0.1a25", + "ty", + + { include-group = "testing" }, ] @@ -51,3 +66,272 @@ source = "vcs" [tool.hatch.version.raw-options] version_scheme = "python-simplified-semver" local_scheme = "dirty-tag" + + +[tool.mypy] +pretty = true +show_error_context = true +show_error_end = true + +# see https://mypy.readthedocs.io/en/stable/error_code_list2.html +## NOTE: strict implies all of these.. will gradually move onto it +strict = false # TODO enable later +check_untyped_defs = true +warn_redundant_casts = true +strict_equality = true +warn_unused_ignores = true +## +enable_error_code = [ + "deprecated", + "redundant-expr", + "possibly-undefined", + "truthy-bool", + "truthy-iterable", + "ignore-without-code", + "unused-awaitable", +] + +# an example of suppressing +# [mypy-my.config.repos.pdfannots.pdfannots] +# ignore_errors = True +# +# TOML equivalent: +# [[tool.mypy.overrides]] +# module = "my.config.repos.pdfannots.pdfannots" +# ignore_errors = true + + +[tool.ty.analysis] +# suppressing rules/changing error level goes here + +[tool.ty.src] +# exclude = [...] + + +[tool.ruff] +line-length = 120 # impacts import sorting + +lint.extend-select = [ + "ALL", +] + +lint.ignore = [ + "CPY", # copyright headers + "D", # annoying nags about docstrings + "N", # pep naming + "TCH", # type checking rules, mostly just suggests moving imports under TYPE_CHECKING + "S", # bandit (security checks) -- tends to be not very useful, lots of nitpicks + "DTZ", # datetimes checks -- complaining about missing tz and mostly false positives + "FIX", # complains about fixmes/todos -- annoying + "TD", # complains about todo formatting -- too annoying + "ANN", # missing type annotations? seems way to strict though + "EM" , # suggests assigning all exception messages into a variable first... pretty annoying + +### too opinionated style checks + "E203", # whitespace before , -- sometimes used for readability, and ruff format should cover it anyway + "E221", # multiple spaces before operator -- also covered by ruff format + "E266", # too many leading '#' for block comment -- sometimes genuinely useful for visual separation + "E501", # too long lines + "E731", # assigning lambda instead of using def + "E741", # Ambiguous variable name: `l` + "E742", # Ambiguous class name: `O + "E401", # Multiple imports on one line + "F403", # import *` used; unable to detect undefined names +### + +### + "E722", # Do not use bare `except` ## Sometimes it's useful for defensive imports and that sort of thing.. + "F811", # Redefinition of unused # this gets in the way of pytest fixtures (e.g. in cachew) + +## might be nice .. but later and I don't wanna make it strict + "E402", # Module level import not at top of file + +### these are just nitpicky, we usually know better + "PLR0911", # too many return statements + "PLR0912", # too many branches + "PLR0913", # too many function arguments + "PLR0915", # too many statements + "PLR1714", # consider merging multiple comparisons + "PLR2044", # line with empty comment + "PLR5501", # use elif instead of else if + "PLR2004", # magic value in comparison -- super annoying in tests +### + "PLR0402", # import X.Y as Y -- TODO maybe consider enabling it, but double check + + "B009", # calling gettattr with constant attribute -- this is useful to convince mypy + "B010", # same as above, but setattr + "B017", # pytest.raises(Exception) + "B023", # seems to result in false positives? + + # complains about useless pass, but has sort of a false positive if the function has a docstring? + # this is common for click entrypoints (e.g. in __main__), so disable + "PIE790", + + # a bit too annoying, offers to convert for loops to list comprehension + # , which may heart readability + "PERF401", + + # suggests no using exception in for loops + # we do use this technique a lot, plus in 3.11 happy path exception handling is "zero-cost" + "PERF203", + + "RET504", # unnecessary assignment before returning -- that can be useful for readability + "RET505", # unnecessary else after return -- can hurt readability + + "PLC1901", # suggests using `if string` instead of `strinig == ""` -- dumb. + + "PLW0603", # global variable update.. we usually know why we are doing this + "PLW2901", # for loop variable overwritten, usually this is intentional + + "PT011", # pytest raises is too broad + + "COM812", # trailing comma missing -- mostly just being annoying with long multiline strings + + "TRY003", # suggests defining exception messages in exception class -- kinda annoying + "TRY201", # raise without specifying exception name -- sometimes hurts readability + "TRY400", # a bit dumb, and results in false positives (see https://github.com/astral-sh/ruff/issues/18070) + "TRY401", # redundant exception in logging.exception call? TODO double check, might result in excessive logging + + "TID252", # Prefer absolute imports over relative imports from parent modules + + ## too annoying + "T20", # just complains about prints and pprints (TODO maybe consider later?) + "Q", # flake quotes, too annoying + "C90", # some complexity checking + "G004", # logging statement uses f string + "ERA001", # commented out code + "SLF001", # private member accessed + "BLE001", # do not catch 'blind' Exception + "INP001", # complains about implicit namespace packages + "SIM102", # if statements collapsing, often hurts readability + "SIM103", # multiple conditions collapsing, often hurts readability + "SIM105", # suggests using contextlib.suppress instad of try/except -- this wouldn't be mypy friendly + "SIM108", # suggests using ternary operation instead of if -- hurts readability + "SIM110", # suggests using any(...) instead of for look/return -- hurts readability + "SIM117", # suggests using single with statement instead of nested -- doesn't work in tests + "RSE102", # complains about missing parens in exceptions + ## + + "PLC0415", # "imports should be at the top level" -- not realistic + + "RUF067", # `__init__` module should only contain docstrings and re-exports -- might be nice to aim for, but dunno +] + +[tool.ruff.format] +quote-style = "preserve" + + +[tool.pytest.ini_options] +# discover files that don't follow test_ naming. Useful to keep tests along with the source code +python_files = ["*.py"] + +# this is necessary for --pyargs to discover implicit namespace packages correctly +consider_namespace_packages = true + +# see https://docs.pytest.org/en/stable/reference/reference.html#confval-strict +strict = true + +addopts = [ + # prevent pytest cache from being created... it craps into project dir and I never use it anyway + "-p", "no:cacheprovider", + + # -rap to print tests summary even when they are successful + "-rap", + "--verbose", + + # otherwise it won't discover doctests + "--doctest-modules", + + # show all test durations (unless they are too short) + "--durations=0", +] + + +[tool.tox] +min_version = "4" + +# Relies on the correct version of Python installed. +# We rely on CI for the test matrix. +env_list = ["ruff", "format", "tests", "mypy", "ty"] + +# https://github.com/tox-dev/tox/issues/20#issuecomment-247788333 +# Hack to prevent .tox from crapping to the project directory. +work_dir = "{env:TOXWORKDIR_BASE:}{tox_root}{/}.tox" + +[tool.tox.env_run_base] +pass_env = [ + # Useful for tests to know they are running under CI. + "CI", + "CI_*", + + # Respect user's cache dirs to prevent tox from crapping into project dir. + "PYTHONPYCACHEPREFIX", + "MYPY_CACHE_DIR", + "RUFF_CACHE_DIR", +] + +# Do not add current working directory to PYTHONPATH. +# Generally this is more robust and safer, and prevents weird issues later on. +set_env.PYTHONSAFEPATH = "1" +runner = "uv-venv-lock-runner" +uv_sync_locked = false + +[tool.tox.env.ruff] +skip_install = true +dependency_groups = ["testing"] +commands = [ + [ + "{env_python}", "-m", "ruff", + "check", + { replace = "posargs", default = [], extend = true }, + ], +] + + +[tool.tox.env.format] +skip_install = true +dependency_groups = ["testing"] +commands = [ + [ + "{env_python}", "-m", "ruff", + "format", + "--check", + { replace = "posargs", default = [], extend = true }, + ], +] + + +[tool.tox.env.tests] +dependency_groups = ["testing"] +commands = [ + [ + "{env_python}", "-m", "pytest", + "--pyargs", "{[project]name}", + { replace = "posargs", default = [], extend = true }, + ], +] + + +[tool.tox.env.mypy] +dependency_groups = ["typecheck"] +commands = [ + [ + "{env_python}", "-m", "mypy", + "-p", "{[project]name}", + # uncomment if you need coverage + # "--txt-report" , ".coverage.mypy", + # "--html-report", ".coverage.mypy", + { replace = "posargs", default = [], extend = true }, + ], +] + + +[tool.tox.env.ty] +dependency_groups = ["typecheck"] +commands = [ + [ + "{env_python}", "-m", "ty", + "check", + { replace = "posargs", default = [], extend = true }, + ], +] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 226488b..0000000 --- a/pytest.ini +++ /dev/null @@ -1,20 +0,0 @@ -[pytest] -# discover files that don't follow test_ naming. Useful to keep tests along with the source code -python_files = *.py - -# this setting only impacts package/module naming under pytest, not the discovery -consider_namespace_packages = true - -addopts = - # prevent pytest cache from being created... it craps into project dir and I never use it anyway - -p no:cacheprovider - - # -rap to print tests summary even when they are successful - -rap - --verbose - - # otherwise it won't discover doctests - --doctest-modules - - # show all test durations (unless they are too short) - --durations=0 diff --git a/ruff.toml b/ruff.toml deleted file mode 100644 index e05c3b4..0000000 --- a/ruff.toml +++ /dev/null @@ -1,102 +0,0 @@ -line-length = 120 # impacts import sorting - -lint.extend-select = [ - "ALL", -] - -# Preserve types, even if a file imports `from __future__ import annotations` -# we need this for cachew to work with HPI types on 3.9 -# can probably remove after 3.10? -lint.pyupgrade.keep-runtime-typing = true - -lint.ignore = [ - "D", # annoying nags about docstrings - "N", # pep naming - "TCH", # type checking rules, mostly just suggests moving imports under TYPE_CHECKING - "S", # bandit (security checks) -- tends to be not very useful, lots of nitpicks - "DTZ", # datetimes checks -- complaining about missing tz and mostly false positives - "FIX", # complains about fixmes/todos -- annoying - "TD", # complains about todo formatting -- too annoying - "ANN", # missing type annotations? seems way to strict though - "EM" , # suggests assigning all exception messages into a variable first... pretty annoying - -### too opinionated style checks - "E501", # too long lines - "E731", # assigning lambda instead of using def - "E741", # Ambiguous variable name: `l` - "E742", # Ambiguous class name: `O - "E401", # Multiple imports on one line - "F403", # import *` used; unable to detect undefined names -### - -### - "E722", # Do not use bare `except` ## Sometimes it's useful for defensive imports and that sort of thing.. - "F811", # Redefinition of unused # this gets in the way of pytest fixtures (e.g. in cachew) - -## might be nice .. but later and I don't wanna make it strict - "E402", # Module level import not at top of file - -### these are just nitpicky, we usually know better - "PLR0911", # too many return statements - "PLR0912", # too many branches - "PLR0913", # too many function arguments - "PLR0915", # too many statements - "PLR1714", # consider merging multiple comparisons - "PLR2044", # line with empty comment - "PLR5501", # use elif instead of else if - "PLR2004", # magic value in comparison -- super annoying in tests -### - "PLR0402", # import X.Y as Y -- TODO maybe consider enabling it, but double check - - "B009", # calling gettattr with constant attribute -- this is useful to convince mypy - "B010", # same as above, but setattr - "B017", # pytest.raises(Exception) - "B023", # seems to result in false positives? - - # complains about useless pass, but has sort of a false positive if the function has a docstring? - # this is common for click entrypoints (e.g. in __main__), so disable - "PIE790", - - # a bit too annoying, offers to convert for loops to list comprehension - # , which may heart readability - "PERF401", - - # suggests no using exception in for loops - # we do use this technique a lot, plus in 3.11 happy path exception handling is "zero-cost" - "PERF203", - - "RET504", # unnecessary assignment before returning -- that can be useful for readability - "RET505", # unnecessary else after return -- can hurt readability - - "PLW0603", # global variable update.. we usually know why we are doing this - "PLW2901", # for loop variable overwritten, usually this is intentional - - "PT011", # pytest raises is too broad - - "COM812", # trailing comma missing -- mostly just being annoying with long multiline strings - - "TRY003", # suggests defining exception messages in exception class -- kinda annoying - "TRY201", # raise without specifying exception name -- sometimes hurts readability - "TRY400", # a bit dumb, and results in false positives (see https://github.com/astral-sh/ruff/issues/18070) - "TRY401", # redundant exception in logging.exception call? TODO double check, might result in excessive logging - - "TID252", # Prefer absolute imports over relative imports from parent modules - - ## too annoying - "T20", # just complains about prints and pprints (TODO maybe consider later?) - "Q", # flake quotes, too annoying - "C90", # some complexity checking - "G004", # logging statement uses f string - "ERA001", # commented out code - "SLF001", # private member accessed - "BLE001", # do not catch 'blind' Exception - "INP001", # complains about implicit namespace packages - "SIM102", # if statements collapsing, often hurts readability - "SIM103", # multiple conditions collapsing, often hurts readability - "SIM105", # suggests using contextlib.suppress instad of try/except -- this wouldn't be mypy friendly - "SIM108", # suggests using ternary operation instead of if -- hurts readability - "SIM110", # suggests using any(...) instead of for look/return -- hurts readability - "SIM117", # suggests using single with statement instead of nested -- doesn't work in tests - "RSE102", # complains about missing parens in exceptions - ## -] diff --git a/src/orgparse/__init__.py b/src/orgparse/__init__.py index 110c474..c0e3991 100644 --- a/src/orgparse/__init__.py +++ b/src/orgparse/__init__.py @@ -108,14 +108,14 @@ from collections.abc import Iterable from pathlib import Path -from typing import Optional, TextIO, Union +from typing import TextIO from .node import OrgEnv, OrgNode, parse_lines # todo basenode?? __all__ = ["load", "loadi", "loads"] -def load(path: Union[str, Path, TextIO], env: Optional[OrgEnv] = None) -> OrgNode: +def load(path: str | Path | TextIO, env: OrgEnv | None = None) -> OrgNode: """ Load org-mode document from a file. @@ -145,7 +145,7 @@ def load(path: Union[str, Path, TextIO], env: Optional[OrgEnv] = None) -> OrgNod return loadi(all_lines, filename=filename, env=env) -def loads(string: str, filename: str = '', env: Optional[OrgEnv] = None) -> OrgNode: +def loads(string: str, filename: str = '', env: OrgEnv | None = None) -> OrgNode: """ Load org-mode document from a string. @@ -155,7 +155,7 @@ def loads(string: str, filename: str = '', env: Optional[OrgEnv] = None) return loadi(string.splitlines(), filename=filename, env=env) -def loadi(lines: Iterable[str], filename: str = '', env: Optional[OrgEnv] = None) -> OrgNode: +def loadi(lines: Iterable[str], filename: str = '', env: OrgEnv | None = None) -> OrgNode: """ Load org-mode document from an iterative object. diff --git a/src/orgparse/date.py b/src/orgparse/date.py index 1685f32..671cc63 100644 --- a/src/orgparse/date.py +++ b/src/orgparse/date.py @@ -3,9 +3,8 @@ import datetime import re from datetime import timedelta -from typing import Optional, Union -DateIsh = Union[datetime.date, datetime.datetime] +DateIsh = datetime.date | datetime.datetime def total_seconds(td: timedelta) -> float: @@ -422,11 +421,11 @@ def _as_datetime(date) -> datetime.datetime: return date @staticmethod - def _daterange_from_groupdict(dct, prefix='') -> tuple[list, Optional[list]]: + def _daterange_from_groupdict(dct, prefix='') -> tuple[list, list | None]: start_keys = ['year', 'month', 'day', 'hour' , 'min'] # fmt: skip end_keys = ['year', 'month', 'day', 'end_hour', 'end_min'] # fmt: skip start_range = list(map(int, filter(None, (dct[prefix + k] for k in start_keys)))) - end_range: Optional[list] + end_range: list | None end_range = list(map(int, filter(None, (dct[prefix + k] for k in end_keys)))) if len(end_range) < len(end_keys): end_range = None @@ -465,8 +464,8 @@ def list_from_str(cls, string: str) -> list[OrgDate]: prefix = 'inactive_' active = False rangedash = '--[' - repeater: Optional[tuple[str, int, str]] = None - warning: Optional[tuple[str, int, str]] = None + repeater: tuple[str, int, str] | None = None + warning: tuple[str, int, str] | None = None if mdict[prefix + 'repeatpre'] is not None: keys = [prefix + 'repeat' + suffix for suffix in cookie_suffix] values = [mdict[k] for k in keys] @@ -549,8 +548,8 @@ def from_str(cls, string): end_dict.update({'hour': end_hour, 'min': end_min}) end = cls._datetuple_from_groupdict(end_dict) cookie_suffix = ['pre', 'num', 'dwmy'] - repeater: Optional[tuple[str, int, str]] = None - warning: Optional[tuple[str, int, str]] = None + repeater: tuple[str, int, str] | None = None + warning: tuple[str, int, str] | None = None prefix = '' if mdict[prefix + 'repeatpre'] is not None: keys = [prefix + 'repeat' + suffix for suffix in cookie_suffix] @@ -644,7 +643,7 @@ def is_duration_consistent(self): return self._duration is None or self._duration == total_minutes(self.duration) @classmethod - def from_str(cls, line: str) -> OrgDateClock: + def from_str(cls, string: str) -> OrgDateClock: """ Get CLOCK from given string. @@ -652,7 +651,7 @@ def from_str(cls, line: str) -> OrgDateClock: of start time, datetime object of stop time and length in minute. """ - match = cls._re.search(line) + match = cls._re.search(string) if not match: return cls(None, None) @@ -660,20 +659,20 @@ def from_str(cls, line: str) -> OrgDateClock: # second part starting with "--", does not exist for open clock dates has_end = bool(match.group(6)) - ymdhm2_dt: Optional[datetime.datetime] - len_min: Optional[int] + ymdhm2_dt: datetime.datetime | None + len_min: int | None if has_end: ymdhm2 = [int(d) for d in match.groups()[6:11]] hm3 = [int(d) for d in match.groups()[11:]] - ymdhm2_dt = datetime.datetime(*ymdhm2) # type: ignore[arg-type] + ymdhm2_dt = datetime.datetime(*ymdhm2) # type: ignore[arg-type] # ty: ignore[invalid-argument-type] len_min = hm3[0] * 60 + hm3[1] else: ymdhm2_dt = None len_min = None return cls( - datetime.datetime(*ymdhm1), # type: ignore[arg-type] + datetime.datetime(*ymdhm1), # type: ignore[arg-type] # ty: ignore[invalid-argument-type] ymdhm2_dt, len_min, ) diff --git a/src/orgparse/extra.py b/src/orgparse/extra.py index e89343e..0c9c1ce 100644 --- a/src/orgparse/extra.py +++ b/src/orgparse/extra.py @@ -2,7 +2,6 @@ import re from collections.abc import Iterator, Sequence -from typing import Optional, Union RE_TABLE_SEPARATOR = re.compile(r'\s*\|(\-+\+)*\-+\|') RE_TABLE_ROW = re.compile(r'\s*\|([^|]+)+\|') @@ -40,7 +39,7 @@ def rows(self) -> Iterator[Row]: if r is not None: yield r - def _pre_rows(self) -> Iterator[Optional[Row]]: + def _pre_rows(self) -> Iterator[Row | None]: for l in self._lines: if RE_TABLE_SEPARATOR.match(l): yield None @@ -74,7 +73,7 @@ def __init__(self, columns: Sequence[str], rows: Sequence[Row]) -> None: def __iter__(self) -> Iterator[dict[str, str]]: for x in self._rows: - yield dict(zip(self.columns, x)) + yield dict(zip(self.columns, x, strict=True)) class Gap: @@ -82,7 +81,7 @@ class Gap: pass -Rich = Union[Table, Gap] +Rich = Table | Gap def to_rich_text(text: str) -> Iterator[Rich]: diff --git a/src/orgparse/node.py b/src/orgparse/node.py index 5794b43..750cc6b 100644 --- a/src/orgparse/node.py +++ b/src/orgparse/node.py @@ -3,12 +3,7 @@ import itertools import re from collections.abc import Iterable, Iterator, Sequence -from typing import ( - Any, - Optional, - Union, - cast, -) +from typing import Any, cast from .date import ( OrgDate, @@ -94,7 +89,7 @@ def parse_heading_tags(heading: str) -> tuple[str, list[str]]: RE_HEADING_TAGS = re.compile(r'(.*?)\s*:([\w@:]+):\s*$') -def parse_heading_todos(heading: str, todo_candidates: list[str]) -> tuple[str, Optional[str]]: +def parse_heading_todos(heading: str, todo_candidates: list[str]) -> tuple[str, str | None]: """ Get TODO keyword and heading without TODO keyword. @@ -136,10 +131,10 @@ def parse_heading_priority(heading): RE_HEADING_PRIORITY = re.compile(r'^\s*\[#([A-Z0-9])\] ?(.*)$') -PropertyValue = Union[str, int, float] +PropertyValue = str | int | float -def parse_property(line: str) -> tuple[Optional[str], Optional[PropertyValue]]: +def parse_property(line: str) -> tuple[str | None, PropertyValue | None]: """ Get property from given string. @@ -150,7 +145,7 @@ def parse_property(line: str) -> tuple[Optional[str], Optional[PropertyValue]]: """ prop_key = None - prop_val: Optional[Union[str, int, float]] = None + prop_val: str | int | float | None = None match = RE_PROP.search(line) if match: prop_key = match.group(1) @@ -163,7 +158,7 @@ def parse_property(line: str) -> tuple[Optional[str], Optional[PropertyValue]]: RE_PROP = re.compile(r'^\s*:(.*?):\s*(.*?)\s*$') -def parse_duration_to_minutes(duration: str) -> Union[float, int]: +def parse_duration_to_minutes(duration: str) -> float | int: """ Parse duration minutes from given string. Convert to integer if number has no decimal points @@ -191,7 +186,9 @@ def parse_duration_to_minutes(duration: str) -> Union[float, int]: """ minutes = parse_duration_to_minutes_float(duration) - return int(minutes) if minutes.is_integer() else minutes + # ugh ty is a bit insane and thinks that float is always int | float? + # see https://docs.astral.sh/ty/reference/typing-faq/#why-does-ty-show-int-float-when-i-annotate-something-as-float + return int(minutes) if minutes.is_integer() else minutes # ty: ignore[unresolved-attribute] def parse_duration_to_minutes_float(duration: str) -> float: @@ -222,7 +219,7 @@ def parse_duration_to_minutes_float(duration: str) -> float: 0.0 """ - match: Optional[Any] + match: Any | None if duration == "": return 0.0 if isinstance(duration, float): @@ -794,7 +791,7 @@ def properties(self) -> dict[str, PropertyValue]: """ return self._properties - def get_property(self, key, val=None) -> Optional[PropertyValue]: + def get_property(self, key, val=None) -> PropertyValue | None: """ Return property named ``key`` if exists or ``val`` otherwise. @@ -1134,7 +1131,7 @@ def __init__(self, *args, **kwds) -> None: self._heading = cast(str, None) self._level: int | None = None self._tags = cast(list[str], None) - self._todo: Optional[str] = None + self._todo: str | None = None self._priority = None self._scheduled = OrgDateScheduled(None) self._deadline = OrgDateDeadline(None) @@ -1310,7 +1307,7 @@ def _get_tags(self, *, inher: bool = False) -> set[str]: return tags @property - def todo(self) -> Optional[str]: + def todo(self) -> str | None: """ A TODO keyword of this node if exists or None otherwise. @@ -1458,7 +1455,7 @@ def parse_lines(lines: Iterable[str], filename, env=None) -> OrgNode: linenos = itertools.accumulate(itertools.chain([0], (len(c) for c in ch1))) nodes = env.from_chunks(ch2) nodelist = [] - for lineno, node in zip(linenos, nodes): + for lineno, node in zip(linenos, nodes, strict=False): lineno += 1 # in text editors lines are 1-indexed node.linenumber = lineno nodelist.append(node) diff --git a/src/orgparse/tests/data/00_simple.py b/src/orgparse/tests/data/00_simple.py index 23ad86c..91efd44 100644 --- a/src/orgparse/tests/data/00_simple.py +++ b/src/orgparse/tests/data/00_simple.py @@ -16,7 +16,7 @@ def nodedict(i, level, todo=None, shallow_tags=None, tags=None) -> dict[str, Any def tags(nums) -> set[str]: - return set(map('TAG{0}'.format, nums)) + return set(map('TAG{0}'.format, nums)) # ty: ignore[invalid-return-type] data = [ diff --git a/src/orgparse/tests/test_data.py b/src/orgparse/tests/test_data.py index c271273..91b7661 100644 --- a/src/orgparse/tests/test_data.py +++ b/src/orgparse/tests/test_data.py @@ -57,7 +57,7 @@ def test_data(dataname): data = load_data(data_path(dataname, "py")) root = load(oname) - for i, (node, kwds) in enumerate(zip(root[1:], data)): + for i, (node, kwds) in enumerate(zip(root[1:], data, strict=True)): for key in kwds: val = value_from_data_key(node, key) assert kwds[key] == val, ( diff --git a/tox.ini b/tox.ini deleted file mode 100644 index a31cbab..0000000 --- a/tox.ini +++ /dev/null @@ -1,67 +0,0 @@ -[tox] -minversion = 3.21 -# relies on the correct version of Python installed -envlist = ruff,tests,mypy,ty -# https://github.com/tox-dev/tox/issues/20#issuecomment-247788333 -# hack to prevent .tox from crapping to the project directory -toxworkdir = {env:TOXWORKDIR_BASE:}{toxinidir}/.tox - -[testenv] -# TODO how to get package name from setuptools? -package_name = "orgparse" -pass_env = -# useful for tests to know they are running under ci - CI - CI_* -# respect user's cache dirs to prevent tox from crapping into project dir - PYTHONPYCACHEPREFIX - MYPY_CACHE_DIR - RUFF_CACHE_DIR - -set_env = -# do not add current working directory to pythonpath -# generally this is more robust and safer, prevents weird issues later on - PYTHONSAFEPATH=1 - -# default is 'editable', in which tox builds wheel first for some reason? not sure if makes much sense -package = uv-editable - - -[testenv:ruff] -skip_install = true -dependency_groups = testing -commands = - {envpython} -m ruff check \ - {posargs} - - -[testenv:tests] -dependency_groups = testing -commands = - # posargs allow test filtering, e.g. tox ... -- -k test_name - {envpython} -m pytest \ - --pyargs {[testenv]package_name} \ - {posargs} - - -[testenv:mypy] -dependency_groups = testing -commands = - {envpython} -m mypy --no-install-types \ - -p {[testenv]package_name} \ - --txt-report .coverage.mypy \ - --html-report .coverage.mypy \ - # this is for github actions to upload to codecov.io - # sadly xml coverage crashes on windows... so we need to disable it - {env:CI_MYPY_COVERAGE} \ - {posargs} - - -[testenv:ty] -dependency_groups = testing -extras = optional -deps = # any other dependencies (if needed) -commands = - {envpython} -m ty \ - check \ - {posargs}