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
2 changes: 1 addition & 1 deletion .github/workflows/unit-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14.0-beta.2"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14.0-rc.3"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

![version](https://img.shields.io/badge/version-0.8.0-informational)
![pre-release](https://img.shields.io/badge/pre--release-beta-red)
![python](https://img.shields.io/badge/python-%3E%3D3.9-informational)
![python](https://img.shields.io/badge/python-%3E%3D10-informational)
![coverage](https://img.shields.io/badge/coverage-96%25-brightgreen)
![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=orange)

Expand Down
8 changes: 4 additions & 4 deletions concoursetools/additional.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class DatetimeVersion(TypedVersion):
execution_date: datetime

@classmethod
def now(cls) -> "DatetimeVersion":
def now(cls) -> DatetimeVersion:
"""Return the version corresponding to now."""
return cls(datetime.now())

Expand Down Expand Up @@ -236,7 +236,7 @@ def to_flat_dict(self) -> VersionConfig:
}

@classmethod
def from_flat_dict(cls: "type[MultiVersion[SortableVersionT]]", version_dict: VersionConfig) -> "MultiVersion[SortableVersionT]":
def from_flat_dict(cls: type[MultiVersion[SortableVersionT]], version_dict: VersionConfig) -> MultiVersion[SortableVersionT]:
"""
Load an instance from a dictionary representing the version.

Expand Down Expand Up @@ -382,7 +382,7 @@ def fetch_all_versions(self) -> set[SortableVersionT]:

class _PseudoConcourseResource(ConcourseResource[VersionT]):

def __new__(cls) -> "_PseudoConcourseResource[VersionT]":
def __new__(cls) -> _PseudoConcourseResource[VersionT]:
raise TypeError(f"Cannot instantiate a {cls.__name__} type")


Expand Down Expand Up @@ -425,7 +425,7 @@ class MultiResourceConcourseResource(_PseudoConcourseResource[VersionT]):
A special Resource class which delegates to multiple other resource classes.
"""
@classmethod
def _from_resource_config(cls, resource_config: ResourceConfig) -> "ConcourseResource[VersionT]":
def _from_resource_config(cls, resource_config: ResourceConfig) -> ConcourseResource[VersionT]:
try:
resource_key = resource_config.pop(param_key)
except KeyError as error:
Expand Down
6 changes: 3 additions & 3 deletions concoursetools/cli/docstring.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class Docstring:
parameters: dict[str, str]

@classmethod
def from_object(cls, obj: object) -> "Docstring":
def from_object(cls, obj: object) -> Docstring:
"""
Parse an object with a docstring.

Expand All @@ -42,7 +42,7 @@ def from_object(cls, obj: object) -> "Docstring":
return cls.from_string(raw_docstring)

@classmethod
def from_string(cls, raw_docstring: str) -> "Docstring":
def from_string(cls, raw_docstring: str) -> Docstring:
"""
Parse a docstring.

Expand All @@ -63,7 +63,7 @@ def from_string(cls, raw_docstring: str) -> "Docstring":
return cls(first_line, description.strip(), parameters)


def _pair_up(data: list[str]) -> Generator[tuple[str, str], None, None]:
def _pair_up(data: list[str]) -> Generator[tuple[str, str]]:
"""
Pair up a list of items.

Expand Down
15 changes: 4 additions & 11 deletions concoursetools/cli/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,14 @@
from dataclasses import dataclass
import inspect
import shutil
import sys
import textwrap
from types import UnionType
from typing import Any, Generic, TypeVar

from concoursetools import __version__
from concoursetools.cli.docstring import Docstring

_CURRENT_PYTHON_VERSION = (sys.version_info.major, sys.version_info.minor)

if _CURRENT_PYTHON_VERSION >= (3, 10):
from types import UnionType
_ANNOTATIONS_TO_TYPES: dict[type[Any] | UnionType | str, type] = {}
else:
_ANNOTATIONS_TO_TYPES: dict[type[Any] | Any | str, type] = {} # type: ignore[no-redef]
_ANNOTATIONS_TO_TYPES: dict[type[Any] | UnionType | str, type] = {}

CLIFunction = Callable[..., None]
CLIFunctionT = TypeVar("CLIFunctionT", bound=CLIFunction)
Expand All @@ -37,8 +31,7 @@
type_.__name__: type_,
f"{type_.__name__} | None": type_,
})
if _CURRENT_PYTHON_VERSION >= (3, 10):
_ANNOTATIONS_TO_TYPES[type_ | None] = type_
_ANNOTATIONS_TO_TYPES[type_ | None] = type_


class _CLIParser(ABC):
Expand Down Expand Up @@ -377,7 +370,7 @@ def add_to_parser(self, parser: ArgumentParser) -> None:
...

@classmethod
def yield_from_function(cls, func: CLIFunction, allow_short: set[str]) -> Generator[Parameter[Any], None, None]:
def yield_from_function(cls, func: CLIFunction, allow_short: set[str]) -> Generator[Parameter[Any]]:
"""
Yield parameters from a function.

Expand Down
2 changes: 1 addition & 1 deletion concoursetools/colour.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def colour_print(*values: object, colour: str = MISSING_COLOUR, bold: bool = Fal


@contextmanager
def print_in_colour(colour: str, bold: bool = False, underline: bool = False) -> Generator[None, None, None]:
def print_in_colour(colour: str, bold: bool = False, underline: bool = False) -> Generator[None]:
"""
Print anything in colour within a :ref:`context manager <context-managers>`.

Expand Down
2 changes: 1 addition & 1 deletion concoursetools/importing.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ def import_py_file(import_path: str, file_path: Path) -> ModuleType:


@contextmanager
def edit_sys_path(prepend: Sequence[Path] = (), append: Sequence[Path] = ()) -> Generator[None, None, None]:
def edit_sys_path(prepend: Sequence[Path] = (), append: Sequence[Path] = ()) -> Generator[None]:
"""
Temporarily add to :data:`sys.path` within a context manager.

Expand Down
2 changes: 1 addition & 1 deletion concoursetools/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ def format_string(self, string: str, additional_values: dict[str, str] | None =
return template.safe_substitute(possible_values) if ignore_missing else template.substitute(possible_values)

@classmethod
def from_env(cls) -> "BuildMetadata":
def from_env(cls) -> BuildMetadata:
"""Return an instance populated from the environment."""
return cls(
BUILD_ID=os.environ["BUILD_ID"],
Expand Down
16 changes: 7 additions & 9 deletions concoursetools/mocking.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,11 @@
import sys
from tempfile import TemporaryDirectory
from types import TracebackType
from typing import Any, TypeVar
from typing import Any
from unittest import mock

from concoursetools import BuildMetadata

T = TypeVar("T")
ContextManager = Generator[T, None, None]
FolderDict = dict[str, Any]


Expand Down Expand Up @@ -156,7 +154,7 @@ def final_state(self) -> FolderDict:
raise RuntimeError("Final state is not set whilst temporary directory is still open.")
return self._final_state

def __enter__(self) -> "TemporaryDirectoryState":
def __enter__(self) -> TemporaryDirectoryState:
self._temp_dir = TemporaryDirectory(**self.temporary_directory_kwargs)
self._set_folder_from_dict(self.path, self.starting_state, encoding=self.encoding)
return self
Expand Down Expand Up @@ -278,7 +276,7 @@ def clear(self) -> None:
self.inner_io = StringIO()

@contextmanager
def capture_stdout_and_stderr(self) -> ContextManager["StringIOWrapper"]:
def capture_stdout_and_stderr(self) -> Generator[StringIOWrapper]:
"""
Capture both :data:`~sys.stdout` and :data:`~sys.stderr`.

Expand All @@ -289,7 +287,7 @@ def capture_stdout_and_stderr(self) -> ContextManager["StringIOWrapper"]:
yield self

@contextmanager
def capture_stderr(self) -> ContextManager["StringIOWrapper"]:
def capture_stderr(self) -> Generator[StringIOWrapper]:
"""
Capture :data:`~sys.stderr`.

Expand All @@ -300,7 +298,7 @@ def capture_stderr(self) -> ContextManager["StringIOWrapper"]:


@contextmanager
def mock_environ(new_environ: dict[str, str]) -> ContextManager[None]:
def mock_environ(new_environ: dict[str, str]) -> Generator[None]:
"""
Mock :data:`os.environ` in a context manager.

Expand All @@ -317,7 +315,7 @@ def mock_environ(new_environ: dict[str, str]) -> ContextManager[None]:


@contextmanager
def mock_stdin(stdin: str) -> ContextManager[None]:
def mock_stdin(stdin: str) -> Generator[None]:
"""
Mock :data:`~sys.stdin` in a context manager.

Expand All @@ -338,7 +336,7 @@ def mock_stdin(stdin: str) -> ContextManager[None]:


@contextmanager
def mock_argv(*args: str) -> ContextManager[None]:
def mock_argv(*args: str) -> Generator[None]:
"""
Mock :data:`sys.argv` in a context manager.

Expand Down
8 changes: 4 additions & 4 deletions concoursetools/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ def out_main(cls) -> None:
_output(output)

@classmethod
def _parse_check_input(cls) -> tuple["ConcourseResource[VersionT]", VersionT | None]:
def _parse_check_input(cls) -> tuple[ConcourseResource[VersionT], VersionT | None]:
"""Parse input from the command line."""
check_payload = sys.stdin.read()

Expand All @@ -291,7 +291,7 @@ def _parse_check_input(cls) -> tuple["ConcourseResource[VersionT]", VersionT | N
return resource, previous_version

@classmethod
def _parse_in_input(cls) -> tuple["ConcourseResource[VersionT]", VersionT, Path, Params]:
def _parse_in_input(cls) -> tuple[ConcourseResource[VersionT], VersionT, Path, Params]:
"""Parse input from the command line."""
in_payload = sys.stdin.read()

Expand All @@ -309,7 +309,7 @@ def _parse_in_input(cls) -> tuple["ConcourseResource[VersionT]", VersionT, Path,
return resource, version, destination_dir, params

@classmethod
def _parse_out_input(cls) -> tuple["ConcourseResource[VersionT]", Path, Params]:
def _parse_out_input(cls) -> tuple[ConcourseResource[VersionT], Path, Params]:
"""Parse input from the command line."""
out_payload = sys.stdin.read()

Expand All @@ -326,7 +326,7 @@ def _parse_out_input(cls) -> tuple["ConcourseResource[VersionT]", Path, Params]:
return resource, sources_dir, params

@classmethod
def _from_resource_config(cls, resource_config: ResourceConfig) -> "ConcourseResource[VersionT]":
def _from_resource_config(cls, resource_config: ResourceConfig) -> ConcourseResource[VersionT]:
return cls(**resource_config)


Expand Down
19 changes: 8 additions & 11 deletions concoursetools/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,14 @@
import secrets
import subprocess
from tempfile import TemporaryDirectory
from typing import Any, Generic, TypeVar
from typing import Any, Generic

from concoursetools import BuildMetadata, ConcourseResource, Version
from concoursetools.dockertools import MethodName, ScriptName, create_script_file
from concoursetools.mocking import StringIOWrapper, TemporaryDirectoryState, create_env_vars, mock_argv, mock_environ, mock_stdin
from concoursetools.parsing import format_check_input, format_in_input, format_out_input, parse_metadata
from concoursetools.typing import Metadata, MetadataPair, Params, ResourceConfig, VersionConfig, VersionT

T = TypeVar("T")
ContextManager = Generator[T, None, None]
FolderDict = dict[str, Any]


Expand Down Expand Up @@ -90,7 +88,7 @@ def publish_new_version(self) -> object:
"""

@contextmanager
def capture_debugging(self) -> ContextManager[StringIOWrapper]:
def capture_debugging(self) -> Generator[StringIOWrapper]:
"""
Redirect the resource debugging output within a context manager into a variable.

Expand All @@ -105,7 +103,7 @@ def capture_debugging(self) -> ContextManager[StringIOWrapper]:
yield self._debugging_output

@contextmanager
def capture_directory_state(self, starting_state: FolderDict | None = None) -> ContextManager["TemporaryDirectoryState"]:
def capture_directory_state(self, starting_state: FolderDict | None = None) -> Generator[TemporaryDirectoryState]:
"""
Open a context manager to expose the internal state of the resource.

Expand All @@ -127,7 +125,7 @@ def capture_directory_state(self, starting_state: FolderDict | None = None) -> C
self._directory_state.starting_state = old_start_state

@contextmanager
def _forbid_methods(self, *methods: Callable[..., object]) -> ContextManager[None]:
def _forbid_methods(self, *methods: Callable[..., object]) -> Generator[None]:
try:
for method in methods:
def new_method(*args: object, **kwargs: object) -> object:
Expand Down Expand Up @@ -514,9 +512,9 @@ def publish_new_version(self, params: Params | None = None) -> tuple[VersionConf
return new_version_config, metadata_pairs

@classmethod
def from_assets_dir(cls: type["FileTestResourceWrapper"], inner_resource_config: ResourceConfig, assets_dir: Path,
def from_assets_dir(cls: type[FileTestResourceWrapper], inner_resource_config: ResourceConfig, assets_dir: Path,
directory_dict: FolderDict | None = None, one_off_build: bool = False,
instance_vars: dict[str, str] | None = None, **env_vars: str) -> "FileTestResourceWrapper":
instance_vars: dict[str, str] | None = None, **env_vars: str) -> FileTestResourceWrapper:
"""
Create an instance from a single folder of asset files.

Expand Down Expand Up @@ -638,7 +636,7 @@ def publish_new_version(self, **params: object) -> tuple[VersionT, Metadata]:
return new_version, metadata

@contextmanager
def _temporarily_create_script_file(self, script_name: ScriptName, method_name: MethodName) -> ContextManager[None]:
def _temporarily_create_script_file(self, script_name: ScriptName, method_name: MethodName) -> Generator[None]:
attribute_name = f"{script_name}_script"
try:
with TemporaryDirectory() as temp_dir:
Expand Down Expand Up @@ -958,8 +956,7 @@ def run_command(command: str, additional_args: list[str] | None = None, env: dic
cwd=cwd,
check=True,
input=stdin.encode() if stdin is not None else None,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
capture_output=True,
)
except subprocess.CalledProcessError as error:
error_message: bytes = error.stderr
Expand Down
10 changes: 1 addition & 9 deletions concoursetools/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,15 +262,7 @@ class TypedVersion(Version):
_un_flatten_functions: ClassVar[MutableMapping[type[Any], Callable[[type[Any], str], Any]]] = _TypeKeyDict()

def __init_subclass__(cls) -> None:
try:
annotations = inspect.get_annotations(cls)
except AttributeError:
# Function isn't available in Python 3.9, so use the old code until EOL
try:
annotations = vars(cls)["__annotations__"] # avoid MRO lookup
except KeyError:
annotations = {}

annotations = inspect.get_annotations(cls)
if len(annotations) == 0:
raise TypeError("Can't instantiate dataclass TypedVersion without any fields")

Expand Down
1 change: 1 addition & 0 deletions docs/source/changelog/development.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ Development

* Added Python 3.14 support.
* Documentation is now built with Python 3.13.
* Dropped support for Python 3.9.
2 changes: 1 addition & 1 deletion examples/build_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def __call__(self, request: requests.Request) -> requests.Request:
return request

@classmethod
def from_client_credentials(cls, client_id: str, client_secret: str) -> "BitbucketOAuth":
def from_client_credentials(cls, client_id: str, client_secret: str) -> BitbucketOAuth:
token_auth = HTTPBasicAuth(client_id, client_secret)

url = "https://bitbucket.org/site/oauth2/access_token"
Expand Down
2 changes: 1 addition & 1 deletion examples/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def publish_new_version(self, sources_dir: Path, build_metadata: BuildMetadata,
new_version = ExecutionVersion(execution_arn)
return new_version, metadata

def _yield_potential_execution_versions(self) -> Generator[ExecutionVersion, None, None]:
def _yield_potential_execution_versions(self) -> Generator[ExecutionVersion]:
kwargs = {
"PipelineName": self.pipeline_name,
"SortOrder": "Descending",
Expand Down
4 changes: 2 additions & 2 deletions examples/xkcd.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,14 @@ def publish_new_version(self, sources_dir: Path, build_metadata: BuildMetadata)
raise NotImplementedError


def yield_comic_ids(xml_data: str) -> Generator[int, None, None]:
def yield_comic_ids(xml_data: str) -> Generator[int]:
for comic_url in yield_comic_links(xml_data):
parsed_url = urllib.parse.urlparse(comic_url)
comic_id = parsed_url.path.strip("/")
yield int(comic_id)


def yield_comic_links(xml_data: str) -> Generator[str, None, None]:
def yield_comic_links(xml_data: str) -> Generator[str]:
root = ET.fromstring(xml_data)
for entry in root:
if entry.tag.endswith("entry"):
Expand Down
Loading