Skip to content
Draft
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: 4 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[flake8]
max-line-length = 100
# Equivalent to allow-init-docstring, which we set on pydoclint.
extend-ignore = DOC301,F401
31 changes: 14 additions & 17 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,36 +1,33 @@
name: CI

on:
push:
branches: ["main"]
pull_request:
branches: ["main"]

on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.13"]
python-version:
- "3.13"
- "3.14"

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "0.10.7"
python-version: ${{ matrix.python-version }}
enable-cache: true

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ".[test,lint]"
- name: Sync dependencies
run: uv sync --locked --all-extras --dev

- name: Lint with pylint
run: pylint tind_client/
run: uv run pylint tind_client tests

- name: Type-check with mypy
run: mypy tind_client/
run: uv run mypy tind_client tests

- name: Run tests
run: pytest
run: uv run pytest
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Implemented TINDClient to wrap API interactions

### Changed
- N/A
- updates to workflow actions and pyproject.toml
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this truly a change if there hasn't been a release yet?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, good point. I'll move any changes up to the added category before marking ready for review.


### Deprecated
- N/A
Expand Down
42 changes: 19 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# python-tind-client

Python library for interacting with the [TIND ILS](https://tind.io) API.
Python library for interacting with the [TIND DA](https://tind.io) API.

## Requirements

Expand All @@ -23,11 +23,11 @@ pip install "python-tind-client[debug]" # debugpy

## Configuration

Create a `TINDClient` with explicit configuration values:
Create a `TINDClient` with optional configuration values:

- `api_key` (required): Your TIND API token
- `api_url` (required): Base URL of the TIND instance (e.g. `https://tind.example.edu`)
- `default_storage_dir` (optional): Default output directory for downloaded files
- `api_key` (optional): Your TIND API token. Falls back to the `TIND_API_KEY` environment variable.
- `api_url` (optional): Base URL of the TIND instance (e.g. `https://tind.example.edu`). Falls back to the `TIND_API_URL` environment variable.
Comment on lines +28 to +29
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is good, but just to be really clear, it might be nice to have an environment variables subsection like we do in the Willa readme for easy reference.

- `default_storage_dir` (optional): Default output directory for downloaded files. Defaults to `./tmp`.

## Usage

Expand All @@ -44,39 +44,35 @@ client = TINDClient(

### Fetch pyMARC metadata for a record
```python
record = client.fetch_metadata("12345")
record = client.fetch_metadata("116262")
print(record["245"]["a"]) # title
```

### Fetch file metadata for a record
```python
metadata = client.fetch_file_metadata("12345")
print(metadata[0]) # first file metadata dict
print(metadata[0]["url"]) # file download URL
metadata = client.fetch_file_metadata("116262")
print(metadata[0]) # first file metadata dict
print(metadata[0]["url"]) # file download URL
```

### Download a file
```python
# use metadata from previous example
path_to_download = client.fetch_file(metadata[0].url)
path_to_download = client.fetch_file(metadata[0]["url"])
```

## Functional fetch API

The functions in `tind_client.fetch` are available for direct use and now accept
explicit credentials instead of a client object.

### Search for records
```python
from tind_client.fetch import fetch_metadata
# return a list of record IDs matching a query
ids = client.fetch_ids_search("collection:'Disabled Students Program Photos'")

record = fetch_metadata(
"12345",
api_key="your-token",
api_url="https://tind.example.edu",
)
```
# return PyMARC records matching a query
records = client.fetch_search_metadata("collection:'Disabled Students Program Photos'")

For most use cases, prefer `TINDClient` methods as the primary interface.
# return raw XML or PyMARC records from a paginated search
xml_results = client.search("collection:'Disabled Students Program Photos'", result_format="xml")
pymarc_results = client.search("collection:'Disabled Students Program Photos'", result_format="pymarc")
```

## Running tests

Expand Down
19 changes: 14 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,19 @@ testpaths = ["tests"]

[tool.mypy]
python_version = "3.13"
strict = true
warn_unused_configs = true
warn_redundant_casts = true
warn_return_any = true
strict_equality = true
check_untyped_defs = true
disallow_subclassing_any = true
# We can't enable disallow_untyped_calls because of PyMARC.
disallow_untyped_calls = false
disallow_incomplete_defs = true
disallow_untyped_defs = true

[tool.pylint.main]
py-version = "3.13"
[tool.pydoclint]
allow-init-docstring = true
skip-checking-raises = true
style = "sphinx"

[tool.pylint.messages_control]
disable = ["C0114"] # missing-module-docstring — already covered by file-level docstrings
48 changes: 14 additions & 34 deletions tests/test_fetch.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
"""
Tests for tind_client.fetch.
Tests for TINDClient methods (fetch operations).
"""

import json

import pytest
import requests_mock as req_mock # noqa: F401 — activates the requests_mock fixture

from tind_client import TINDClient, fetch
from tind_client import TINDClient
from tind_client.errors import RecordNotFoundError, TINDError

BASE_URL = "https://tind.example.edu"
Expand All @@ -28,17 +28,15 @@ def test_fetch_metadata_success(
requests_mock.get(
f"{BASE_URL}/record/12345/", text=sample_marc_xml, status_code=200
)
record = fetch.fetch_metadata(
"12345", api_key=client.api_key, api_url=client.api_url
)
record = client.fetch_metadata("12345")
assert record["245"]["a"] == "Sample Title"


def test_fetch_metadata_404(requests_mock: req_mock.Mocker, client: TINDClient) -> None:
"""fetch_metadata raises RecordNotFoundError on HTTP 404."""
requests_mock.get(f"{BASE_URL}/record/99999/", text="", status_code=404)
with pytest.raises(RecordNotFoundError):
fetch.fetch_metadata("99999", api_key=client.api_key, api_url=client.api_url)
client.fetch_metadata("99999")


def test_fetch_metadata_empty_body(
Expand All @@ -47,7 +45,7 @@ def test_fetch_metadata_empty_body(
"""fetch_metadata raises RecordNotFoundError when the response body is empty."""
requests_mock.get(f"{BASE_URL}/record/11111/", text=" ", status_code=200)
with pytest.raises(RecordNotFoundError):
fetch.fetch_metadata("11111", api_key=client.api_key, api_url=client.api_url)
client.fetch_metadata("11111")


# ---------------------------------------------------------------------------
Expand All @@ -58,7 +56,7 @@ def test_fetch_metadata_empty_body(
def test_fetch_file_invalid_url(client: TINDClient) -> None:
"""fetch_file raises ValueError for non-TIND download URLs."""
with pytest.raises(ValueError):
fetch.fetch_file("https://not-a-tind-url.com/file.pdf", api_key=client.api_key)
client.fetch_file("https://not-a-tind-url.com/file.pdf")


def test_fetch_file_success(
Expand All @@ -74,7 +72,7 @@ def test_fetch_file_success(
status_code=200,
headers={"Content-Disposition": 'attachment; filename="document.pdf"'},
)
path = fetch.fetch_file(url, api_key=client.api_key, output_dir=str(tmp_path))
path = client.fetch_file(url, output_dir=str(tmp_path))
assert path.endswith("document.pdf")


Expand All @@ -87,7 +85,7 @@ def test_fetch_file_not_found(
url = f"{BASE_URL}/files/missing/download"
requests_mock.get(url, status_code=404)
with pytest.raises(RecordNotFoundError):
fetch.fetch_file(url, api_key=client.api_key, output_dir=str(tmp_path))
client.fetch_file(url, output_dir=str(tmp_path))


# ---------------------------------------------------------------------------
Expand All @@ -105,9 +103,7 @@ def test_fetch_file_metadata_success(
text=json.dumps(payload),
status_code=200,
)
result = fetch.fetch_file_metadata(
"12345", api_key=client.api_key, api_url=client.api_url
)
result = client.fetch_file_metadata("12345")
assert result[0]["name"] == "file.pdf"


Expand All @@ -121,9 +117,7 @@ def test_fetch_file_metadata_error(
status_code=404,
)
with pytest.raises(TINDError):
fetch.fetch_file_metadata(
"12345", api_key=client.api_key, api_url=client.api_url
)
client.fetch_file_metadata("12345")


# ---------------------------------------------------------------------------
Expand All @@ -140,9 +134,7 @@ def test_fetch_ids_search_success(
text=json.dumps({"hits": ["1", "2", "3"]}),
status_code=200,
)
ids = fetch.fetch_ids_search(
"title:python", api_key=client.api_key, api_url=client.api_url
)
ids = client.fetch_ids_search("title:python")
assert ids == ["1", "2", "3"]


Expand All @@ -156,9 +148,7 @@ def test_fetch_ids_search_error(
status_code=400,
)
with pytest.raises(TINDError):
fetch.fetch_ids_search(
"title:python", api_key=client.api_key, api_url=client.api_url
)
client.fetch_ids_search("title:python")


# ---------------------------------------------------------------------------
Expand All @@ -169,12 +159,7 @@ def test_fetch_ids_search_error(
def test_search_invalid_format(client: TINDClient) -> None:
"""search raises ValueError for unsupported result_format values."""
with pytest.raises(ValueError, match="Unexpected result format"):
fetch.search(
"title:test",
api_key=client.api_key,
api_url=client.api_url,
result_format="csv",
)
client.search("title:test", result_format="csv")


def test_search_returns_xml(
Expand All @@ -191,12 +176,7 @@ def test_search_returns_xml(

requests_mock.get(f"{BASE_URL}/search", text=wrapped, status_code=200)

results = fetch.search(
"title:sample",
api_key=client.api_key,
api_url=client.api_url,
result_format="xml",
)
results = client.search("title:sample", result_format="xml")
assert isinstance(results, list)
assert len(results) >= 1
assert requests_mock.call_count == 1
21 changes: 1 addition & 20 deletions tind_client/__init__.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,15 @@
"""
python-tind-client — Python library for interacting with the TIND ILS API.
python-tind-client — Python library for interacting with the TIND DA API.
"""

__copyright__ = "© 2026 The Regents of the University of California. MIT license."

from .client import TINDClient
from .api import tind_download, tind_get
from .errors import AuthorizationError, RecordNotFoundError, TINDError
from .fetch import (
fetch_file,
fetch_file_metadata,
fetch_ids_search,
fetch_marc_by_ids,
fetch_metadata,
fetch_search_metadata,
search,
)

__all__ = [
"TINDClient",
"tind_get",
"tind_download",
"AuthorizationError",
"RecordNotFoundError",
"TINDError",
"fetch_metadata",
"fetch_file",
"fetch_file_metadata",
"fetch_ids_search",
"fetch_marc_by_ids",
"fetch_search_metadata",
"search",
]
Loading