Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
85 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
af2109f
fix: Rename FakeSettings to FakeConfig to stay in line with the revie…
Oct 26, 2026
23641f6
Merge remote-tracking branch 'origin/stack/registry' into stack/test-…
Oct 27, 2026
6dca5ef
test: keep the existing model tests where they are
Oct 27, 2026
d7e045f
Merge DIRACGrid main into stack/test-expansion (the registry, squash-…
Oct 27, 2026
b94e917
test: a configuration that is not a mapping is refused
Oct 27, 2026
66af4f2
Merge branch 'stack/test-expansion' into stack/credential-models
Oct 27, 2026
e55693f
Merge DIRACGrid main into stack/credential-models (the test expansion…
Oct 27, 2026
ca8d39f
fix: credential errors never repeat the value they were given
Oct 27, 2026
eccfbc6
fix: a provider's error text stays out of the AuthenticationError
Oct 27, 2026
8e2f76c
docs: document the credential fields in the API reference
Oct 27, 2026
2670cfb
docs: Google-style docstrings for the credential functions
Oct 27, 2026
89c3b94
fix: Removed the x509 proxy as it will not be used anymore, and also …
Nov 4, 2026
e3fc956
Merge branch 'main' into stack/credential-models
Alexander-Auer Nov 4, 2026
40fcf80
fix: Added a small credentialcache so each scope can get its own toke…
Nov 5, 2026
ca36ea1
test: give each backend its own folder in the unit tests
Nov 3, 2026
db3513c
feat: HTCondor submit descriptions in the shared core
Nov 3, 2026
cb083fd
feat: HTCondor commands and the state map in the shared core
Nov 3, 2026
e05f6dd
fix: no X.509 proxy in HTCondor submit descriptions
Nov 4, 2026
3f94e6e
ci: run integration tests side by side and name what is skipped
Nov 3, 2026
6a58e1b
test: the htcondor integration stack
Nov 3, 2026
c518fca
feat: an HTCondor-CE configuration can name its schedd
Nov 3, 2026
ee61671
fix: a local input that cannot be read is refused on submit
Nov 3, 2026
20752a9
feat: the HTCondor-CE backend's building blocks
Nov 3, 2026
2cad59c
feat: HTCondorCEBackend with submit and status
Nov 3, 2026
16efdf2
test: HTCondor-CE submit and status against simulated tools
Nov 3, 2026
667b092
fix: the HTCondor-CE backend authenticates with one token
Nov 4, 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
7 changes: 5 additions & 2 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
- ".github/workflows/integration.yml"
- "tests/conftest.py"
- "pyproject.toml"
- "src/**"

permissions:
contents: read
Expand Down Expand Up @@ -66,10 +67,12 @@ jobs:
# TODO(#13/#14): ship the suite in the image and exec as an unprivileged user.
runner=(docker compose -f "$COMPOSE_FILE" exec -T backend pytest)
else
runner=(pixi run -e py313 pytest)
# Independent tests, run side by side (stacks.yml `workers`).
runner=(pixi run -e py313 pytest -n "${{ matrix.workers }}")
fi
set +e
"${runner[@]}" tests/integration -m "${{ matrix.markers }}" \
# -rs --no-fold-skipped: name every skipped test and why.
"${runner[@]}" tests/integration -m "${{ matrix.markers }}" -rs --no-fold-skipped \
--stack="${{ matrix.stack }}" --config="${{ matrix.config }}"
rc=$?
# Exit 5 = no tests selected; tolerated until the contract suite lands.
Expand Down
2 changes: 1 addition & 1 deletion docs/dev/how-to/add-an-integration-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ You do not need to change any CI workflow. CI finds the stack in `stacks.yml`.

```sh
docker compose -f tests/integration/stacks/<id>/compose.yml up --wait
pixi run -e py313 pytest tests/integration -m "<markers>" --stack=<id> --config=basic
pixi run -e py313 pytest tests/integration -n 4 -m "<markers>" --stack=<id> --config=basic
pixi run -e py313 pytest tests/integration/test_manifest.py
```

Expand Down
5 changes: 4 additions & 1 deletion docs/dev/how-to/run-integration-tests-locally.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ CI runs the same commands as below, so you can reproduce any CI job on your mach
2. Run the tests. Copy `<markers>` from the stack's entry in `stacks.yml`:

```sh
pixi run -e py313 pytest tests/integration -m "<markers>" --stack=<id> --config=basic
pixi run -e py313 pytest tests/integration -n 4 -m "<markers>" --stack=<id> --config=basic
```

`-n 4` runs four tests at the same time, as CI does. Use the stack's `workers` value from
`stacks.yml` if it sets one, or leave `-n` out to run one test at a time while debugging.

For stacks with `exec_in_container: true` (the `local-*` stacks), run the tests inside the
container instead:

Expand Down
1 change: 1 addition & 0 deletions docs/dev/reference/integration-stacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ tests/integration/
| `versions` | no | Backend versions to test, for example `[lts, latest]`. Default: `[latest]`. |
| `markers` | yes | `pytest -m` expression that selects the tests for this stack. |
| `exec_in_container` | no | `true`: run pytest inside the backend container. Default: `false`. |
| `workers` | no | How many tests run at the same time (pytest-xdist). Default: `4`. Set `1` for a backend that cannot take several jobs at once. |

## When a stack gets CI jobs

Expand Down
8 changes: 8 additions & 0 deletions docs/user/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ Anything else, including every name starting with `_`, is internal.

::: intercede.registry

## Credentials

::: intercede.auth.models

::: intercede.auth.protocols

::: intercede.auth.providers

## Exceptions

::: intercede.exceptions
11 changes: 11 additions & 0 deletions src/intercede/_backends/_htcondor/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""What interCEde knows about HTCondor, shared by every backend that drives it.

Plain functions and data only, with no dependency on any backend class: the
HTCondor-CE backend uses them directly, and the HTCondor scheduler of the
composed backend (issue #21) will use the same ones over its transport. They
share these functions, never a subtype.

- ``submit``: a submit description built from a ``SubmissionSpec``
- ``commands``: the argv of each HTCondor tool, and parsers for their output
- ``status``: the state mapping from job ads to ``JobStatus``
"""
117 changes: 117 additions & 0 deletions src/intercede/_backends/_htcondor/commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""The argv of each HTCondor tool, and parsers for what they print.

``remote`` is how a tool reaches its schedd: ``schedd_options(pool, name)``
for a remote schedd, as an HTCondor-CE is reached, or ``()`` for the schedd
of the host the tool runs on, as the composed backend reaches it.
"""

from __future__ import annotations

import re
from collections.abc import Sequence

from intercede.models import JobID

# Ids per command line, so a status poll of thousands of jobs stays bounded.
CHUNK = 100


def schedd_options(pool: str, name: str) -> list[str]:
"""Reach the schedd ``name`` through the collector ``pool`` (host:port)."""
return ["-pool", pool, "-name", name]


def submit(
description: str, pool: str | None = None, name: str | None = None
) -> list[str]:
"""Submit a description file and spool its inputs to the schedd.

Spooling copies the inputs at submit time, so nothing on the submitting
host is needed afterwards. With ``pool`` and ``name`` the schedd is a
remote one (``-remote`` implies spooling); without, it is the local one.
"""
if (pool is None) != (name is None):
raise ValueError("a remote schedd needs both pool and name")

command = ["condor_submit", "-terse"]

if pool is None or name is None:
command.append("-spool")
else:
command.extend(["-pool", pool, "-remote", name])

command.append(description)
return command


def query(
ids: Sequence[JobID], attributes: Sequence[str], remote: Sequence[str]
) -> list[str]:
"""Read the ads of jobs still in the queue."""
return ["condor_q", *remote, *ids, "-attributes", ",".join(attributes), "-json"]


def history(
ids: Sequence[JobID], attributes: Sequence[str], remote: Sequence[str]
) -> list[str]:
"""Read the ads of jobs that have left the queue."""
return [
"condor_history",
*remote,
*ids,
"-attributes",
",".join(attributes),
"-json",
]


def remove(ids: Sequence[JobID], remote: Sequence[str]) -> list[str]:
"""Remove jobs: cancels active ones, releases finished ones."""
return ["condor_rm", *remote, *ids]


def transfer_data(ids: Sequence[JobID], remote: Sequence[str]) -> list[str]:
"""Fetch the spooled output of jobs into the directory they were submitted from."""
return ["condor_transfer_data", *remote, *ids]


def chunks(ids: Sequence[JobID]) -> list[Sequence[JobID]]:
"""Split ids into groups of at most ``CHUNK``."""
return [ids[start : start + CHUNK] for start in range(0, len(ids), CHUNK)]


_TERSE = re.compile(
r"\s*(?P<cluster>\d+)\.(?P<first>\d+)"
r"\s*-\s*"
r"(?P<last_cluster>\d+)\.(?P<last>\d+)\s*"
)


def parse_submit(raw: str) -> list[JobID]:
"""Parse job IDs from condor_submit -terse output, e.g. '12.0 - 12.3'."""
lines = [line for line in raw.splitlines() if line.strip()]

if len(lines) != 1:
raise ValueError(f"unexpected condor_submit output: {raw.strip()!r}")

match = _TERSE.fullmatch(lines[0])
if match is None:
raise ValueError(f"unexpected condor_submit output: {raw.strip()!r}")

cluster = int(match["cluster"])
first = int(match["first"])
last_cluster = int(match["last_cluster"])
last = int(match["last"])

if cluster != last_cluster or last < first:
raise ValueError(f"unexpected condor_submit output: {raw.strip()!r}")

return [JobID(f"{cluster}.{proc}") for proc in range(first, last + 1)]


_REMOVED = re.compile(r"^Job (\d+\.\d+) marked for removal", re.MULTILINE)


def parse_remove(raw: str) -> set[JobID]:
"""Return the ids ``condor_rm`` reports as removed; any other id was not."""
return {JobID(job) for job in _REMOVED.findall(raw)}
124 changes: 124 additions & 0 deletions src/intercede/_backends/_htcondor/status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""From HTCondor job ads to ``JobStatus``.

Reading only: the state is taken from what HTCondor records about a job, never
from a policy interCEde sets on it. A finished job reads as FAILED when its
payload exited non-zero or was killed by a signal, from ``ExitCode`` and
``ExitBySignal``.

Ported from DIRAC's ``BatchSystems/Condor.py`` (``STATES_MAP`` and
``getCondorStatus``), with differences: DIRAC puts a payload that fails on
hold to recognise it, where the exit code is read here instead; the states
DIRAC leaves unmapped (6, 7) are mapped; and a removed job reports what it did
before it was removed, so a purged job keeps its result.
"""

from __future__ import annotations

import json
from collections.abc import Mapping, Sequence
from typing import Any

from intercede.models import JobID, JobStatus

# The ad attributes every status query asks for.
STATE_ATTRIBUTES: Sequence[str] = (
"ClusterId",
"ProcId",
"JobStatus",
"LastJobStatus",
"ExitCode",
"ExitBySignal",
"HoldReasonCode",
"HoldReason",
)

# HTCondor's JobStatus codes.
_IDLE, _RUNNING, _REMOVED, _COMPLETED, _HELD, _TRANSFERRING, _SUSPENDED = range(1, 8)

_STATES: Mapping[int, JobStatus] = {
_IDLE: JobStatus.WAITING,
_RUNNING: JobStatus.RUNNING,
_TRANSFERRING: JobStatus.RUNNING, # transferring output after running
_SUSPENDED: JobStatus.WAITING,
}

_HOLD_SPOOLING = 16 # HoldReasonCode: input files are still being spooled


def job_id(ad: Mapping[str, Any]) -> JobID:
"""Return the ``cluster.proc`` id of a job ad."""
return JobID(f"{ad['ClusterId']}.{ad['ProcId']}")


def job_status(ad: Mapping[str, Any]) -> JobStatus:
"""Map one job ad to the common status."""
code = ad.get("JobStatus")
if code == _COMPLETED:
return _result(ad)
if code == _REMOVED:
# purge() removes finished jobs to release them, after which the
# history only says "removed": the state it left tells the result.
return (
_result(ad) if ad.get("LastJobStatus") == _COMPLETED else JobStatus.ABORTED
)
if code == _HELD:
# Held by the site or by HTCondor, not by interCEde: the job is stuck,
# except while its inputs are still being spooled.
return (
JobStatus.WAITING
if ad.get("HoldReasonCode") == _HOLD_SPOOLING
else JobStatus.ABORTED
)
return (
_STATES.get(code, JobStatus.UNKNOWN)
if isinstance(code, int)
else JobStatus.UNKNOWN
)


def is_removed(ad: Mapping[str, Any]) -> bool:
"""Tell whether a job was removed, even while it is still in the queue.

``condor_rm`` only marks a job for removal: the schedd drops it from the
queue a moment later. In between it is already released.

Args:
ad: A job ad with ``JobStatus``.

Returns:
True when the job has been removed.

"""
return ad.get("JobStatus") == _REMOVED


def _result(ad: Mapping[str, Any]) -> JobStatus:
"""Tell DONE from FAILED for a job whose payload has finished."""
if ad.get("ExitBySignal") is True:
return JobStatus.FAILED
exit_code = ad.get("ExitCode")
return (
JobStatus.FAILED
if isinstance(exit_code, int) and exit_code != 0
else JobStatus.DONE
)


def hold_reason(ad: Mapping[str, Any]) -> str | None:
"""Return why a held job is held, if it is."""
reason = ad.get("HoldReason")
return reason if ad.get("JobStatus") == _HELD and isinstance(reason, str) else None


def parse_ads(raw: str) -> list[dict[str, Any]]:
"""Parse ``condor_q -json`` or ``condor_history -json`` output.

``condor_q`` prints nothing at all when no job matches, and
``condor_history`` prints an empty list.
"""
if not raw.strip():
return []
ads = json.loads(raw)
if not isinstance(ads, list) or not all(isinstance(ad, dict) for ad in ads):
raise ValueError("expected a JSON list of job ads")
return ads
Loading
Loading