Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
9ef20fc
feat: Models the core contract needs
Oct 2, 2026
771d48a
Feat: Protocols and Exceptions added for the core contract
Oct 2, 2026
5fe30c2
Feat: Private protocols for the BatchBackend
Oct 2, 2026
89d0bc9
feat: a fake backend to run unit tests against
Oct 6, 2026
af4c436
fix: some code cleanup in the helper functions
Oct 6, 2026
99f82ae
feat: setup for the unit test suite
Oct 6, 2026
e041acf
chore: style fix
Oct 6, 2026
d287307
chore: styling, unit test fixture
Oct 6, 2026
e830545
chore: single letter var `h` refering to hanlde changed to `handle`
Oct 6, 2026
be1e998
fix: unused parameter in `test_injected_failure_is_reported_per_job()…
Oct 6, 2026
2aeb589
chore: Making room in local fork for my documentation so i can easily…
Oct 7, 2026
5024ce9
fix: placed in the wrong folder :/
Oct 7, 2026
2c32012
chore: added testplan
Oct 7, 2026
3bf4210
feat: Models the core contract needs
Oct 2, 2026
0a6a9e1
Feat: Protocols and Exceptions added for the core contract
Oct 2, 2026
36cf7df
Feat: Private protocols for the BatchBackend
Oct 2, 2026
357050e
feat: a fake backend to run unit tests against
Oct 6, 2026
56bce86
fix: some code cleanup in the helper functions
Oct 6, 2026
5923545
feat: setup for the unit test suite
Oct 6, 2026
1f92acf
chore: style fix
Oct 6, 2026
f7cf50c
chore: styling, unit test fixture
Oct 6, 2026
bab3989
chore: single letter var `h` refering to hanlde changed to `handle`
Oct 6, 2026
eb3c644
fix: unused parameter in `test_injected_failure_is_reported_per_job()…
Oct 6, 2026
f727cbb
Merge branch 'feat/core-contract' of github.com:Alexander-Auer/interc…
Oct 7, 2026
feaf5e7
fix: made the OutputSpec optional as it is not suppoerted by all and …
Oct 7, 2026
46fd1a3
Feat: Core contract issue #16 (#1)
Alexander-Auer Oct 7, 2026
42dce34
fix: These documents should not exist in this way in the main repository
Oct 7, 2026
d67d380
fix: None check for the outputs in submissionspec
Oct 7, 2026
910b0b0
chore: trailing white spaces
Oct 7, 2026
a9fcca0
chore: fixed pre commit locally
Oct 7, 2026
74c40a7
chore: removed pdf, as they just take up space and no one will use th…
Oct 9, 2026
5cb1ee3
fix: JobHandle key is now a combination of the handle's fields instea…
Oct 19, 2026
b6af530
Merge branch 'main' into feat/core-contract
Alexander-Auer Oct 19, 2026
f90cdd4
feat: backend configuration and a closed registry
Alexander-Auer Oct 19, 2026
9dac6f5
test: expand unit test coverage to every logical case
Alexander-Auer Oct 20, 2026
0338aa2
feat: credential models and provider-based supply
Alexander-Auer Oct 20, 2026
ceed1df
Merge branch 'main' into feat/core-contract
Alexander-Auer Oct 23, 2026
d310fe8
test: trim the unit tests to logical cases that can happen
Oct 23, 2026
529fa75
Merge branch 'feat/core-contract' into stack/registry
Oct 23, 2026
5d94d0f
test: trim the registry and configuration tests to logical cases
Oct 23, 2026
60906ef
Merge branch 'stack/registry' into stack/test-expansion
Oct 23, 2026
ae6ca0a
test: trim the expanded tests to logical cases that can happen
Oct 23, 2026
359ece8
Merge branch 'stack/test-expansion' into stack/credential-models
Oct 23, 2026
f7e5eea
test: trim the credential tests to logical cases that can happen
Oct 23, 2026
a644ce6
docs: follow the Diátaxis structure for the user documentation
Oct 23, 2026
b6e172f
fix: address the review of the core contract
Oct 23, 2026
bc5619e
Merge branch 'feat/core-contract' into stack/registry
Oct 23, 2026
7391fbe
fix: no references to the ADRs in the registry and configuration
Oct 23, 2026
9c89415
Merge branch 'stack/registry' into stack/test-expansion
Oct 23, 2026
673cfa4
Merge branch 'stack/test-expansion' into stack/credential-models
Oct 23, 2026
ceb871e
fix: no references to the ADRs in the credential code
Oct 23, 2026
a0211b4
Merge DIRACGrid main into stack/registry (the core contract, squash-m…
Oct 26, 2026
5d10b5a
fix: apply the core contract review to the registry
Oct 26, 2026
7d86596
refactor: rename registry.backend() to get_backend()
Oct 26, 2026
b4fb7bd
refactor!: one public configuration model per backend kind
Oct 26, 2026
03efb2a
docs: generate the configuration reference from the models
Oct 26, 2026
2191feb
Merge branch 'stack/registry' into stack/test-expansion
Oct 26, 2026
929ea61
test: remove leftovers of the flat configuration model
Oct 26, 2026
ce1c811
Merge branch 'stack/test-expansion' into stack/credential-models
Oct 26, 2026
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
16 changes: 16 additions & 0 deletions docs/user/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,22 @@ Anything else, including every name starting with `_`, is internal.

::: intercede.models

## Configuration

::: intercede.config

## Registry

::: intercede.registry

## Credentials

::: intercede.auth.models

::: intercede.auth.protocols

::: intercede.auth.providers

## Exceptions

::: intercede.exceptions
6 changes: 6 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ plugins:
show_source: false
members_order: source
merge_init_into_class: true
inherited_members: true
# Field tables for the configuration models, read from the models
# themselves, so the documentation cannot drift from the code.
extensions:
- griffe_pydantic:
schema: false

validation:
links:
Expand Down
18 changes: 18 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Repository = "https://github.com/DIRACGrid/intercede"
testing = [
"pytest",
"pytest-asyncio",
"pytest-cov",
"pytest-xdist",
"pyyaml",
"ty",
Expand All @@ -59,6 +60,19 @@ markers = [
"destructive_fetch: asserts one-shot/destructive output-retrieval semantics",
]

[tool.coverage.run]
branch = true
patch = ["subprocess"] # also measure the tests that start a fresh interpreter
source = ["intercede"]

[tool.coverage.report]
show_missing = true
skip_covered = true
exclude_also = [
"if TYPE_CHECKING:",
"^\\s*\\.\\.\\.$", # protocol method bodies
]

[tool.mypy]
strict = true
plugins = ["pydantic.mypy"]
Expand Down Expand Up @@ -100,6 +114,9 @@ ignore = [
"FLY002",
]

[tool.ruff.lint.pydocstyle]
convention = "google"

[tool.ruff.lint.isort]
required-imports = ["from __future__ import annotations"]

Expand Down Expand Up @@ -144,6 +161,7 @@ intercede = { path = ".", editable = true }
mkdocs = "<2"
mkdocs-material = "*"
mkdocstrings = { version = "*", extras = ["python"] }
griffe-pydantic = "*"
[tool.pixi.feature.docs.tasks]
docs = { cmd = "mkdocs build --strict", description = "Build the documentation" }

Expand Down
21 changes: 21 additions & 0 deletions src/intercede/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,21 @@
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as get_version

from intercede import registry
from intercede.config import (
ARCConfig,
BackendConfig,
BatchLocalConfig,
BatchSSHConfig,
CloudConfig,
HTCondorCEConfig,
validate_config,
)
from intercede.exceptions import (
AuthenticationError,
ConfigurationError,
InterCEdeError,
RegistryError,
ResourceUnavailableError,
SpecificationRejectedError,
)
Expand Down Expand Up @@ -47,6 +58,7 @@

__all__ = [
"__version__",
"registry",
# protocols
"Diagnosable",
"JobBackend",
Expand All @@ -71,10 +83,19 @@
"StatusMap",
"Submission",
"SubmissionSpec",
# configuration
"ARCConfig",
"BackendConfig",
"BatchLocalConfig",
"BatchSSHConfig",
"CloudConfig",
"HTCondorCEConfig",
"validate_config",
# exceptions
"AuthenticationError",
"ConfigurationError",
"InterCEdeError",
"RegistryError",
"ResourceUnavailableError",
"SpecificationRejectedError",
]
18 changes: 18 additions & 0 deletions src/intercede/_backends/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Settings of the in-memory fake, which no public configuration reaches.

Every other backend is built from its public configuration model in
``intercede.config``.
"""

from __future__ import annotations

from typing import Literal

from intercede.models import _Model


class FakeSettings(_Model):
"""The in-memory fake, for the test suites."""

kind: Literal["fake"] = "fake"
resource: str = "fake://local"
158 changes: 158 additions & 0 deletions src/intercede/_backends/credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""What a backend asks its credential provider for, when, and what it accepts back.

``credential_requirements`` turns a configuration into what to ask for. This is
also the one place freshness is decided. A backend keeps the set it was
last given, privately, and before each operation calls ``refresh_if_stale``:
the provider is asked only when that set is missing a required kind or one of
its credentials is about to expire. Where credentials come from, and how they
are renewed, stays the consumer's business, inside its provider.
"""

from __future__ import annotations

from datetime import UTC, datetime, timedelta

from intercede.auth.models import (
BearerToken,
Credential,
CredentialRequirements,
CredentialSet,
SSHPassword,
SSHPrivateKey,
X509Proxy,
)
from intercede.auth.protocols import CredentialProvider
from intercede.config import (
ARCConfig,
BackendConfig,
BatchSSHConfig,
HTCondorCEConfig,
)
from intercede.exceptions import AuthenticationError, InterCEdeError

# What each configured auth mode asks the credential provider for.
# "agent" is absent on purpose: an SSH agent holds the key, so nothing is asked.
AUTH_KINDS: dict[str, type[Credential]] = {
"token": BearerToken,
"proxy": X509Proxy,
"key": SSHPrivateKey,
"password": SSHPassword,
}

# Ask again this long before a credential expires, so it cannot run out in the
# middle of an operation. A library default; whether a provider should be able
# to change it is still open.
DEFAULT_MARGIN = timedelta(minutes=5)


def credential_requirements(
config: BackendConfig, scopes: frozenset[str] = frozenset()
) -> CredentialRequirements | None:
"""Say what a backend must ask its provider for.

Args:
config: The backend's validated configuration.
scopes: The scopes of the operation about to run, for token-based
backends; ignored by the others.

Returns:
What to ask for, or None when the backend needs no credential: it runs
on this host, or an SSH agent holds the key.
"""
if isinstance(config, ARCConfig | HTCondorCEConfig):
# The endpoint is the resource, so a token is minted for this CE's audience.
return CredentialRequirements(
resource=config.endpoint,
kinds=frozenset(AUTH_KINDS[mode] for mode in config.auth),
scopes=scopes,
)
if isinstance(config, BatchSSHConfig) and config.auth != "agent":
# The resource is the login, ``user@host:port``: a provider finds the key
# or password by it, and the same host can take different users.
return CredentialRequirements(
resource=f"{config.user}@{config.host}:{config.port}",
kinds=frozenset({AUTH_KINDS[config.auth]}),
)
return None


def is_stale(
credentials: CredentialSet | None,
requirements: CredentialRequirements,
*,
margin: timedelta = DEFAULT_MARGIN,
now: datetime | None = None,
) -> bool:
"""Tell whether ``credentials`` cannot serve ``requirements`` for ``margin`` more.

Stale when there are no credentials yet, when a required kind is missing,
or when a required credential expires within ``margin``. A credential that
never expires is never stale.
"""
if credentials is None:
return True
moment = now or datetime.now(UTC)
for kind in requirements.kinds:
credential = credentials.get(kind)
if credential is None:
return True
if (
credential.expires_at is not None
and credential.expires_at - margin <= moment
):
return True
return False


async def refresh_if_stale(
current: CredentialSet | None,
requirements: CredentialRequirements,
provider: CredentialProvider,
*,
margin: timedelta = DEFAULT_MARGIN,
now: datetime | None = None,
) -> CredentialSet:
"""Return ``current`` while it is fresh, otherwise a new set from ``provider``.

Raises:
AuthenticationError: The provider failed, left out a required kind, or
returned a credential that has already expired. Always a failure of
the whole operation, never a per-job outcome.

"""
if not is_stale(current, requirements, margin=margin, now=now):
assert current is not None # is_stale() is True for None
return current

try:
fresh = await provider.get(requirements)
except InterCEdeError:
raise # already typed, e.g. an AuthenticationError from the provider
except Exception as exc:
raise AuthenticationError(
f"the credential provider failed for {requirements.resource}: {exc}"
) from exc

if not isinstance(fresh, CredentialSet):
raise AuthenticationError(
f"the credential provider returned a {type(fresh).__name__} "
f"for {requirements.resource}, not a CredentialSet"
)
missing = []
for kind in requirements.kinds:
if fresh.get(kind) is None:
missing.append(kind.__name__)
if missing:
raise AuthenticationError(
# Sorted: kinds is a set, whose order changes from run to run.
f"the credential provider returned no {', '.join(sorted(missing))} "
f"for {requirements.resource}"
)
# Expiring within the margin is accepted (it is asked again next time);
# already expired is not, since it cannot authenticate anything.
if is_stale(fresh, requirements, margin=timedelta(0), now=now):
raise AuthenticationError(
f"the credential provider returned expired credentials "
f"for {requirements.resource}"
)
return fresh
27 changes: 23 additions & 4 deletions src/intercede/_backends/fake.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@

from __future__ import annotations

from collections.abc import Collection, Mapping, Sequence
from collections.abc import Callable, Collection, Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from types import TracebackType
from typing import Self

from intercede._backends.config import FakeSettings
from intercede.auth.protocols import CredentialProvider
from intercede.exceptions import (
InterCEdeError,
ResourceUnavailableError,
Expand Down Expand Up @@ -92,6 +94,22 @@ def __init__(
self._next_id = 0
self._closed = False

@classmethod
def from_settings(
cls, settings: FakeSettings, credentials: CredentialProvider
) -> Self:
"""Build the fake from its settings; how the registry constructs it.

Args:
settings: The fake's settings: only the resource it reports.
credentials: Never asked: nothing leaves the process, so the fake
needs no credentials.

Returns:
A new fake, not yet opened.
"""
return cls(resource=settings.resource)

# -- lifecycle ---------------------------------------------------------

async def __aenter__(self) -> Self:
Expand Down Expand Up @@ -252,10 +270,8 @@ def _check_supported(self, spec: SubmissionSpec) -> None:

if not self.resource_staging:
self._check_can_fetch(spec.inputs)
if spec.outputs is not None:
if spec.outputs is not None: # no outputs means nothing to upload
self._check_can_upload(spec.outputs.members)
else:
raise SpecificationRejectedError(f"tag: {spec.tag} contains no outputs")

def _check_container_support(self, spec: SubmissionSpec) -> None:
if spec.container is not None and not self.containers:
Expand Down Expand Up @@ -368,3 +384,6 @@ def _write_job_files(
_kill: type[Killable] = FakeBackend
_purge: type[Purgeable] = FakeBackend
_diag: type[Diagnosable] = FakeBackend
_built_by_registry: Callable[[FakeSettings, CredentialProvider], JobBackend] = (
FakeBackend.from_settings
)
Loading
Loading