Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ jobs:
fail-fast: false
matrix:
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
python-version: ['3.11', '3.12', '3.13', '3.14']

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down Expand Up @@ -93,7 +93,7 @@ jobs:
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1

- name: Run tests with lowest resolution
if: matrix.python-version == '3.10' && matrix.os == 'ubuntu-latest'
if: matrix.python-version == '3.11' && matrix.os == 'ubuntu-latest'
run: just test-lowest

- name: Run tests with highest resolution
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ releases are available on [PyPI](https://pypi.org/project/pytask) and

## Unreleased

- [#1022](https://github.com/pytask-dev/pytask/pull/1022) raises the minimum supported
Python version to 3.11.
- [#935](https://github.com/pytask-dev/pytask/pull/935) detects when a cached task
module comes from a different path and raises
`ImportPathMismatchError`, while recognizing cached bytecode modules by their source
Expand Down
6 changes: 3 additions & 3 deletions docs/source/tutorials/set_up_a_project.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ to manage dependencies and project metadata.
[project]
name = "my_project"
version = "0.1.0"
requires-python = ">=3.10"
requires-python = ">=3.11"
dependencies = ["pytask"]

[build-system]
Expand All @@ -109,7 +109,7 @@ to manage dependencies and project metadata.
[project]
name = "my_project"
version = "0.1.0"
requires-python = ">=3.10"
requires-python = ">=3.11"
channels = ["conda-forge"] # (1)!
platforms = ["linux-64", "osx-64", "osx-arm64", "win-64"]

Expand All @@ -119,7 +119,7 @@ to manage dependencies and project metadata.

[dependencies]
pytask = "*"
python = ">=3.10"
python = ">=3.11"

[tool.pytask.ini_options]
paths = ["src/my_project"] # (2)!
Expand Down
2 changes: 1 addition & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ docs-serve *FLAGS:

# Run tests with lowest dependency resolution (like CI)
test-lowest:
uv run --python 3.10 --group test --resolution lowest-direct pytest --nbmake
uv run --python 3.11 --group test --resolution lowest-direct pytest --nbmake

# Run tests with highest dependency resolution (like CI)
test-highest:
Expand Down
6 changes: 2 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
name = "pytask"
description = "pytask is a workflow management system that facilitates reproducible data analyses."
requires-python = ">=3.10"
requires-python = ">=3.11"
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
Expand All @@ -11,7 +11,6 @@ classifiers = [
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
Expand All @@ -30,8 +29,7 @@ dependencies = [
"rich>=13.8.0",
"sqlalchemy>=2.0.31",
"msgspec[toml]>=0.18.6",
'tomli>=1; python_version < "3.11"',
'typing-extensions>=4.8.0; python_version < "3.11"',
"typing-extensions>=4.8.0",
"universal-pathlib>=0.2.2",
]

Expand Down
208 changes: 1 addition & 207 deletions src/_pytask/_hashlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import hashlib
import os
import sys
from contextlib import suppress
from hashlib import file_digest
from pathlib import Path
from typing import Any

Expand All @@ -13,212 +13,6 @@
_WINDOWS_DRIVE_PREFIX_LENGTH = 3


if sys.version_info >= (3, 11): # pragma: no cover
from hashlib import file_digest
else: # pragma: no cover
# This tuple and __get_builtin_constructor() must be modified if a new
# always available algorithm is added.
__always_supported = (
"md5",
"sha1",
"sha224",
"sha256",
"sha384",
"sha512",
"blake2b",
"blake2s",
"sha3_224",
"sha3_256",
"sha3_384",
"sha3_512",
"shake_128",
"shake_256",
)

algorithms_guaranteed = set(__always_supported)
algorithms_available = set(__always_supported)

__all__ = __always_supported + (
"new",
"algorithms_guaranteed",
"algorithms_available",
"file_digest",
)

__builtin_constructor_cache = {}

# Prefer our blake2 implementation
# OpenSSL 1.1.0 comes with a limited implementation of blake2b/s. The OpenSSL
# implementations neither support keyed blake2 (blake2 MAC) nor advanced
# features like salt, personalization, or tree hashing. OpenSSL hash-only
# variants are available as 'blake2b512' and 'blake2s256', though.
__block_openssl_constructor = {
"blake2b",
"blake2s",
}

def __get_builtin_constructor(name):
cache = __builtin_constructor_cache
constructor = cache.get(name)
if constructor is not None:
return constructor

with suppress(ImportError):
if name in {"SHA1", "sha1"}:
import _sha1

cache["SHA1"] = cache["sha1"] = _sha1.sha1
elif name in {"MD5", "md5"}:
import _md5

cache["MD5"] = cache["md5"] = _md5.md5
elif name in {"SHA256", "sha256", "SHA224", "sha224"}:
try:
import _sha2
except ImportError:
import _sha256 as _sha2
cache["SHA224"] = cache["sha224"] = _sha2.sha224
cache["SHA256"] = cache["sha256"] = _sha2.sha256
elif name in {"SHA512", "sha512", "SHA384", "sha384"}:
try:
import _sha2
except ImportError:
import _sha256 as _sha2
cache["SHA384"] = cache["sha384"] = _sha2.sha384
cache["SHA512"] = cache["sha512"] = _sha2.sha512
elif name in {"blake2b", "blake2s"}:
import _blake2

cache["blake2b"] = _blake2.blake2b
cache["blake2s"] = _blake2.blake2s
elif name in {"sha3_224", "sha3_256", "sha3_384", "sha3_512"}:
import _sha3

cache["sha3_224"] = _sha3.sha3_224
cache["sha3_256"] = _sha3.sha3_256
cache["sha3_384"] = _sha3.sha3_384
cache["sha3_512"] = _sha3.sha3_512
elif name in {"shake_128", "shake_256"}:
import _sha3

cache["shake_128"] = _sha3.shake_128
cache["shake_256"] = _sha3.shake_256

constructor = cache.get(name)
if constructor is not None:
return constructor

raise ValueError("unsupported hash type " + name)

def __get_openssl_constructor(name):
if name in __block_openssl_constructor:
# Prefer our builtin blake2 implementation.
return __get_builtin_constructor(name)
try:
# MD5, SHA1, and SHA2 are in all supported OpenSSL versions
# SHA3/shake are available in OpenSSL 1.1.1+
f = getattr(_hashlib, "openssl_" + name)
# Allow the C module to raise ValueError. The function will be
# defined but the hash not actually available. Don't fall back to
# builtin if the current security policy blocks a digest, bpo#40695.
f(usedforsecurity=False)
# Use the C function directly (very fast)
return f
except (AttributeError, ValueError):
return __get_builtin_constructor(name)

def __py_new(name, data=b"", **kwargs):
"""new(name, data=b'', **kwargs) - Return a new hashing object using the
named algorithm; optionally initialized with data (which must be
a bytes-like object).
"""
return __get_builtin_constructor(name)(data, **kwargs)

def __hash_new(name, data=b"", **kwargs):
"""new(name, data=b'') - Return a new hashing object using the named algorithm;
optionally initialized with data (which must be a bytes-like object).
"""
if name in __block_openssl_constructor:
# Prefer our builtin blake2 implementation.
return __get_builtin_constructor(name)(data, **kwargs)
try:
return _hashlib.new(name, data, **kwargs)
except ValueError:
# If the _hashlib module (OpenSSL) doesn't support the named
# hash, try using our builtin implementations.
# This allows for SHA224/256 and SHA384/512 support even though
# the OpenSSL library prior to 0.9.8 doesn't provide them.
return __get_builtin_constructor(name)(data)

try:
import _hashlib

new = __hash_new
__get_hash = __get_openssl_constructor
algorithms_available = algorithms_available.union(
_hashlib.openssl_md_meth_names
)
except ImportError:
_hashlib = None
new = __py_new
__get_hash = __get_builtin_constructor

with suppress(ImportError):
# OpenSSL's PKCS5_PBKDF2_HMAC requires OpenSSL 1.0+ with HMAC and SHA
from _hashlib import pbkdf2_hmac

__all__ += ("pbkdf2_hmac",)

with suppress(ImportError):
# OpenSSL's scrypt requires OpenSSL 1.1+
from _hashlib import scrypt

def file_digest(fileobj, digest, /, *, _bufsize=2**18):
"""Hash the contents of a file-like object. Returns a digest object.

*fileobj* must be a file-like object opened for reading in binary mode.
It accepts file objects from open(), io.BytesIO(), and SocketIO objects.
The function may bypass Python's I/O and use the file descriptor *fileno*
directly.

*digest* must either be a hash algorithm name as a *str*, a hash
constructor, or a callable that returns a hash object.
"""
# On Linux we could use AF_ALG sockets and sendfile() to archive zero-copy
# hashing with hardware acceleration.
if isinstance(digest, str):
digestobj = new(digest)
else:
digestobj = digest()

if hasattr(fileobj, "getbuffer"):
# io.BytesIO object, use zero-copy buffer
digestobj.update(fileobj.getbuffer())
return digestobj

# Only binary files implement readinto().
if not (
hasattr(fileobj, "readinto")
and hasattr(fileobj, "readable")
and fileobj.readable()
):
raise ValueError(
f"'{fileobj!r}' is not a file-like object in binary reading mode."
)

# binary file, socket.SocketIO object
# Note: socket I/O uses different syscalls than file I/O.
buf = bytearray(_bufsize) # Reusable buffer to reduce allocations.
view = memoryview(buf)
while True:
size = fileobj.readinto(buf)
if size == 0:
break # EOF
digestobj.update(view[:size])

return digestobj


def hash_value(value: Any) -> int | str:
"""Hash values.

Expand Down
26 changes: 6 additions & 20 deletions src/_pytask/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@
from typing import BinaryIO
from typing import Generic
from typing import NamedTuple
from typing import Self
from typing import TextIO
from typing import cast
from typing import final

import click
from typing_extensions import Self

from _pytask.capture_utils import CaptureMethod
from _pytask.capture_utils import ShowCapture
Expand Down Expand Up @@ -606,26 +606,12 @@ def writeorg(self, data: str) -> None:
# MultiCapture


# Generic NamedTuple only supported since Python 3.11.
if sys.version_info >= (3, 11) or TYPE_CHECKING:
@final
class CaptureResult(NamedTuple, Generic[AnyStr]):
"""A class for capture results."""

@final
class CaptureResult(NamedTuple, Generic[AnyStr]):
"""A class for capture results."""

out: AnyStr
err: AnyStr

else:
import collections

class CaptureResult(
collections.namedtuple("CaptureResult", ["out", "err"]), # noqa: PYI024
Generic[AnyStr],
):
"""A class for capture results."""

__slots__ = ()
out: AnyStr
err: AnyStr


class MultiCapture(Generic[AnyStr]):
Expand Down
8 changes: 1 addition & 7 deletions src/_pytask/config_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import enum
import os
import sys
import tomllib
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any
Expand All @@ -18,12 +18,6 @@
if TYPE_CHECKING:
from collections.abc import Sequence

if sys.version_info >= (3, 11): # pragma: no cover
import tomllib
else: # pragma: no cover
import tomli as tomllib # ty: ignore[unresolved-import]


__all__ = [
"find_project_root_and_config",
"normalize_programmatic_config",
Expand Down
3 changes: 0 additions & 3 deletions tests/test_execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -670,9 +670,6 @@ def task2() -> None:
assert session2.exit_code == ExitCode.OK


@pytest.mark.xfail(
sys.platform == "linux" and sys.version_info[:2] == (3, 10), reason="flakey"
)
def test_pytask_on_a_module_that_uses_the_functional_api(tmp_path):
source = """
from pytask import task, ExitCode, build
Expand Down
2 changes: 1 addition & 1 deletion tests/test_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pytask import is_task_function

if TYPE_CHECKING:
from typing_extensions import assert_type
from typing import assert_type

from pytask import CollectionMetadata
from pytask import task
Expand Down
Loading
Loading