Skip to content
Open
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
128 changes: 126 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ name: Tests

on:
push:
branches: [ dtq ]
# dtq is the mainline; main is the CLARIN branch we are validating a merge
# into. feat/** and fix/** get CI before they open a PR.
branches: [ dtq, main, 'feat/**', 'fix/**' ]
pull_request:
workflow_dispatch: # manual validation of a merge candidate

permissions:
contents: read
Expand Down Expand Up @@ -37,4 +40,125 @@ jobs:
pip install -r requirements-test.txt

- name: Run tests
run: python -m pytest tests/ -v
# Coverage floor guards against the CLARIN surface silently sliding back
# toward the zero it had before test/clarin-usage-coverage landed.
run: >
python -m pytest tests/ -v
--cov=dspace_rest_client --cov-report=term-missing
--cov-fail-under=70

differential-contract:
# THE MERGE GATE. Run the CLARIN consumer-contract suite against BOTH the
# dtq implementation (this checkout) and the main implementation (swapped in
# from origin/main). A test green on both proves the merge preserves that
# behaviour. Tests that deliberately encode a dtq fix or behaviour change
# are marked @pytest.mark.dtq_only and are skipped on the main leg.
#
# Leg selection targets the four CLARIN test files explicitly: the DQ test
# modules import dtq-only symbols (ResourcePolicy, ...) at module scope, so
# collecting them against the main implementation would be an import error.
name: CLARIN contract vs ${{ matrix.impl }} impl
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
impl: [dtq, main]

steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # need origin/main to swap the implementation in

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.10"
cache: pip
cache-dependency-path: requirements-test.txt

- name: Install test deps
run: |
python -m pip install --upgrade pip
pip install -r requirements-test.txt

- name: Swap in the ${{ matrix.impl }} implementation
if: matrix.impl != 'dtq'
run: git checkout "origin/${{ matrix.impl }}" -- dspace_rest_client/

- name: Run CLARIN contract suite
run: |
FILES="tests/test_clarin_read.py tests/test_clarin_write.py \
tests/test_models_clarin.py tests/test_clarin_usage_contract.py"
if [ "${{ matrix.impl }}" = "dtq" ]; then
python -m pytest $FILES -v # full CLARIN surface, incl. dtq_only
else
python -m pytest $FILES -v -m "not dtq_only" # shared contract only
fi

# ---- Consumer smoke jobs -------------------------------------------------
# Turn "the API surface is a superset" into "the consumers still import/run".
# Gated on CONSUMER_READ_TOKEN: until that read-scoped token for the private
# consumer repos exists, the gate job reports enabled=false and consumer-smoke
# is skipped (a clean green), per plan §6.3.
check-consumer-token:
runs-on: ubuntu-latest
outputs:
enabled: ${{ steps.probe.outputs.enabled }}
steps:
- id: probe
env:
TOKEN: ${{ secrets.CONSUMER_READ_TOKEN }}
run: echo "enabled=${{ env.TOKEN != '' }}" >> "$GITHUB_OUTPUT"

consumer-smoke:
needs: check-consumer-token
if: needs.check-consumer-token.outputs.enabled == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- repo: DSpace-ISstag-integration
ref: main
smoke: python -m pytest tests/ mcp/tests/ -q
- repo: dspace-rest-test
ref: master
smoke: python -c "import dspace_rest_client.client"
- repo: dspace-import-clarin
ref: main
smoke: python -c "import dspace_rest_client.client"
# dspace-item-importer is intentionally omitted until its .gitmodules
# is repointed off the deleted `dtq-dev` branch (plan §6.3 / brief §5).
steps:
- uses: actions/checkout@v6
with:
path: candidate

- uses: actions/checkout@v6
with:
repository: dataquest-dev/${{ matrix.repo }}
ref: ${{ matrix.ref }}
token: ${{ secrets.CONSUMER_READ_TOKEN }}
submodules: recursive
path: consumer

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.10"

- name: Point the consumer submodule at this candidate commit
run: |
rm -rf consumer/libs/dspace-rest-python
cp -r candidate consumer/libs/dspace-rest-python

- name: Install and smoke
working-directory: consumer
run: |
python -m pip install --upgrade pip
pip install ./libs/dspace-rest-python
if [ -f requirements.lock ]; then pip install -r requirements.lock; fi
if [ -f libs/dspace-rest-python/requirements-test.txt ]; then
pip install -r libs/dspace-rest-python/requirements-test.txt
fi
${{ matrix.smoke }}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ __pypackages__/
env/
venv/
.idea/
.coverage
coverage.xml
9 changes: 9 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[pytest]
# Markers used to slice the suite for the branch-differential CI run
# (see .github/workflows/tests.yml :: differential-contract). A pytest.ini
# takes precedence over any [tool.pytest.ini_options] a future pyproject.toml
# might add, so the marker registry stays in one place regardless of packaging.
markers =
dtq_only: behaviour introduced on dtq; not expected to hold on the main implementation
clarin: exercises the CLARIN/UFAL surface (main-lineage consumers)
dq: exercises the DQ integration surface (DSpace-ISstag-integration)
2 changes: 2 additions & 0 deletions requirements-test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@
pytest>=7.0
requests-mock>=1.11
requests
# coverage floor is enforced in CI (tests.yml :: test job, --cov-fail-under)
pytest-cov>=4.0
66 changes: 66 additions & 0 deletions tests/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
COLLECTION_UUID = "22222222-2222-2222-2222-222222222222"
BITSTREAM_UUID = "9f54ef33-c454-4d8e-a5fe-79d8291045ba"
ANON_GROUP_UUID = "6ecfd145-3b7d-429e-ab31-ef6905a05763"
# Used by the CLARIN-side suites (eperson/group lookups, submit groups).
EPERSON_UUID = "33333333-3333-3333-3333-333333333333"
GROUP_UUID = "44444444-4444-4444-4444-444444444444"
BUNDLE_UUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"


def make_client(api_endpoint: str = API) -> DSpaceClient:
Expand Down Expand Up @@ -112,3 +116,65 @@ def policy_json(pid: int = 1, action: str = "READ", group_name: str = "Anonymous
if start_date is not None:
d["startDate"] = start_date
return d


def raw_policy_json(pid: int = 1, action: str = "READ", **extra) -> dict:
"""A resource policy in the *raw* shape the CLARIN ``get_resource_policy``
returns (a plain dict the caller subscripts as ``["id"]``), not a model."""
d = {"id": pid, "action": action, "type": "resourcepolicy"}
d.update(extra)
return d


def group_json(uuid: str = GROUP_UUID, name: str = "Anonymous",
permanent: bool = False, **extra) -> dict:
d = {"uuid": uuid, "name": name, "type": "group", "permanent": permanent}
d.update(extra)
return d


def user_json(uuid: str = EPERSON_UUID, email: str = "tester@dspace.test",
name: str = "Tester", netid: str = None, can_login: bool = True,
**extra) -> dict:
d = {"uuid": uuid, "type": "eperson", "name": name, "email": email,
"canLogIn": can_login}
if netid is not None:
d["netid"] = netid
d.update(extra)
return d


def label_json(lid: int = 10, label: str = "PUB", title: str = "Publicly available",
icon: str = "pub.png", extended: bool = False) -> dict:
return {"id": lid, "label": label, "title": title, "icon": icon,
"extended": extended}


def license_json(lid: int = 1, name: str = "CC-BY",
definition: str = "https://creativecommons.org/licenses/by/4.0/",
confirmation: int = 1, required_info: str = "SEND_TOKEN",
label: dict = None, extended: list = None) -> dict:
d = {"id": lid, "name": name, "definition": definition,
"confirmation": confirmation, "requiredInfo": required_info}
if label is not None:
d["clarinLicenseLabel"] = label
if extended is not None:
d["extendedClarinLicenseLabels"] = extended
return d


def clarin_allowance_json(aid: int = 1, **extra) -> dict:
d = {"id": aid, "type": "clarinlruallowance"}
d.update(extra)
return d


def search_envelope(items: list) -> dict:
"""The ``discover/search/objects`` HAL envelope, wrapping each item as an
``indexableObject``. Used by ``get_items_from_collection`` and
``search_objects``.
"""
return {"_embedded": {"searchResult": {
"page": {"totalElements": len(items)},
"_embedded": {"objects": [
{"_embedded": {"indexableObject": it}} for it in items]}}}}
Loading