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/.github/workflows/main.yml b/.github/workflows/main.yml index ed9e43e..24583e0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -43,7 +43,7 @@ jobs: # 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 @@ -93,7 +93,7 @@ jobs: # 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 @@ -109,9 +109,9 @@ jobs: - 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 + run: .ci/release --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 + run: .ci/release diff --git a/conftest.py b/conftest.py deleted file mode 100644 index 895c4df..0000000 --- a/conftest.py +++ /dev/null @@ -1,57 +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 - -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) -> pathlib.Path | None: - 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/pyproject.toml b/pyproject.toml index 51e9f30..c305316 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,12 +34,13 @@ Homepage = "https://github.com/karlicoss/cachew" optional = [ "colorlog", ] + [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", + "pytest>=9", # need version 9 for proper namespace package support "ruff", "pytz", @@ -53,10 +54,9 @@ testing = [ ] typecheck = [ { include-group = "testing" }, - "mypy", - "lxml", # for mypy coverage - "ty>=0.0.1a22", + "lxml", # for mypy html coverage + "ty>=0.0.3", "types-pytz", # optional runtime only dependency diff --git a/pytest.ini b/pytest.ini index 226488b..aefa02e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,9 +2,13 @@ # 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 +# 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 +# disable for now -- some macos tests ('file backend') are flaky +# strict = true + addopts = # prevent pytest cache from being created... it craps into project dir and I never use it anyway -p no:cacheprovider diff --git a/src/cachew/__init__.py b/src/cachew/__init__.py index d4cc6c3..a0f1469 100644 --- a/src/cachew/__init__.py +++ b/src/cachew/__init__.py @@ -278,11 +278,11 @@ def cachew_error(e: Exception, *, logger: logging.Logger) -> None: @doublewrap def cachew_impl[**P]( func=None, # TODO should probably type it after switch to python 3.10/proper paramspec - cache_path: PathProvider[P] | None = use_default_path, # ty: ignore[too-many-positional-arguments] # see https://github.com/astral-sh/ty/issues/157 + cache_path: PathProvider[P] | None = use_default_path, *, force_file: bool = False, cls: type | tuple[Kind, type] | None = None, - depends_on: HashFunction[P] = default_hash, # ty: ignore[too-many-positional-arguments] + depends_on: HashFunction[P] = default_hash, logger: logging.Logger | None = None, chunk_by: int = 100, # NOTE: allowed values for chunk_by depend on the system. @@ -435,7 +435,7 @@ def _func(*args, **kwargs): @functools.wraps(func) def binder(*args, **kwargs): kwargs['_cachew_context'] = ctx - res = cachew_wrapper(*args, **kwargs) # ty: ignore[missing-argument] + res = cachew_wrapper(*args, **kwargs) if use_kind == 'single': lres = list(res) @@ -457,11 +457,11 @@ def cachew[F: Callable](fun: F) -> F: ... # but at least it works for checking that cachew_path and depdns_on have the same args :shrug: @overload def cachew[F, **P]( - cache_path: PathProvider[P] | None = ..., # ty: ignore[too-many-positional-arguments] + cache_path: PathProvider[P] | None = ..., *, force_file: bool = ..., cls: type | tuple[Kind, type] | None = ..., - depends_on: HashFunction[P] = ..., # ty: ignore[too-many-positional-arguments] + depends_on: HashFunction[P] = ..., logger: logging.Logger | None = ..., chunk_by: int = ..., synthetic_key: str | None = ..., @@ -568,10 +568,10 @@ def _module_is_disabled(module_name: str, logger: logging.Logger) -> bool: class Context[**P]: # fmt: off func : Callable - cache_path : PathProvider[P] # ty: ignore[too-many-positional-arguments] + cache_path : PathProvider[P] force_file : bool cls_ : type - depends_on : HashFunction[P] # ty: ignore[too-many-positional-arguments] + depends_on : HashFunction[P] logger : logging.Logger chunk_by : int synthetic_key: str | None @@ -612,7 +612,7 @@ def composite_hash(self, *args, **kwargs) -> dict[str, Any]: def cachew_wrapper[**P]( *args, - _cachew_context: Context[P], # ty: ignore[too-many-positional-arguments] + _cachew_context: Context[P], **kwargs, ): C = _cachew_context diff --git a/src/cachew/legacy.py b/src/cachew/legacy.py index e3b4970..9596268 100644 --- a/src/cachew/legacy.py +++ b/src/cachew/legacy.py @@ -28,7 +28,7 @@ def get_union_args(cls) -> Optional[tuple[type]]: args = cls.__args__ args = tuple(e for e in args if e is not type(None)) assert len(args) > 0 - return args + return args # ty: ignore[invalid-return-type] def is_union(cls) -> bool: @@ -313,7 +313,7 @@ class NTBinder(Generic[NT]): @staticmethod def make(tp: type[NT], name: Optional[str] = None) -> 'NTBinder[NT]': - tp, optional = strip_optional(tp) + tp, optional = strip_optional(tp) # ty: ignore[invalid-assignment] union: Optional[type] fields: tuple[Any, ...] primitive: bool diff --git a/src/cachew/tests/marshall.py b/src/cachew/tests/marshall.py index 5009c6d..6d90f2d 100644 --- a/src/cachew/tests/marshall.py +++ b/src/cachew/tests/marshall.py @@ -110,7 +110,7 @@ def union_hook(data, type_): jsons: list[Json] = [None for _ in range(count)] with profile(test_name + ':serialize'), timer(f'serializing {count} objects of type {Type}'): for i in range(count): - jsons[i] = to_json(objects[i]) + jsons[i] = to_json(objects[i]) # ty: ignore[invalid-assignment] strs: list[bytes] = [None for _ in range(count)] # type: ignore[misc] with profile(test_name + ':json_dump'), timer(f'json dump {count} objects of type {Type}'): @@ -165,7 +165,7 @@ def union_hook(data, type_): objects2 = [None for _ in range(count)] with profile(test_name + ':deserialize'), timer(f'deserializing {count} objects of type {Type}'): for i in range(count): - objects2[i] = from_json(jsons2[i]) + objects2[i] = from_json(jsons2[i]) # ty: ignore[invalid-argument-type] assert objects[:100] + objects[-100:] == objects2[:100] + objects2[-100:] diff --git a/tox.ini b/tox.ini index 11af103..635ee25 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,10 @@ [tox] -minversion = 3.21 +minversion = 4 + # relies on the correct version of Python installed +# (we rely on CI for the test matrix) 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 @@ -23,8 +26,8 @@ set_env = # 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 +runner = uv-venv-lock-runner +uv_sync_locked = false [testenv:ruff]