diff --git a/.bumpversion.toml b/.bumpversion.toml new file mode 100644 index 0000000000..9568c31fd1 --- /dev/null +++ b/.bumpversion.toml @@ -0,0 +1,13 @@ +[tool.bumpversion] + allow_dirty = true + current_version = "0.175.0" + + [[tool.bumpversion.files]] + filename = "pyproject.toml" + replace = "version = \"{new_version}\"" + search = "version = \"{current_version}\"" + + [[tool.bumpversion.files]] + filename = "src/utilities/__init__.py" + replace = "__version__ = \"{new_version}\"" + search = "__version__ = \"{current_version}\"" diff --git a/.coveragerc.toml b/.coveragerc.toml new file mode 100644 index 0000000000..14509433ef --- /dev/null +++ b/.coveragerc.toml @@ -0,0 +1,38 @@ +[coverage_conditional_plugin] + [coverage_conditional_plugin.rules] + skipif-ci = '"CI" in os_environ' + skipif-ci-and-mac = '("CI" in os_environ) and (sys_platform == "darwin")' + skipif-ci-and-not-linux = '("CI" in os_environ) and (sys_platform != "linux")' + skipif-ci-or-mac = '("CI" in os_environ) or (sys_platform == "darwin")' + skipif-linux = 'sys_platform == "linux"' + skipif-mac = 'sys_platform == "darwin"' + skipif-not-linux = 'sys_platform != "linux"' + skipif-not-macos = 'sys_platform != "darwin"' + skipif-not-windows = 'sys_platform != "windows"' + skipif-windows = 'sys_platform == "darwin"' + +[html] + directory = ".coverage/html" + +[report] + exclude_also = [ + "@overload", + "assert_never", + "case never:", + "if TYPE_CHECKING:", + ] + fail_under = 100.0 + skip_covered = true + skip_empty = true + +[run] + branch = true + data_file = ".coverage/data" + omit = [ + "src/utilities/__init__.py", + "src/utilities/pytest_plugins/*.py", + "src/utilities/streamlit.py", + ] + parallel = true + plugins = ["coverage_conditional_plugin"] + source = ["src/utilities"] diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml new file mode 100644 index 0000000000..6390630598 --- /dev/null +++ b/.github/workflows/pull-request.yaml @@ -0,0 +1,65 @@ +name: pull-request +on: + pull_request: + branches: + - master + schedule: + - cron: 0 0 * * * +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - name: Run 'pre-commit' + uses: dycw/action-pre-commit@latest + with: + token: ${{secrets.GITHUB_TOKEN}} + repos: |- + dycw/pre-commit-hook-nitpick + pre-commit/pre-commit-hooks + pyright: + runs-on: ubuntu-latest + steps: + - name: Run 'pyright' + uses: dycw/action-pyright@latest + with: + token: ${{secrets.GITHUB_TOKEN}} + python-version: "3.12" + pytest: + env: + CI: "1" + name: pytest (${{matrix.os}}, ${{matrix.python-version}}, + ${{matrix.resolution}}) + runs-on: ${{matrix.os}} + services: + redis: + image: ${{ matrix.os == 'ubuntu-latest' && 'redis/redis-stack:latest' || + '' }} + ports: + - 6379:6379 + steps: + - name: Run 'pytest' + uses: dycw/action-pytest@latest + with: + token: ${{secrets.GITHUB_TOKEN}} + python-version: ${{matrix.python-version}} + resolution: ${{matrix.resolution}} + strategy: + fail-fast: false + matrix: + os: + - macos-latest + - ubuntu-latest + python-version: + - "3.12" + - "3.13" + - "3.14" + resolution: + - highest + timeout-minutes: 10 + ruff: + runs-on: ubuntu-latest + steps: + - name: Run 'ruff' + uses: dycw/action-ruff@latest + with: + token: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml deleted file mode 100644 index 1d89e858ff..0000000000 --- a/.github/workflows/pull-request.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: pull-request - -on: - pull_request: - branches: - - master - -jobs: - check-version-bumped: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 - with: - cache: pip - - run: curl -fsSL https://raw.githubusercontent.com/dycw/remote-scripts/refs/heads/master/scripts/check-version-bumped | sh -s - - ruff: - name: ruff - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: astral-sh/ruff-action@v3 - - run: ruff check --fix - - run: ruff format - - pyright: - name: pyright - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true - version: latest - - uses: actions/setup-python@v5 - with: - python-version-file: .python-version - - run: uv sync - - run: uv run pyright - - test: - name: test / ${{ matrix.os }} / ${{ matrix.version }} - env: - CI: 1 - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [macos-latest, ubuntu-latest] - version: ["3.12", "3.13", "3.14"] - services: - redis: - image: ${{ matrix.os == 'ubuntu-latest' && 'redis/redis-stack:latest' || '' }} - ports: - - 6379:6379 - steps: - - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true - version: latest - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.version }} - - run: uv sync --locked - - run: uv run pytest --cov-report=term-missing:skip-covered -n=auto - timeout-minutes: 60 diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml new file mode 100644 index 0000000000..43eb7b29d4 --- /dev/null +++ b/.github/workflows/push.yaml @@ -0,0 +1,13 @@ +name: push +on: + push: + branches: + - master +jobs: + tag: + runs-on: ubuntu-latest + steps: + - name: Tag latest commit + uses: dycw/action-tag@latest + with: + token: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml deleted file mode 100644 index ef791efdd1..0000000000 --- a/.github/workflows/push.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: push - -on: - push: - branches: - - master - -jobs: - tag: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: butlerlogic/action-autotag@1.1.2 # https://github.com/ButlerLogic/action-autotag/issues/45#issuecomment-1825726927 - env: - GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" - with: - strategy: regex - root: pyproject.toml - regex_pattern: 'current_version = "(?\d+\.\d+\.\d+)"' - - publish: - runs-on: ubuntu-latest - needs: - - tag - environment: - name: release - permissions: - id-token: write - steps: - - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true - - uses: actions/setup-python@v5 - with: - python-version-file: pyproject.toml - - run: uv build - - run: uv publish diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 995753d778..706cac0760 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,45 +1,36 @@ repos: - # fixers - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.10 + - repo: https://github.com/dycw/pre-commit-hook-nitpick + rev: 0.7.9 hooks: - - id: ruff-check - args: [--fix] - - id: ruff-format - - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.9.18 - hooks: - - id: uv-lock - args: [--upgrade] - - repo: https://github.com/compwa/taplo-pre-commit - rev: v0.9.3 - hooks: - - id: taplo-format - args: - [ - --option, - indent_tables=true, - --option, - indent_entries=true, - --option, - reorder_keys=true, - ] - - repo: https://github.com/dycw/pre-commit-hooks - rev: 0.13.26 - hooks: - - id: format-requirements - - id: replace-sequence-str - - id: run-bump-my-version - - repo: https://github.com/scop/pre-commit-shfmt - rev: v3.12.0-2 - hooks: - - id: shfmt - # linters - - repo: https://github.com/shellcheck-py/shellcheck-py - rev: v0.11.0.1 - hooks: - - id: shellcheck - # fixers/linters + - args: + # - --coverage + - --description=Miscellaneous Python utilities + - --github--pull-request--pre-commit + - --github--pull-request--pyright + - --github--pull-request--pytest--os--macos + - --github--pull-request--pytest--os--ubuntu + - --github--pull-request--pytest--python-version--3-12 + - --github--pull-request--pytest--python-version--3-13 + - --github--pull-request--pytest--python-version--3-14 + - --github--pull-request--pytest--resolution--highest + - --github--pull-request--ruff + - --github--push--tag + - --package-name=dycw-utilities + - --pre-commit--prettier + - --pre-commit--ruff + - --pre-commit--taplo + - --pre-commit--uv + - --pyproject + - --pyright + - --pytest + - --pytest--asyncio + - --pytest--timeout=600 + - --python-package-name=utilities + - --python-version=3.12 + - --readme + - --repo-name=python-utilities + - --ruff + id: nitpick - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: @@ -50,8 +41,43 @@ repos: - id: detect-private-key - id: end-of-file-fixer - id: mixed-line-ending - args: [--fix=lf] + args: + - --fix=lf - id: no-commit-to-branch - id: pretty-format-json - args: [--autofix] + args: + - --autofix - id: trailing-whitespace + - repo: local + hooks: + - id: prettier + name: prettier + entry: npx prettier --write + language: system + types_or: + - markdown + - yaml + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.10 + hooks: + - id: ruff-check + args: + - --fix + - id: ruff-format + - repo: https://github.com/compwa/taplo-pre-commit + rev: v0.9.3 + hooks: + - id: taplo-format + args: + - --option + - indent_tables=true + - --option + - indent_entries=true + - --option + - reorder_keys=true + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.9.21 + hooks: + - id: uv-lock + args: + - --upgrade diff --git a/README.md b/README.md index 6981f24278..fae17c09bc 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,3 @@ -[![PyPI version](https://badge.fury.io/py/dycw-utilities.svg)](https://badge.fury.io/py/dycw-utilities) +# `python-utilities` -# `dycw-utilities` - -[All the Python functions I don't want to write twice.](https://github.com/nvim-lua/plenary.nvim) - -## Installation - -- `pip install dycw-utilities` - -or with [extras](https://github.com/dycw/python-utilities/blob/master/pyproject.toml). +Miscellaneous Python utilities diff --git a/pyproject.toml b/pyproject.toml index 146684e160..11753129c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,9 @@ -# build-system [build-system] build-backend = "uv_build" requires = ["uv_build"] -# dependency groups [dependency-groups] - aeventkit = ["aeventkit >=2.1.0, <2.2"] - altair = ["altair >=5.5.0, <5.6"] + altair = ["altair >=6.0.0, <6.1"] altair-test = ["polars", "img2pdf", "vl-convert-python"] atools = ["atools >=0.14.2, <0.15"] cachetools = ["cachetools >=6.2.4, <6.3"] @@ -27,6 +24,8 @@ "pyright[nodejs] >=1.1.407, <1.2", "pytest-cov >=7.0.0, <7.1", "pytest-timeout >=2.4.0, <2.5", + "dycw-utilities[test]", + "rich", ] fastapi = ["fastapi >=0.128.0, <0.129"] fastapi-test = ["httpx", "uvicorn"] @@ -89,7 +88,6 @@ tzdata = ["tzdata >=2025.3, <2025.4"] whenever-test = ["pathvalidate"] -# project [project] authors = [{ email = "d.wan@icloud.com", name = "Derek Wan" }] dependencies = [ @@ -98,10 +96,11 @@ "tzlocal >=5.3.1, <5.4", "whenever >=0.9.4, <0.10", ] + description = "Miscellaneous Python utilities" name = "dycw-utilities" readme = "README.md" requires-python = ">= 3.12" - version = "0.174.20" + version = "0.175.0" [project.entry-points.pytest11] pytest-randomly = "utilities.pytest_plugins.pytest_randomly" @@ -127,203 +126,7 @@ "testbook >=0.4.2, <0.5", ] - [project.scripts] - -# tool [tool] - - # bump-my-version - [tool.bumpversion] - allow_dirty = true - current_version = "0.174.20" - - [[tool.bumpversion.files]] - filename = "src/utilities/__init__.py" - replace = "__version__ = \"{new_version}\"" - search = "__version__ = \"{current_version}\"" - - # coverage - [tool.coverage] - [tool.coverage.coverage_conditional_plugin] - [tool.coverage.coverage_conditional_plugin.rules] - skipif-ci = '"CI" in os_environ' - skipif-ci-and-mac = '("CI" in os_environ) and (sys_platform == "darwin")' - skipif-ci-and-not-linux = '("CI" in os_environ) and (sys_platform != "linux")' - skipif-ci-or-mac = '("CI" in os_environ) or (sys_platform == "darwin")' - skipif-linux = 'sys_platform == "linux"' - skipif-mac = 'sys_platform == "darwin"' - skipif-not-linux = 'sys_platform != "linux"' - skipif-not-macos = 'sys_platform != "darwin"' - skipif-not-windows = 'sys_platform != "windows"' - skipif-windows = 'sys_platform == "darwin"' - - [tool.coverage.html] - directory = ".coverage/html" - - [tool.coverage.report] - exclude_also = [ - "@overload", - "assert_never", - "case never:", - "if TYPE_CHECKING:", - ] - fail_under = 100.0 - skip_covered = true - skip_empty = true - - [tool.coverage.run] - branch = true - data_file = ".coverage/data" - omit = [ - "src/utilities/__init__.py", - "src/utilities/pytest_plugins/*.py", - "src/utilities/streamlit.py", - ] - parallel = true - plugins = ["coverage_conditional_plugin"] - source = ["src/utilities"] - - # pyright - [tool.pyright] - deprecateTypingAliases = true - enableReachabilityAnalysis = false - ignore = ["**/_typeshed/**"] - pythonVersion = "3.12" - reportCallInDefaultInitializer = true - reportImplicitOverride = true - reportImplicitStringConcatenation = true - reportImportCycles = true - reportMissingSuperCall = true - reportMissingTypeArgument = false - reportMissingTypeStubs = false - reportPrivateUsage = false - reportPropertyTypeMismatch = true - reportUninitializedInstanceVariable = true - reportUnknownArgumentType = false - reportUnknownMemberType = false - reportUnknownParameterType = false - reportUnknownVariableType = false - reportUnnecessaryComparison = false - reportUnnecessaryTypeIgnoreComment = true - reportUnusedCallResult = true - reportUnusedImport = false - reportUnusedVariable = false - typeCheckingMode = "strict" - - # pytest - [tool.pytest] - addopts = [ - "-ra", - "-vv", - "--color=auto", - "--durations=10", - "--durations-min=10", - "--timeout=600", - ] - asyncio_default_fixture_loop_scope = "function" - asyncio_mode = "auto" - collect_imported_tests = false - empty_parameter_set_mark = "fail_at_collect" - filterwarnings = [ - "error", - "ignore: was delete before being closed:ResourceWarning", # sqlalchemy - "ignore:Exception ignored in.* :pytest.PytestUnraisableExceptionWarning", - "ignore:Exception in thread Thread-.*:pytest.PytestUnhandledThreadExceptionWarning", - "ignore:Jupyter is migrating its paths to use standard platformdirs:DeprecationWarning", # jupyter - "ignore:ResourceTracker called reentrantly for resource cleanup, which is unsupported:UserWarning", - "ignore:The garbage collector is trying to clean up non-checked-in connection :RuntimeWarning", # sqlalchemy - "ignore:Using fork.* can cause Polars to deadlock in the child process:RuntimeWarning", # polars/pqdm - "ignore:coroutine 'AsyncConnection.close' was never awaited:RuntimeWarning", - "ignore:loop is closed:ResourceWarning", # redis - "ignore:unclosed :ResourceWarning", # redis - "ignore:unclosed :ResourceWarning", # redis - "ignore:unclosed Connection :ResourceWarning", # redis - "ignore:unclosed connection :ResourceWarning", # asyncpg - "ignore:unclosed database in :ResourceWarning", # sqlalchemy - "ignore:unclosed event loop <_UnixSelectorEventLoop .*>:ResourceWarning", # redis - "ignore:unclosed file <_io.*TextIOWrapper .*>:ResourceWarning", # logging - "ignore:unclosed transport <_SelectorSocketTransport .*>:ResourceWarning", # redis - "ignore:Do not expect file_or_dir in Namespace:UserWarning", # pytest - ] - minversion = "9.0" - strict = true - testpaths = ["src/tests"] - timeout = "600" - xfail_strict = true - - # ruff - [tool.ruff] - src = ["src"] - target-version = "py312" - unsafe-fixes = true - - [tool.ruff.format] - preview = true - skip-magic-trailing-comma = true - - [tool.ruff.lint] - explicit-preview-rules = true - fixable = ["ALL"] - ignore = [ - "ANN401", # any-type - "A005", # stdlib-module-shadowing - "ASYNC109", # async-function-with-timeout - "C901", # complex-structure - "CPY", # flake8-copyright - "D", # pydocstyle - "DOC", # pydoclint - "E501", # line-too-long - "PD", # pandas-vet - "PERF203", # try-except-in-loop - "PLC0415", # import-outside-top-level - "PLR0911", # too-many-return-statements - "PLR0912", # too-many-branches - "PLR0913", # too-many-arguments - "PLR0915", # too-many-statements - "PLR2004", # magic-value-comparison - "PT012", # pytest-raises-with-multiple-statements - "PT013", # pytest-incorrect-pytest-import - "S202", # tarfile-unsafe-members - "S310", # suspicious-url-open-usage - "S311", # suspicious-non-cryptographic-random-usage - "S602", # subprocess-popen-with-shell-equals-true - "S603", # subprocess-without-shell-equals-true - "S607", # start-process-with-partial-path - # preview - "S101", # assert - # formatter - "W191", # tab-indentation - "E111", # indentation-with-invalid-multiple - "E114", # indentation-with-invalid-multiple-comment - "E117", # over-indented - "COM812", # missing-trailing-comma - "COM819", # prohibited-trailing-comma - "ISC001", # single-line-implicit-string-concatenation - "ISC002", # multi-line-implicit-string-concatenation - ] - preview = true - select = [ - "ALL", - "RUF022", # unsorted-dunder-all - ] - - [tool.ruff.lint.extend-per-file-ignores] - "src/tests/**/*.py" = [ - "S101", # assert - "SLF001", # private-member-access - ] - "src/tests/test_typing_funcs/no_future.py" = [ - "I002", - ] # missing-required-import - - [tool.ruff.lint.flake8-tidy-imports] - ban-relative-imports = "all" - - [tool.ruff.lint.isort] - required-imports = ["from __future__ import annotations"] - split-on-trailing-comma = false - - # uv [tool.uv] default-groups = "all" diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000000..8e3afb1cef --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,29 @@ +{ + "deprecateTypingAliases": true, + "enableReachabilityAnalysis": false, + "include": [ + "src" + ], + "pythonVersion": "3.12", + "reportCallInDefaultInitializer": true, + "reportImplicitOverride": true, + "reportImplicitStringConcatenation": true, + "reportImportCycles": true, + "reportMissingSuperCall": true, + "reportMissingTypeArgument": false, + "reportMissingTypeStubs": false, + "reportPrivateImportUsage": false, + "reportPrivateUsage": false, + "reportPropertyTypeMismatch": true, + "reportUninitializedInstanceVariable": true, + "reportUnknownArgumentType": false, + "reportUnknownMemberType": false, + "reportUnknownParameterType": false, + "reportUnknownVariableType": false, + "reportUnnecessaryComparison": false, + "reportUnnecessaryTypeIgnoreComment": true, + "reportUnusedCallResult": true, + "reportUnusedImport": false, + "reportUnusedVariable": false, + "typeCheckingMode": "strict" +} diff --git a/pytest.toml b/pytest.toml new file mode 100644 index 0000000000..c28abdc82f --- /dev/null +++ b/pytest.toml @@ -0,0 +1,43 @@ +[pytest] + addopts = [ + "-ra", + "-vv", + "--color=auto", + "--durations=10", + "--durations-min=10", + # "--cov=utilities", + # "--cov-config=.coveragerc.toml", + # "--cov-report=html", + ] + asyncio_default_fixture_loop_scope = "function" + asyncio_mode = "auto" + collect_imported_tests = false + empty_parameter_set_mark = "fail_at_collect" + filterwarnings = [ + "error", + "ignore: was delete before being closed:ResourceWarning", # sqlalchemy + "ignore:Automatically deduplicated selection parameter with identical configuration:UserWarning", # altair + "ignore:Do not expect file_or_dir in Namespace:UserWarning", # pytest + "ignore:Exception ignored in.* :pytest.PytestUnraisableExceptionWarning", + "ignore:Exception in thread Thread-.*:pytest.PytestUnhandledThreadExceptionWarning", + "ignore:Implicitly cleaning up <_TemporaryFileWrapper .*>:ResourceWarning", # tempfile + "ignore:Jupyter is migrating its paths to use standard platformdirs:DeprecationWarning", # jupyter + "ignore:ResourceTracker called reentrantly for resource cleanup, which is unsupported:UserWarning", + "ignore:The garbage collector is trying to clean up non-checked-in connection :RuntimeWarning", # sqlalchemy + "ignore:Using fork.* can cause Polars to deadlock in the child process:RuntimeWarning", # polars/pqdm + "ignore:coroutine 'AsyncConnection.close' was never awaited:RuntimeWarning", + "ignore:loop is closed:ResourceWarning", # redis + "ignore:unclosed :ResourceWarning", # redis + "ignore:unclosed :ResourceWarning", # redis + "ignore:unclosed Connection :ResourceWarning", # redis + "ignore:unclosed connection :ResourceWarning", # asyncpg + "ignore:unclosed database in :ResourceWarning", # sqlalchemy + "ignore:unclosed event loop <_UnixSelectorEventLoop .*>:ResourceWarning", # redis + "ignore:unclosed file <_io.*TextIOWrapper .*>:ResourceWarning", # logging + "ignore:unclosed transport <_SelectorSocketTransport .*>:ResourceWarning", # redis + ] + minversion = "9.0" + strict = true + testpaths = ["src/tests"] + timeout = "600" + xfail_strict = true diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000000..3cac3d1404 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,61 @@ +target-version = "py312" +unsafe-fixes = true + +[format] + preview = true + skip-magic-trailing-comma = true + +[lint] + explicit-preview-rules = true + fixable = ["ALL"] + ignore = [ + "ANN401", + "ASYNC109", + "C901", + "CPY", + "D", + "E501", + "PD", + "PERF203", + "PLC0415", + "PLE1205", + "PLR0904", + "PLR0911", + "PLR0912", + "PLR0913", + "PLR0915", + "PLR2004", + "PT012", + "PT013", + "PYI041", + "S202", + "S310", + "S311", + "S602", + "S603", + "S607", + "W191", + "E111", + "E114", + "E117", + "COM812", + "COM819", + "ISC001", + "ISC002", + ] + preview = true + select = ["ALL", "RUF022", "RUF029"] + + [lint.extend-per-file-ignores] + "src/tests/test_typing_funcs/no_future.py" = ["I002"] + "test_*.py" = ["S101", "SLF001"] + + [lint.flake8-bugbear] + extend-immutable-calls = ["typing.cast"] + + [lint.flake8-tidy-imports] + ban-relative-imports = "all" + + [lint.isort] + required-imports = ["from __future__ import annotations"] + split-on-trailing-comma = false diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 142a27266b..daeeaf3e03 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -1,5 +1,6 @@ from __future__ import annotations +from asyncio import sleep from contextlib import AbstractContextManager, suppress from logging import LogRecord, setLogRecordFactory from typing import TYPE_CHECKING @@ -118,6 +119,7 @@ async def test_async_engine( test_async_sqlite_engine: AsyncEngine, test_async_postgres_engine: AsyncEngine, ) -> AsyncEngine: + await sleep(0.0) dialect = request.param match dialect: case "sqlite": @@ -133,6 +135,7 @@ async def test_async_engine( async def test_async_sqlite_engine(*, tmp_path: Path) -> AsyncEngine: from utilities.sqlalchemy import create_engine + await sleep(0.0) db_path = tmp_path / "db.sqlite" return create_engine("sqlite+aiosqlite", database=str(db_path), async_=True) diff --git a/src/tests/regressions/test_pytest_regressions/TestOrjsonRegressionFixture__test_error.json b/src/tests/regressions/test_pytest_regressions/TestOrjsonRegressionFixture__test_error.json new file mode 100644 index 0000000000..c508d5366f --- /dev/null +++ b/src/tests/regressions/test_pytest_regressions/TestOrjsonRegressionFixture__test_error.json @@ -0,0 +1 @@ +false diff --git a/src/tests/test_aeventkit.py b/src/tests/test_aeventkit.py deleted file mode 100644 index 580cdb61da..0000000000 --- a/src/tests/test_aeventkit.py +++ /dev/null @@ -1,282 +0,0 @@ -from __future__ import annotations - -from asyncio import sleep -from collections.abc import Callable -from functools import wraps -from io import StringIO -from logging import StreamHandler, getLogger -from re import search -from typing import TYPE_CHECKING, Literal - -from eventkit import Event -from hypothesis import given -from hypothesis.strategies import sampled_from -from pytest import raises - -from utilities.aeventkit import ( - LiftedEvent, - LiftListenerError, - TypedEvent, - add_listener, - lift_listener, -) -from utilities.hypothesis import temp_paths, text_ascii - -if TYPE_CHECKING: - from pathlib import Path - - -class TestAddListener: - @given(sync_or_async=sampled_from(["sync", "async"])) - async def test_main(self, *, sync_or_async: Literal["sync", "async"]) -> None: - event = Event() - called = False - match sync_or_async: - case "sync": - - def listener_sync() -> None: - nonlocal called - called |= True - - _ = add_listener(event, listener_sync) - case "async": - - async def listener_async() -> None: - nonlocal called - called |= True - await sleep(0.01) - - _ = add_listener(event, listener_async) - - event.emit() - await sleep(0.01) - assert called - - @given(root=temp_paths(), sync_or_async=sampled_from(["sync", "async"])) - async def test_no_error_handler_but_run_into_error( - self, *, root: Path, sync_or_async: Literal["sync", "async"] - ) -> None: - logger = getLogger(str(root)) - logger.addHandler(StreamHandler(buffer := StringIO())) - event = Event() - - match sync_or_async: - case "sync": - - def listener_sync() -> None: ... - - _ = add_listener(event, listener_sync, logger=str(root)) - case "async": - - async def listener_async() -> None: - await sleep(0.01) - - _ = add_listener(event, listener_async, logger=str(root)) - - event.emit(None) - await sleep(0.01) - pattern = r"listener_a?sync\(\) takes 0 positional arguments but 1 was given" - contents = buffer.getvalue() - assert search(pattern, contents) - - @given( - name=text_ascii(min_size=1), case=sampled_from(["sync", "async/sync", "async"]) - ) - async def test_with_error_handler( - self, *, name: str, case: Literal["sync", "async/sync", "async"] - ) -> None: - event = Event(_name=name) - assert event.name() == name - called = False - log: set[tuple[str, type[BaseException]]] = set() - - def listener_sync(is_success: bool, /) -> None: # noqa: FBT001 - if is_success: - nonlocal called - called |= True - else: - raise ValueError - - def error_sync(event: Event, exception: BaseException, /) -> None: - nonlocal log - log.add((event.name(), type(exception))) - - async def listener_async(is_success: bool, /) -> None: # noqa: FBT001 - if is_success: - nonlocal called - called |= True - await sleep(0.01) - else: - raise ValueError - - async def error_async(event: Event, exception: BaseException, /) -> None: - nonlocal log - log.add((event.name(), type(exception))) - await sleep(0.01) - - match case: - case "sync": - _ = add_listener(event, listener_sync, error=error_sync) - case "async/sync": - _ = add_listener(event, listener_async, error=error_sync) - case "async": - _ = add_listener(event, listener_async, error=error_async) - event.emit(True) # noqa: FBT003 - await sleep(0.01) - assert called - assert log == set() - event.emit(False) # noqa: FBT003 - await sleep(0.01) - assert log == {(name, ValueError)} - - @given( - case=sampled_from([ - "no/sync", - "no/async", - "have/sync", - "have/async/sync", - "have/async", - ]) - ) - async def test_ignore( - self, - *, - case: Literal[ - "no/sync", "no/async", "have/sync", "have/async/sync", "have/async" - ], - ) -> None: - event = Event() - called = False - log: set[tuple[str, type[BaseException]]] = set() - - def listener_sync(is_success: bool, /) -> None: # noqa: FBT001 - if is_success: - nonlocal called - called |= True - else: - raise ValueError - - def error_sync(event: Event, exception: BaseException, /) -> None: - nonlocal log - log.add((event.name(), type(exception))) - - async def listener_async(is_success: bool, /) -> None: # noqa: FBT001 - if is_success: - nonlocal called - called |= True - await sleep(0.01) - else: - raise ValueError - - async def error_async(event: Event, exception: BaseException, /) -> None: - nonlocal log - log.add((event.name(), type(exception))) - await sleep(0.01) - - match case: - case "no/sync": - _ = add_listener(event, listener_sync, ignore=ValueError) - case "no/async": - _ = add_listener(event, listener_async, ignore=ValueError) - case "have/sync": - _ = add_listener( - event, listener_sync, error=error_sync, ignore=ValueError - ) - case "have/async/sync": - _ = add_listener( - event, listener_async, error=error_sync, ignore=ValueError - ) - case "have/async": - _ = add_listener( - event, listener_async, error=error_async, ignore=ValueError - ) - event.emit(True) # noqa: FBT003 - await sleep(0.01) - assert called - assert log == set() - event.emit(False) # noqa: FBT003 - await sleep(0.01) - assert log == set() - - def test_decorators(self) -> None: - event = Event() - counter = 0 - - def listener() -> None: - nonlocal counter - counter += 1 - - def increment[**P, R](func: Callable[P, R], /) -> Callable[P, R]: - @wraps(func) - def wrapped(*args: P.args, **kwargs: P.kwargs) -> R: - nonlocal counter - counter += 1 - return func(*args, **kwargs) - - return wrapped - - _ = add_listener(event, listener, decorators=increment) - event.emit() - assert counter == 2 - - -class TestLiftListener: - def test_error(self) -> None: - def listener() -> None: - pass - - async def error(event: Event, exception: BaseException, /) -> None: - _ = (event, exception) - await sleep(0.01) - - with raises( - LiftListenerError, - match=r"Synchronous listener .* cannot be paired with an asynchronous error handler .*", - ): - _ = lift_listener(listener, Event(), error=error) - - -class TestLiftedEvent: - def test_main(self) -> None: - event1 = Event() - counter = 0 - - def listener() -> None: - nonlocal counter - counter += 1 - - _ = event1.connect(listener) - event1.emit() - assert counter == 1 - - event2 = Event() - - class Example(LiftedEvent[Callable[[], None]]): ... - - lifted = Example(event=event2) - _ = lifted.connect(listener) - lifted.emit() - assert counter == 2 - - def incorrect(x: int, /) -> None: - assert x >= 0 - - _ = lifted.connect(incorrect) # pyright: ignore[reportArgumentType] - - -class TestTypedEvent: - def test_main(self) -> None: - class Example(TypedEvent[Callable[[int], None]]): ... - - event = Example() - - def correct(x: int, /) -> None: - assert x >= 0 - - _ = event.connect(correct) - - def incorrect(x: int, y: int, /) -> None: - assert x >= 0 - assert y >= 0 - - _ = event.connect(incorrect) # pyright: ignore[reportArgumentType] diff --git a/src/tests/test_asyncio.py b/src/tests/test_asyncio.py index 5b8a271341..e209781ce9 100644 --- a/src/tests/test_asyncio.py +++ b/src/tests/test_asyncio.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from asyncio import Queue, run +from asyncio import Queue, run, sleep from collections.abc import AsyncIterable, ItemsView, Iterable, KeysView, ValuesView from contextlib import asynccontextmanager from re import DOTALL, search @@ -203,6 +203,7 @@ async def test_sync(self, *, n: int) -> None: @given(n=integers(0, 10)) async def test_async(self, *, n: int) -> None: async def range_async(n: int, /) -> AsyncIterator[int]: + await sleep(0.0) for i in range(n): yield i @@ -220,6 +221,7 @@ async def test_create_task_context_coroutine(self) -> None: @asynccontextmanager async def yield_true() -> AsyncIterator[None]: + await sleep(0.0) nonlocal flag try: flag = True @@ -316,7 +318,7 @@ class CustomError(Exception): ... class TestGetCoroutineName: def test_main(self) -> None: async def func() -> None: - return None + await sleep(0.0) result = get_coroutine_name(func) expected = "func" @@ -369,6 +371,7 @@ async def test_error_non_unique(self, *, iterable: set[int]) -> None: def _lift[T](self, iterable: Iterable[T], /) -> AsyncIterable[T]: async def lifted() -> AsyncIterator[Any]: + await sleep(0.0) for i in iterable: yield i diff --git a/src/tests/test_atools.py b/src/tests/test_atools.py index ebdff6f9a2..d7408cdb12 100644 --- a/src/tests/test_atools.py +++ b/src/tests/test_atools.py @@ -1,5 +1,7 @@ from __future__ import annotations +from asyncio import sleep + from utilities.asyncio import sleep_td from utilities.atools import call_memoized, memoize from utilities.whenever import SECOND @@ -12,6 +14,7 @@ async def test_main(self) -> None: counter = 0 async def increment() -> int: + await sleep(0.0) nonlocal counter counter += 1 return counter @@ -24,6 +27,7 @@ async def test_refresh(self) -> None: counter = 0 async def increment() -> int: + await sleep(0.0) nonlocal counter counter += 1 return counter @@ -43,6 +47,7 @@ async def test_main(self) -> None: @memoize async def increment() -> int: + await sleep(0.0) nonlocal counter counter += 1 return counter @@ -56,6 +61,7 @@ async def test_with_arguments(self) -> None: @memoize(duration=_DELTA) async def increment() -> int: + await sleep(0.0) nonlocal counter counter += 1 return counter diff --git a/src/tests/test_contextlib.py b/src/tests/test_contextlib.py index cc30473090..1d6b99c6bb 100644 --- a/src/tests/test_contextlib.py +++ b/src/tests/test_contextlib.py @@ -55,6 +55,7 @@ async def _test_enhanced_async_context_manager_core( @enhanced_async_context_manager async def yield_marker() -> AsyncIterator[None]: + await asyncio.sleep(0.0) try: yield finally: @@ -142,6 +143,7 @@ async def test_async( sigterm=sigterm, ) async def yield_marker() -> AsyncIterator[None]: + await asyncio.sleep(0.0) try: yield finally: @@ -155,6 +157,7 @@ async def yield_marker() -> AsyncIterator[None]: def test_async_signature(self) -> None: @enhanced_async_context_manager async def yield_marker(x: int, y: int, /) -> AsyncIterator[int]: + await asyncio.sleep(0.0) yield x + y sig = set(signature(yield_marker).parameters) diff --git a/src/tests/test_errors.py b/src/tests/test_errors.py index 27e3454e80..20a5acb641 100644 --- a/src/tests/test_errors.py +++ b/src/tests/test_errors.py @@ -1,6 +1,6 @@ from __future__ import annotations -from asyncio import TaskGroup +from asyncio import TaskGroup, sleep from pytest import RaisesGroup, raises @@ -27,6 +27,7 @@ async def test_group(self) -> None: class CustomError(Exception): ... async def coroutine() -> None: + await sleep(0.0) raise CustomError with RaisesGroup(CustomError) as exc_info: @@ -58,11 +59,13 @@ async def test_group(self) -> None: class Custom1Error(Exception): ... async def coroutine1() -> None: + await sleep(0.0) raise Custom1Error class Custom2Error(Exception): ... async def coroutine2() -> None: + await sleep(0.0) msg = "message2" raise Custom2Error(msg) diff --git a/src/tests/test_functions.py b/src/tests/test_functions.py index 1e1c4aaa73..5741fb6f5f 100644 --- a/src/tests/test_functions.py +++ b/src/tests/test_functions.py @@ -24,7 +24,7 @@ permutations, sampled_from, ) -from pytest import mark, param, raises +from pytest import approx, mark, param, raises from utilities.errors import ImpossibleCaseError from utilities.functions import ( @@ -717,7 +717,7 @@ class Example: attr: ClassVar[int] = n attrs = dict(yield_object_attributes(Example)) - assert len(attrs) == 29 + assert len(attrs) == approx(29, rel=0.1) assert attrs["attr"] == n diff --git a/src/tests/test_pytest_regressions.py b/src/tests/test_pytest_regressions.py index e268680666..e35a61cc71 100644 --- a/src/tests/test_pytest_regressions.py +++ b/src/tests/test_pytest_regressions.py @@ -5,6 +5,7 @@ from hypothesis import HealthCheck, given, settings from hypothesis.strategies import sampled_from from polars import int_range +from pytest import raises from tests.test_typing_funcs.with_future import ( DataClassFutureInt, @@ -12,6 +13,7 @@ DataClassFutureNestedOuterFirstInner, DataClassFutureNestedOuterFirstOuter, ) +from utilities.pytest_regressions import OrjsonRegressionError if TYPE_CHECKING: from utilities.pytest_regressions import ( @@ -44,14 +46,6 @@ def test_series(self, *, polars_regression: PolarsRegressionFixture) -> None: class TestOrjsonRegressionFixture: - def test_dataclass_nested( - self, *, orjson_regression: OrjsonRegressionFixture - ) -> None: - obj = DataClassFutureNestedOuterFirstOuter( - inner=DataClassFutureNestedOuterFirstInner(int_=0) - ) - orjson_regression.check(obj) - def test_dataclass_int(self, *, orjson_regression: OrjsonRegressionFixture) -> None: obj = DataClassFutureInt(int_=0) orjson_regression.check(obj) @@ -66,3 +60,19 @@ def test_dataclass_literal( ) -> None: obj = DataClassFutureLiteral(truth=truth) orjson_regression.check(obj, suffix=truth) + + def test_dataclass_nested( + self, *, orjson_regression: OrjsonRegressionFixture + ) -> None: + obj = DataClassFutureNestedOuterFirstOuter( + inner=DataClassFutureNestedOuterFirstInner(int_=0) + ) + orjson_regression.check(obj) + + def test_error(self, *, orjson_regression: OrjsonRegressionFixture) -> None: + orjson_regression.check(False) # noqa: FBT003 + with raises( + OrjsonRegressionError, + match=r"Obtained object \(at '.+'\) and existing object \(at '.+'\) differ; got True and False", + ): + orjson_regression.check(True) # noqa: FBT003 diff --git a/src/utilities/__init__.py b/src/utilities/__init__.py index b992bfe185..30ee604cb2 100644 --- a/src/utilities/__init__.py +++ b/src/utilities/__init__.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "0.174.20" +__version__ = "0.175.0" diff --git a/src/utilities/aeventkit.py b/src/utilities/aeventkit.py deleted file mode 100644 index 75ad0600d0..0000000000 --- a/src/utilities/aeventkit.py +++ /dev/null @@ -1,389 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from functools import wraps -from inspect import iscoroutinefunction -from typing import TYPE_CHECKING, Any, Self, assert_never, cast, override - -from eventkit import ( - Constant, - Count, - DropWhile, - Enumerate, - Event, - Filter, - Fork, - Iterate, - Map, - Pack, - Partial, - PartialRight, - Pluck, - Skip, - Star, - Take, - TakeUntil, - TakeWhile, - Timestamp, -) - -from utilities.functions import apply_decorators -from utilities.iterables import always_iterable -from utilities.logging import to_logger - -if TYPE_CHECKING: - from collections.abc import Callable - - from utilities.types import Coro, LoggerLike, MaybeCoro, MaybeIterable, TypeLike - - -## - - -def add_listener[E: Event, F: Callable]( - event: E, - listener: Callable[..., MaybeCoro[None]], - /, - *, - error: Callable[[Event, BaseException], MaybeCoro[None]] | None = None, - ignore: TypeLike[BaseException] | None = None, - logger: LoggerLike | None = None, - decorators: MaybeIterable[Callable[[F], F]] | None = None, - done: Callable[..., MaybeCoro[None]] | None = None, - keep_ref: bool = False, -) -> E: - """Connect a listener to an event.""" - lifted = lift_listener( - listener, - event, - error=error, - ignore=ignore, - logger=logger, - decorators=decorators, - ) - return cast("E", event.connect(lifted, done=done, keep_ref=keep_ref)) - - -## - - -@dataclass(repr=False, kw_only=True) -class LiftedEvent[F: Callable[..., MaybeCoro[None]]]: - """A lifted version of `Event`.""" - - event: Event - - def name(self) -> str: - return self.event.name() # pragma: no cover - - def done(self) -> bool: - return self.event.done() # pragma: no cover - - def set_done(self) -> None: - self.event.set_done() # pragma: no cover - - def value(self) -> Any: - return self.event.value() # pragma: no cover - - def connect[F2: Callable]( - self, - listener: F, - /, - *, - error: Callable[[Event, BaseException], MaybeCoro[None]] | None = None, - ignore: TypeLike[BaseException] | None = None, - logger: LoggerLike | None = None, - decorators: MaybeIterable[Callable[[F2], F2]] | None = None, - done: Callable[..., MaybeCoro[None]] | None = None, - keep_ref: bool = False, - ) -> Event: - return add_listener( - self.event, - listener, - error=error, - ignore=ignore, - logger=logger, - decorators=decorators, - done=done, - keep_ref=keep_ref, - ) - - def disconnect( - self, listener: Any, /, *, error: Any = None, done: Any = None - ) -> Any: - return self.event.disconnect( # pragma: no cover - listener, error=error, done=done - ) - - def disconnect_obj(self, obj: Any, /) -> None: - self.event.disconnect_obj(obj) # pragma: no cover - - def emit(self, *args: Any) -> None: - self.event.emit(*args) # pragma: no cover - - def emit_threadsafe(self, *args: Any) -> None: - self.event.emit_threadsafe(*args) # pragma: no cover - - def clear(self) -> None: - self.event.clear() # pragma: no cover - - def run(self) -> list[Any]: - return self.event.run() # pragma: no cover - - def pipe(self, *targets: Event) -> Event: - return self.event.pipe(*targets) # pragma: no cover - - def fork(self, *targets: Event) -> Fork: - return self.event.fork(*targets) # pragma: no cover - - def set_source(self, source: Any, /) -> None: - self.event.set_source(source) # pragma: no cover - - def _onFinalize(self, ref: Any) -> None: # noqa: N802 - self.event._onFinalize(ref) # noqa: SLF001 # pragma: no cover - - async def aiter(self, *, skip_to_last: bool = False, tuples: bool = False) -> Any: - async for i in self.event.aiter( # pragma: no cover - skip_to_last=skip_to_last, tuples=tuples - ): - yield i - - __iadd__ = connect - __isub__ = disconnect - __call__ = emit - __or__ = pipe - - @override - def __repr__(self) -> str: - return self.event.__repr__() # pragma: no cover - - def __len__(self) -> int: - return self.event.__len__() # pragma: no cover - - def __bool__(self) -> bool: - return self.event.__bool__() # pragma: no cover - - def __getitem__(self, fork_targets: Any, /) -> Fork: - return self.event.__getitem__(fork_targets) # pragma: no cover - - def __await__(self) -> Any: - return self.event.__await__() # pragma: no cover - - def __aiter__(self) -> Any: - return self.event.aiter() # pragma: no cover - - def __contains__(self, c: Any, /) -> bool: - return self.event.__contains__(c) # pragma: no cover - - @override - def __reduce__(self) -> Any: - return self.event.__reduce__() # pragma: no cover - - def filter(self, *, predicate: Any = bool) -> Filter: - return self.event.filter(predicate=predicate) # pragma: no cover - - def skip(self, *, count: int = 1) -> Skip: - return self.event.skip(count=count) # pragma: no cover - - def take(self, *, count: int = 1) -> Take: - return self.event.take(count=count) # pragma: no cover - - def takewhile(self, *, predicate: Any = bool) -> TakeWhile: - return self.event.takewhile(predicate=predicate) # pragma: no cover - - def dropwhile(self, *, predicate: Any = lambda x: not x) -> DropWhile: # pyright: ignore[reportUnknownLambdaType] - return self.event.dropwhile(predicate=predicate) # pragma: no cover - - def takeuntil(self, notifier: Event, /) -> TakeUntil: - return self.event.takeuntil(notifier) # pragma: no cover - - def constant(self, constant: Any, /) -> Constant: - return self.event.constant(constant) # pragma: no cover - - def iterate(self, it: Any, /) -> Iterate: - return self.event.iterate(it) # pragma: no cover - - def count(self, *, start: int = 0, step: int = 1) -> Count: - return self.event.count(start=start, step=step) # pragma: no cover - - def enumerate(self, *, start: int = 0, step: int = 1) -> Enumerate: - return self.event.enumerate(start=start, step=step) # pragma: no cover - - def timestamp(self) -> Timestamp: - return self.event.timestamp() # pragma: no cover - - def partial(self, *left_args: Any) -> Partial: - return self.event.partial(*left_args) # pragma: no cover - - def partial_right(self, *right_args: Any) -> PartialRight: - return self.event.partial_right(*right_args) # pragma: no cover - - def star(self) -> Star: - return self.event.star() # pragma: no cover - - def pack(self) -> Pack: - return self.event.pack() # pragma: no cover - - def pluck(self, *selections: int | str) -> Pluck: - return self.event.pluck(*selections) # pragma: no cover - - def map( - self, - func: Any, - /, - *, - timeout: float | None = None, - ordered: bool = True, - task_limit: int | None = None, - ) -> Map: - return self.event.map( # pragma: no cover - func, timeout=timeout, ordered=ordered, task_limit=task_limit - ) - - -## - - -class TypedEvent[F: Callable[..., MaybeCoro[None]]](Event): - """A typed version of `Event`.""" - - @override - def connect[F2: Callable]( - self, - listener: F, - error: Callable[[Self, BaseException], MaybeCoro[None]] | None = None, - done: Callable[[Self], MaybeCoro[None]] | None = None, - keep_ref: bool = False, - *, - ignore: TypeLike[BaseException] | None = None, - logger: LoggerLike | None = None, - decorators: MaybeIterable[Callable[[F2], F2]] | None = None, - ) -> Self: - lifted = lift_listener( - listener, - self, - error=cast( - "Callable[[Event, BaseException], MaybeCoro[None]] | None", error - ), - ignore=ignore, - logger=logger, - decorators=decorators, - ) - return cast( - "Self", super().connect(lifted, error=error, done=done, keep_ref=keep_ref) - ) - - -## - - -def lift_listener[F1: Callable[..., MaybeCoro[None]], F2: Callable]( - listener: F1, - event: Event, - /, - *, - error: Callable[[Event, BaseException], MaybeCoro[None]] | None = None, - ignore: TypeLike[BaseException] | None = None, - logger: LoggerLike | None = None, - decorators: MaybeIterable[Callable[[F2], F2]] | None = None, -) -> F1: - match error, bool(iscoroutinefunction(listener)): - case None, False: - listener_typed = cast("Callable[..., None]", listener) - - @wraps(listener) - def listener_no_error_sync(*args: Any, **kwargs: Any) -> None: - try: - listener_typed(*args, **kwargs) - except Exception as exc: # noqa: BLE001 - if (ignore is not None) and isinstance(exc, ignore): - return - to_logger(logger).exception("") - - lifted = listener_no_error_sync - - case None, True: - listener_typed = cast("Callable[..., Coro[None]]", listener) - - @wraps(listener) - async def listener_no_error_async(*args: Any, **kwargs: Any) -> None: - try: - await listener_typed(*args, **kwargs) - except Exception as exc: # noqa: BLE001 - if (ignore is not None) and isinstance(exc, ignore): - return - to_logger(logger).exception("") - - lifted = listener_no_error_async - case _, _: - match bool(iscoroutinefunction(listener)), bool(iscoroutinefunction(error)): - case False, False: - listener_typed = cast("Callable[..., None]", listener) - error_typed = cast("Callable[[Event, Exception], None]", error) - - @wraps(listener) - def listener_have_error_sync(*args: Any, **kwargs: Any) -> None: - try: - listener_typed(*args, **kwargs) - except Exception as exc: # noqa: BLE001 - if (ignore is not None) and isinstance(exc, ignore): - return - error_typed(event, exc) - - lifted = listener_have_error_sync - case False, True: - listener_typed = cast("Callable[..., None]", listener) - error_typed = cast( - "Callable[[Event, Exception], Coro[None]]", error - ) - raise LiftListenerError(listener=listener_typed, error=error_typed) - case True, _: - listener_typed = cast("Callable[..., Coro[None]]", listener) - - @wraps(listener) - async def listener_have_error_async( - *args: Any, **kwargs: Any - ) -> None: - try: - await listener_typed(*args, **kwargs) - except Exception as exc: # noqa: BLE001 - if (ignore is not None) and isinstance(exc, ignore): - return None - if iscoroutinefunction(error): - error_typed = cast( - "Callable[[Event, Exception], Coro[None]]", error - ) - return await error_typed(event, exc) - error_typed = cast( - "Callable[[Event, Exception], None]", error - ) - error_typed(event, exc) - - lifted = listener_have_error_async - case never: - assert_never(never) - case never: - assert_never(never) - - if decorators is not None: - lifted = apply_decorators(lifted, *always_iterable(decorators)) - return cast("F1", lifted) - - -@dataclass(kw_only=True, slots=True) -class LiftListenerError(Exception): - listener: Callable[..., None] - error: Callable[[Event, Exception], Coro[None]] - - @override - def __str__(self) -> str: - return f"Synchronous listener {self.listener} cannot be paired with an asynchronous error handler {self.error}" - - -__all__ = [ - "LiftListenerError", - "LiftedEvent", - "TypedEvent", - "add_listener", - "lift_listener", -] diff --git a/src/utilities/altair.py b/src/utilities/altair.py index 05debce937..5a046c5afe 100644 --- a/src/utilities/altair.py +++ b/src/utilities/altair.py @@ -145,7 +145,9 @@ def plot_dataframes( ] zoom = selection_interval(bind="scales", encodings=["x"]) chart = ( - vconcat(*layers).add_params(zoom).resolve_scale(color="independent", x="shared") + vconcat_charts(*layers) + .add_params(zoom) + .resolve_scale(color="independent", x="shared") ) if title is not None: chart = chart.properties(title=title) diff --git a/src/utilities/pytest_regressions.py b/src/utilities/pytest_regressions.py index 2b41248c87..8e55f05f5b 100644 --- a/src/utilities/pytest_regressions.py +++ b/src/utilities/pytest_regressions.py @@ -1,15 +1,17 @@ from __future__ import annotations from contextlib import suppress +from dataclasses import dataclass from json import loads from pathlib import Path from shutil import copytree -from typing import TYPE_CHECKING, Any, assert_never +from typing import TYPE_CHECKING, Any, assert_never, override from pytest_regressions.file_regression import FileRegressionFixture from utilities.functions import ensure_str from utilities.operator import is_equal +from utilities.reprlib import get_repr if TYPE_CHECKING: from polars import DataFrame, Series @@ -70,10 +72,28 @@ def check( check_fn=self._check_fn, ) - def _check_fn(self, path1: Path, path2: Path, /) -> None: - left = loads(path1.read_text()) - right = loads(path2.read_text()) - assert is_equal(left, right), f"{left=}, {right=}" + def _check_fn(self, path_obtained: Path, path_existing: Path, /) -> None: + obtained = loads(path_obtained.read_text()) + existing = loads(path_existing.read_text()) + if not is_equal(obtained, existing): + raise OrjsonRegressionError( + path_obtained=path_obtained, + path_existing=path_existing, + obtained=obtained, + existing=existing, + ) + + +@dataclass(kw_only=True, slots=True) +class OrjsonRegressionError(Exception): + path_obtained: Path + path_existing: Path + obtained: Any + existing: Any + + @override + def __str__(self) -> str: + return f"Obtained object (at {str(self.path_obtained)!r}) and existing object (at {str(self.path_existing)!r}) differ; got {get_repr(self.obtained)} and {get_repr(self.existing)}" ## diff --git a/uv.lock b/uv.lock index 52979f784e..324b119083 100644 --- a/uv.lock +++ b/uv.lock @@ -7,18 +7,6 @@ resolution-markers = [ "python_full_version < '3.13'", ] -[[package]] -name = "aeventkit" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5c/8c/c08db1a1910f8d04ec6a524de522edd0bac181bdf94dbb01183f7685cd77/aeventkit-2.1.0.tar.gz", hash = "sha256:4e7d81bb0a67227121da50a23e19e5bbf13eded541a9f4857eeb6b7b857b738a", size = 24703, upload-time = "2025-06-22T15:54:03.961Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/8c/2a4b912b1afa201b25bdd0f5bccf96d5a8b5dccb6131316a8dd2d9cabcc1/aeventkit-2.1.0-py3-none-any.whl", hash = "sha256:962d43f79e731ac43527f2d0defeed118e6dbaa85f1487f5667540ebb8f00729", size = 26678, upload-time = "2025-06-22T15:54:02.141Z" }, -] - [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -151,18 +139,18 @@ wheels = [ [[package]] name = "altair" -version = "5.5.0" +version = "6.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, { name = "jsonschema" }, { name = "narwhals" }, { name = "packaging" }, - { name = "typing-extensions", marker = "python_full_version < '3.14'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/16/b1/f2969c7bdb8ad8bbdda031687defdce2c19afba2aa2c8e1d2a17f78376d8/altair-5.5.0.tar.gz", hash = "sha256:d960ebe6178c56de3855a68c47b516be38640b73fb3b5111c2a9ca90546dd73d", size = 705305, upload-time = "2024-11-23T23:39:58.542Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/c0/184a89bd5feba14ff3c41cfaf1dd8a82c05f5ceedbc92145e17042eb08a4/altair-6.0.0.tar.gz", hash = "sha256:614bf5ecbe2337347b590afb111929aa9c16c9527c4887d96c9bc7f6640756b4", size = 763834, upload-time = "2025-11-12T08:59:11.519Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/f3/0b6ced594e51cc95d8c1fc1640d3623770d01e4969d29c0bd09945fafefa/altair-5.5.0-py3-none-any.whl", hash = "sha256:91a310b926508d560fe0148d02a194f38b824122641ef528113d029fcd129f8c", size = 731200, upload-time = "2024-11-23T23:39:56.4Z" }, + { url = "https://files.pythonhosted.org/packages/db/33/ef2f2409450ef6daa61459d5de5c08128e7d3edb773fefd0a324d1310238/altair-6.0.0-py3-none-any.whl", hash = "sha256:09ae95b53d5fe5b16987dccc785a7af8588f2dca50de1e7a156efa8a461515f8", size = 795410, upload-time = "2025-11-12T08:59:09.804Z" }, ] [[package]] @@ -634,7 +622,7 @@ wheels = [ [[package]] name = "dycw-utilities" -version = "0.174.20" +version = "0.175.0" source = { editable = "." } dependencies = [ { name = "atomicwrites" }, @@ -666,9 +654,6 @@ test = [ ] [package.dev-dependencies] -aeventkit = [ - { name = "aeventkit" }, -] altair = [ { name = "altair" }, ] @@ -706,9 +691,11 @@ dev = [ { name = "coloredlogs" }, { name = "coverage-conditional-plugin" }, { name = "dycw-pytest-only" }, + { name = "dycw-utilities", extra = ["test"] }, { name = "pyright", extra = ["nodejs"] }, { name = "pytest-cov" }, { name = "pytest-timeout" }, + { name = "rich" }, ] fastapi = [ { name = "fastapi" }, @@ -922,8 +909,7 @@ requires-dist = [ provides-extras = ["logging", "test"] [package.metadata.requires-dev] -aeventkit = [{ name = "aeventkit", specifier = ">=2.1.0,<2.2" }] -altair = [{ name = "altair", specifier = ">=5.5.0,<5.6" }] +altair = [{ name = "altair", specifier = ">=6.0.0,<6.1" }] altair-test = [ { name = "img2pdf" }, { name = "polars" }, @@ -948,9 +934,11 @@ dev = [ { name = "coloredlogs", specifier = ">=15.0.1,<15.1" }, { name = "coverage-conditional-plugin", specifier = ">=0.9.0,<0.10" }, { name = "dycw-pytest-only", specifier = ">=2.1.1,<2.2" }, + { name = "dycw-utilities", extras = ["test"] }, { name = "pyright", extras = ["nodejs"], specifier = ">=1.1.407,<1.2" }, { name = "pytest-cov", specifier = ">=7.0.0,<7.1" }, { name = "pytest-timeout", specifier = ">=2.4.0,<2.5" }, + { name = "rich" }, ] fastapi = [{ name = "fastapi", specifier = ">=0.128.0,<0.129" }] fastapi-test = [