# The Python client

`dalicc` is the Python client for the DALICC API, and a `dalicc` command line tool comes
with it. This is the complete reference for both, written so that anybody calling DALICC
from Python or from a shell never has to open the DALICC source code.

Part of the [documentation index](../README.md#documentation).

*Contents:* [1. Install](#1-install) · [2. Authenticate](#2-authenticate) ·
[3. Quick start](#3-quick-start) · [4. The client](#4-the-client) ·
[5. Identifiers](#5-identifiers-bare-ids-and-full-iris) ·
[6. The license library](#6-the-license-library) ·
[7. Dependency graphs](#7-dependency-graphs) · [8. Reasoning](#8-reasoning) ·
[9. Composing](#9-composing) · [10. Translation](#10-the-translation-assistant) ·
[11. Account](#11-the-account) · [12. GitHub](#12-the-github-dependency-checker) ·
[13. What comes back](#13-what-comes-back) · [14. Errors](#14-errors) ·
[15. Rate limits](#15-rate-limits-and-quotas) · [16. Async](#16-asynchronous-use) ·
[17. Testing](#17-testing) · [18. Command line](#18-the-command-line-tool) ·
[19. Versioning](#19-versioning) ·
[20. History, releases and verification](#20-history-releases-and-verification) ·
[21. License](#21-license)

The package is developed in [`sdk/`](../sdk) and published from there to
[github.com/dalicc/python-sdk](https://github.com/dalicc/python-sdk), which is the
client's own public repository. The HTTP contract it speaks is
[API.md](API.md); what a license, a draft, a submission or a translation *is* belongs
to [USERS.md](USERS.md). Nothing this client returns is legal advice.

Working on the client itself rather than with it? Its test suite, its distributions
and the checks that run on them are in
[DEVELOPMENT.md](DEVELOPMENT.md#12-the-python-clients-tests-and-build).

---

## 1. Install

```bash
pip install --pre dalicc          # the release candidate, 2.0.0rc2
pip install --pre "dalicc[rdf]"   # with rdflib, for hashing a file you hold
pip install dalicc                # from 2.0.0 on
pip install -e ./sdk              # working on the client itself
```

Python 3.10 or newer. The only runtime dependency is `httpx`, below 1.0; the `rdf` extra
adds `rdflib` for `dalicc.hash_of` and `dalicc hash`
([section 20](#20-history-releases-and-verification)).

The package is on PyPI as `dalicc`, published as release candidates until `2.0.0`;
this version is `2.0.0rc2`. pip passes over a candidate unless `--pre` asks for it,
so the first line is the one to use until `2.0.0` is published and the third line is
the one from then on. `2.0.0` follows the deployment of the service, and the same code is in
the client's public repository at
[github.com/dalicc/python-sdk](https://github.com/dalicc/python-sdk), which is a mirror
of `sdk/`. The third line is for a checkout of the DALICC service repository, where the
client is developed and where its test suite runs against the application.

## 2. Authenticate

Searching, reading and reasoning need no credentials. Composing, your own drafts, your
own dependency graphs and the translation assistant do.

Create a personal API token on `/account/tokens` (see [USERS.md](USERS.md)). It looks
like `dalicc_` followed by 40 characters and is shown once, when it is created.

```python
api = Client(api_key="dalicc_...")
```

or leave it out and put it in the environment:

```bash
export DALICC_API_KEY=dalicc_...
export DALICC_BASE_URL=https://<your 2026 instance>    # optional
```

`Client()` then picks both up. The default address, `https://api.dalicc.net`, still
serves the earlier generation of the API: the ten operations of
[API.md section 2](API.md#2-the-documented-contract) answer there and every other
method gets `404`, until that address moves to the 2026 generation later. Point
`DALICC_BASE_URL` at a 2026 instance to use the rest. `Client(api_key="")` stays anonymous even when the
variable is set. The token is sent as `Authorization: Bearer ...` and never appears in
a `repr()` or in a log line the client writes.

A call that needs a token and has none raises `AuthError` (401). A token that may not
see a particular object gives `ForbiddenError` (403).

## 3. Quick start

```python
from dalicc import Client

with Client() as api:
    for license in api.licenses.list(keyword="apache"):
        print(license.id, license.title)

    verdict = api.reasoning.compatibility(["MIT", "GPL-3.0-only"])
    print("conflicts" if verdict.has_conflicts else "no conflicts")

    document = api.licenses.get("MIT", format="ttl")
    print(document[:200])
```

The client holds a connection pool, so use it as a context manager or call
`api.close()` when you are done.

## 4. The client

```python
Client(
    base_url="https://api.dalicc.net",   # or DALICC_BASE_URL
    api_key=None,                        # or DALICC_API_KEY
    timeout=DEFAULT_TIMEOUT,             # 10 s to connect, 75 s to read; a float or httpx.Timeout
    retries=2,                           # connection attempts, and the limit of the two retries below
    user_agent=None,                     # default: dalicc-python/<version>
    transport=None,                      # an httpx transport, for tests
    retry_on_rate_limit=False,           # wait out a 429 and try again
    retry_on_server_error=False,         # send a read again after a 502, 503, 504 or a broken connection
    max_wait=60.0,                       # the longest wait it will accept
    license_base_uri="https://dalicc.net/licenselibrary/",
    headers=None,                        # extra headers on every request
)
```

`retries` covers a connection that was never established. A request that reached the
service is never repeated on its own: a `POST` that timed out may well have been
carried out, and repeating it would compose a second license.

**Timeouts.** The service gives the reasoner 60 seconds for one check and answers `504`
after that, so the client waits 75 seconds for an answer by default: a client that gave
up at 30 would drop a check the service is still running and still charges for. A float
sets connecting, sending and reading at once, as before; `httpx.Timeout` sets them one
by one. `reasoning.compatibility` and `reasoning.consistency` take a `timeout` of their
own for one call, and so does `request`.

**Retrying reads.** With `retry_on_server_error=True`, a `GET` or `HEAD` that failed
with `502`, `503` or `504`, or whose connection broke or timed out, is sent again, at
most `retries` times, after the `Retry-After` the service sent or a pause that doubles
from half a second, and never after a pause longer than `max_wait`. A `POST` is never
sent again this way. `retry_on_rate_limit` is the other retry and works for every
method, because a `429` means the service refused the request without carrying it out.

Ten groups of methods hang off the client, one per group of operations:
`api.licenses`, `api.dependency_graphs`, `api.vocabulary`, `api.reasoning`,
`api.composing`, `api.translate`, `api.account`, `api.github`, `api.releases` and
`api.users`. The
versions, the content hashes, the releases and the change feed come from version 2 of
the API and are in [section 20](#20-history-releases-and-verification). More is on the
client itself:

```python
api.discovery()                           # GET /v2, what the deployment offers
api.healthz()                             # GET /healthz
api.json("/licenselibrary/spdx-mapping")  # GET any endpoint, as a JSON object
api.request("GET", "/licenselibrary/actions", params={"duties_only": "true"})
api.last_rate_limit                       # the X-RateLimit-* of the last answer
```

`request` is the escape hatch every method goes through: it returns the finished reply
(`status_code`, `headers`, `payload`, `text`, `media_type`) and raises the same
exceptions as every other method, so an endpoint the SDK does not wrap yet is still
one line away.

## 5. Identifiers: bare ids and full IRIs

The license library is addressed by bare identifier and the compatibility check by
full IRI. That asymmetry is part of the frozen contract
([BACKWARD_COMPATIBILITY.md](BACKWARD_COMPATIBILITY.md)), and the client hides it:
every method takes either spelling, and a `LicenseRef` from a listing can be handed
straight to any of them.

```python
from dalicc import license_id, license_uri

license_id("https://dalicc.net/licenselibrary/MIT")   # 'MIT'
license_uri("MIT")                                    # 'https://dalicc.net/licenselibrary/MIT'

api.licenses.get("MIT")                               # both work
api.licenses.get("https://dalicc.net/licenselibrary/MIT")
api.reasoning.compatibility(["MIT", "Apache-2.0"])    # sent as IRIs
```

A URI that is **not** in the library is passed on whole rather than cut down to its
last path segment, so the service refuses it with the `422` it deserves instead of
answering about an unrelated record.

## 6. The license library

| Method | Endpoint |
|---|---|
| `licenses.list(keyword=None, skip=None, limit=None, ports="include")` | `GET /licenselibrary/list` |
| `licenses.iter_list(keyword=None, *, page_size=100, max_items=None, ports="include")` | the same, page by page |
| `licenses.get(license, format="json-ld", raw=False)` | `GET /licenselibrary/license/{id}`, in any of its four formats |
| `licenses.text(license, version=None)` | the same with `format=text`: the license in words |
| `licenses.resolve(license)` | the same, not followed: where an SPDX or retired identifier leads |
| `licenses.faceted_search(target, actions, duties, license_wide_duties, skip, limit, ports)` | `POST /licenselibrary/facetedsearch` |
| `licenses.compare(references)` | `GET /licenselibrary/compare`, a `Comparison` |
| `licenses.actions(duties_only=False, include_reasoning_terms=False)` | `GET /licenselibrary/actions` |
| `licenses.spdx(spdx_id)` | `GET /licenselibrary/spdx/{spdx_id}` |
| `licenses.spdx_mapping()` | `GET /licenselibrary/spdx-mapping` |
| `licenses.review(license)` | `GET /licenselibrary/review/{id}` |
| `licenses.versions(license)` | `GET /licenselibrary/license/{id}/versions` |
| `licenses.version(license, n, format="json-ld", raw=False)` | `.../versions/{n}` |
| `licenses.changelog(license)` | `.../changelog`, a `ChangeLog` |
| `licenses.history(skip=0, limit=50)` | `GET /licenselibrary/history`, a `HistoryPage` |
| `licenses.iter_history(*, page_size=50, max_items=None)` | the same, page by page |
| `licenses.annotator_sidecar(license, asset=None, attribution=None)` | `GET /license-library/{id}/license.json` |
| `licenses.badge_svg(license)` | `GET /license-library/{id}/badge.svg` |
| `licenses.suggest(q)` | `GET /license-library/suggest?q=` |
| `users.profile(user_id, format="ttl")` | `GET /users/{id}?format=`, a publisher's profile as RDF |

### Listing

```python
listing = api.licenses.list(keyword="creative commons")
listing.ids                 # ['CC-BY-4.0', ...]
listing[0].title            # 'Creative Commons Attribution 4.0 International'
listing.envelope            # the raw SPARQL-JSON envelope, untouched
len(listing)
```

`skip` and `limit` are sent **only when you pass them**: leave them out and the
service answers with every row, which is what it has always done. Paging is therefore
a way to be gentle with a slow connection, not a way to get more rows than one call
would give. `ports` is `include` (every record), `exclude` (parents only) or `only`
(the jurisdiction ports).

```python
for ref in api.licenses.iter_list(page_size=50, max_items=200):
    print(ref.id)
```

### One document

```python
api.licenses.get("MIT")                     # dict, the JSON-LD document
api.licenses.get("MIT", format="ttl")       # str, real Turtle
api.licenses.get("MIT", format="rdf-xml")   # str, real RDF/XML
api.licenses.get("MIT", format="text")      # str, the license written out in words
```

The service has a historical quirk here: `?format=ttl` answers with the Turtle
**JSON-encoded into a string** and `Content-Type: application/json`. The client
negotiates on `Accept` instead, which gets the real serialisation with the real media
type. `raw=True` takes the historical path; the text is the same either way. A format
that does not exist raises `ValueError` before the request goes out.

`format="text"` is not a serialisation of the graph: it is the license in words, and
it needs no token and no provider. [Section 10](#10-the-translation-assistant) has the
rest of it. `licenses.text(license, version=None)` is the same call with a name of its
own, and it works for a composed license and for an archived version too.

**SPDX and retired identifiers.** `get` accepts an SPDX identifier the library knows
and the identifiers retired on 2023-04-24: the service redirects to the record that
declares or replaced it and the client follows. `resolve` says where an identifier
leads without downloading the record:

```python
api.licenses.resolve("ODbL-1.0")     # LicenseResolution(id='OdcOpenDatabaseLicense', how='spdx', ...)
api.licenses.resolve("AGPL")         # how='retired', id='AGPL-3.0'
api.licenses.resolve("MIT").how      # 'record'
```

A retired identifier that nothing replaced raises the `410` error of
[section 14](#14-errors).

### Faceted search

```python
listing = api.licenses.faceted_search(
    target={"software": "yes", "dataset": "no"},
    actions={"commercial_use": "permitted", "modify": "permitted"},
    duties={"distribute_duty_attribution": "required"},
    license_wide_duties={"share_alike": "na"},
)
```

Name only the facets you care about; the rest are filled with the neutral value
(`yes` for an asset type, `na` for an action or a duty). Action values are
`permitted`, `na` or `prohibited`; duty values are `required` or `na`. An unknown
facet name raises `ValueError` before the request goes out, and the message lists the
known ones. The full facet list is in
[API.md section 2.3](API.md#23-post-licenselibraryfacetedsearch); `faceted_search_body`
is exported if you want to build the body yourself.

`limit` is a string on this endpoint and an integer on `/list`. The asymmetry is in
the published schema, so the client sends what the schema says and takes an integer
from you either way.

### The rest

```python
comparison = api.licenses.compare(["MIT", "Apache-2.0", "GPL-3.0-only"])  # 2 to 20 ids
[row["label"] for row in comparison.differing]   # the terms they disagree on
api.licenses.actions(duties_only=True)
api.licenses.spdx("Apache-2.0")              # {'spdx_id', 'id', 'uri'}
api.licenses.spdx("GPL-2.0-only WITH Classpath-exception-2.0")   # encoded for you
api.licenses.review("MIT")                   # the content-review record
api.licenses.annotator_sidecar("MIT", asset="https://example.org/data.csv")
api.licenses.badge_svg("MIT")                # the README badge, as SVG text

versions = api.licenses.versions("Apache-2.0")
versions.current                             # 2
[(v.version, v.date, v.summary) for v in versions]

api.licenses.version("Apache-2.0", 1, format="ttl")
log = api.licenses.changelog("Apache-2.0")
[(entry.version, entry.summary, len(entry.changes)) for entry in log.entries]

for record in api.licenses.iter_history(page_size=50):
    print(record["id"], record["version"], record["last_change"])

api.users.profile("<id>")                    # Turtle: foaf:name and one dct:creator per license
api.licenses.suggest("apach")                # the search box's titles
```

`compare`, `changelog` and `history` answer a `Comparison`, a `ChangeLog` and a
`HistoryPage`. Each is still the `dict` the service sent, so `log["entries"]` keeps
working; the properties (`comparison.rows`, `comparison.differing`,
`comparison.unknown_ids`, `log.entries` with typed `changes`, `page.total`, `page.rows`)
read it into typed rows.

Note the path behind the last two of the first block: the annotator sidecar and the
badge live on the website, at `/license-library/`, not on the API prefix
`/licenselibrary/`.

## 7. Dependency graphs

A dependency graph is the set of axioms the reasoner draws its conclusions from
(`odrl:includedIn`, `odrl:implies`, `owl:sameAs`, `dalicc:contradicts`). The
deployment has one it reasons with by default; accounts can own their own.

| Method | Endpoint |
|---|---|
| `dependency_graphs.list_axioms(graph=None)` | `GET /dependencygraph/list` |
| `dependency_graphs.graphs()` | `GET /dependencygraph/graphs` |
| `dependency_graphs.mine()` | `GET /dependencygraph/mine` (token) |
| `dependency_graphs.create(axioms=None, turtle=None, publish=False, title="", description="")` | `POST /dependencygraph` (token) |
| `dependency_graphs.versions(graph=None)` | `.../versions` |
| `dependency_graphs.version(n, graph=None)` | `.../versions/{n}`, as Turtle |
| `dependency_graphs.changelog(graph=None)` | `.../changelog`, a `ChangeLog` |
| `dependency_graphs.rules(graph=None)` | `GET /dependencygraph/rules` |
| `dependency_graphs.compare(left=None, right=None)` | `GET /dependencygraph/compare` |
| `dependency_graphs.turtle(graph=None, version=None)` | `GET /dependency-graph.ttl`, or `/dependency-graph/{id}/download` |

```python
for axiom in api.dependency_graphs.list_axioms():
    print(axiom.subject, axiom.predicate, axiom.object)

api.dependency_graphs.graphs()      # the published core graphs, plus yours with a token
api.dependency_graphs.mine()        # your own, drafts included

result = api.dependency_graphs.create(
    axioms=[
        ("cc:Attribution", "odrl:implies", "cc:Notice"),
        {"subject": "odrl:modify", "relation": "odrl:includedIn", "object": "odrl:derive"},
    ],
    title="Attribution implies a notice",
    publish=False,          # True publishes it, unlisted
)
result.id, result.status, result.axioms, result.warnings
```

An axiom is a mapping with `subject`, `relation` and `object`, or a three-item
sequence in that order; IRIs and compact spellings both work. Name either `axioms` or
`turtle`, not both and not neither, or the client raises `ValueError`. Both sides have
to be actions of the DALICC vocabulary and the relation one of the four above; a cycle
is reported in `warnings` rather than refused. A published graph is **unlisted**: the
identifier is the only way back to it.

Leaving `graph` out of the three history methods asks about the graph the deployment
reasons with; naming one asks about that graph.

`create` keeps a draft unless `publish=True` is passed. The API itself publishes when
`publish` is left out; the client does not, so that a script never publishes a graph by
accident.

```python
rules = api.dependency_graphs.rules("dg_eu")["default_rules"]
[(rule["action_label"], rule["outcome_label"], rule["status_label"]) for rule in rules]

diff = api.dependency_graphs.compare("dg_default", "dg_eu")
diff["summary"]              # {'axioms': {'added': 0, ...}, 'rules': {'added': 9, ...}}
diff["same"]

api.dependency_graphs.turtle()                 # the current core graph, as the site offers it
api.dependency_graphs.turtle("dg_eu", version=1)
```

A default rule says what applies to an action a license is silent about; a rule whose
status is *Proposed* is a proposal for a legal reviewer, not an answer.

### The vocabulary

| Method | Endpoint |
|---|---|
| `vocabulary.get(format="ttl")` | `GET /ns?format=` |
| `vocabulary.term(term, format="ttl")` | `GET /ns/{term}?format=` |
| `vocabulary.versions()` | `GET /ns/versions` |
| `vocabulary.version(n)` | `GET /ns/versions/{n}`, as Turtle with its comments |
| `vocabulary.changelog()` | `GET /ns/changelog`, a `ChangeLog` |

```python
api.vocabulary.get()                        # the whole vocabulary, Turtle
api.vocabulary.term("promote", format="json-ld")
api.vocabulary.versions().current
api.vocabulary.version(1)
```

`format` takes `ttl`, `json-ld`, `rdf-xml` and `nt`, and every answer is the text with
its media type. The versions of the vocabulary in
version 2 of the API, with their content hashes, are in
[section 20](#20-history-releases-and-verification).

## 8. Reasoning

### `reasoning.compatibility(licenses, dependency_graph=None, timeout=None)`

```python
verdict = api.reasoning.compatibility(["Apache-2.0", "GPL-3.0-only"])
verdict.has_conflicts            # True
for conflict in verdict.conflicts:
    print(conflict.kind, conflict.reason)
    print(" ", *conflict.statement_1)
    print(" ", *conflict.statement_2)
```

`direct` conflicts come from the licenses themselves, `derived` ones from the
dependency graph. The service sends both as objects whose keys are stringified
integers; the client reads them into lists and keeps the original in `verdict.raw`.

`dependency_graph` chooses the axioms, by identifier or IRI. Leave it out and the
deployment's own graph is used; a graph belonging to an account needs that account's
token. When a non-default graph was used, `verdict.dependency_graph` names it.

The operation knows no target license. To ask about the license a combined work would
carry, put it in the list and read the conflicts in which it is one side, the way the
GitHub checker's `target` does ([section 12](#12-the-github-dependency-checker)).

### `reasoning.consistency(license=None, composer_input=None, dependency_graph=None, timeout=None)`

```python
api.reasoning.consistency("CC-BY-4.0").consistent          # True

verdict = api.reasoning.consistency(composer_input={
    "title": "Draft terms",
    "permissions": [{"action": "odrl:distribute", "duties": []}],
    "prohibitions": ["odrl:distribute"],
})
verdict.consistent                                          # False
verdict.conflicts[0].reason
```

Name either an existing license or a document that has not been published yet, not
both, or the client raises `ValueError`.

## 9. Composing

Every call here needs a token, and the license belongs to the account behind it.

| Method | Endpoint |
|---|---|
| `composing.create(document, publish=True)` | `POST /licenselibrary/composer` |
| `composing.mine()` | `GET /licenselibrary/mine` |
| `composing.revisions(license)` | `GET /licenselibrary/mine/{id}/revisions` |
| `composing.deprecate(license, reason=None, replaced_by=None)` | `POST /licenselibrary/mine/{id}/deprecate` |
| `composing.withdraw(license, reason=None, replaced_by=None)` | the same: the word the website uses |

```python
result = api.composing.create({
    "title": "Terms of use for the sample dataset",
    "licensor": "A Company",
    "targets": ["dataset"],
    "permissions": [
        {"action": "odrl:distribute", "duties": ["cc:Attribution"]},
        {"action": "odrl:reproduce", "duties": []},
    ],
    "prohibitions": ["dalicc:promote"],
    "duties": ["cc:ShareAlike"],
})
result.id, result.uri, result.status, result.version
```

`document` is a mapping or one `odrl:Set` as a Turtle string
(`api.composing.create(open("license.ttl").read())`). `publish=False` stores a private
draft instead, to be finished at `/license-composer?draft=<id>` on the website. The
field list the service accepts is in
[API.md section 2.4](API.md#24-post-licenselibrarycomposer).

A composed license is **unlisted**: it appears in no list, no search result and no
suggestion, so keep the identifier. Contradicting terms raise `ConflictError`, and
nothing is written:

```python
from dalicc import ConflictError

try:
    api.composing.create(document)
except ConflictError as conflict:
    for entry in conflict.conflicts:
        print(entry["reason"])
```

Afterwards:

```python
api.composing.mine()                       # every license of this account
api.composing.revisions(license_id)        # every saved state of one of them
api.composing.deprecate(old_id, reason="Superseded", replaced_by=new_id)
```

Nothing is ever deleted: a deprecated license keeps resolving at its own address and
gains `owl:deprecated`, the date and a pointer to its successor.

## 10. The translation assistant

The assistant reads the text of a license and proposes the DALICC model for it. What
the proposal is and how to read it is
[section 19 of USERS.md](USERS.md#19-text-to-license); the endpoint behind these methods
is [API.md section 8](API.md#8-the-translation-assistant). It needs a token and it needs
consent.

| Method | What it does |
|---|---|
| `translate.text(text, title=None, save_draft=False, consent=False, wait=True, timeout=1800.0, poll_interval=5.0)` | Translate a text, waiting out a long one |
| `translate.file(path, title=None, save_draft=False, consent=False, encoding="utf-8", wait=True, timeout=1800.0)` | The same, reading the text from a file and using its name as the title |
| `translate.job(job_id, save_draft=False)` | Ask once how a run started earlier is getting on |
| `translate.wait_for(job_id, save_draft=False, timeout=1800.0, poll_interval=5.0)` | Poll until it is over |

```python
result = api.translate.text(
    open("terms.txt").read(),
    title="Terms of use",
    consent=True,          # required, every time
)
```

**The consent is not a formality.** The text is sent to an external model provider for
processing, so the client refuses with `ValueError` before anything leaves your
machine when `consent=True` was not passed. Do not submit confidential texts.

```python
for statement in result.statements:
    print(statement.kind, statement.term, statement.confidence)
    print("  evidence:", statement.evidence)

for clause in result.unmodelled:
    print("not expressible:", clause.proposed_term, clause.clause_quote)

result.license          # the proposal, in the shape composing.create() takes
result.warnings         # contradictions above all
result.conflicts        # what the consistency check found
result.partial          # True when a part of a long text could not be read
result.parts_failed     # which parts those were
```

Nothing is published. With `save_draft=True` the proposal is stored as a private draft
owned by your account and `result.draft_id` names it.

### A long license

A text that needs more than one part is not answered in the request: the service
starts the work, answers `202` with a job id and the client polls it. That is the
default and it is invisible, `translate.text()` simply takes longer:

```python
result = api.translate.text(gpl_text, consent=True, timeout=1800, poll_interval=5)
```

`timeout` bounds the waiting (half an hour by default) and raises `TimeoutError` when
the run is still going. The run is not cancelled by that: it is the client that
stopped waiting, and `api.translate.job(job_id)` still collects it. The poll interval
follows the service's own `Retry-After` when it sends one and never goes below a
second.

To start one and come back later, ask not to wait:

```python
started = api.translate.text(gpl_text, consent=True, wait=False)
started.pending          # True: there is no proposal yet
started.job_id           # what collects it

progress = api.translate.job(started.job_id)
progress.status          # "queued", "running", "done", "failed"
progress.part_current, progress.parts_total

result = api.translate.wait_for(started.job_id)   # blocks until it is over
```

A run that failed comes back with `failed` true and the reason in `message`, because
the failure is the result of the run rather than an error of the request that asked
about it. Finished runs are kept for two hours and then purged.

The quota block says what is left:

```python
quota = result.quota
quota.provider_remaining_requests      # what the provider has left
quota.provider_reset_in                # seconds until that window restarts
quota.user_remaining_today             # what your account has left today
quota.global_remaining_today           # what the deployment has left today
```

An exhausted quota is a `RateLimitedError` with the countdown on it; a deployment with
no assistant configured answers `503` (`ServerError`); a text longer than the
assistant reads is `PayloadTooLarge` (413).

### The other direction: a model written out as a license text

`narrate` takes a license of the library and gives you its terms as prose:

```python
narration = api.translate.narrate("Apache-2.0")
print(narration.text)                  # the whole document, sections numbered
narration.title, narration.preamble, narration.closing
for section in narration.sections:
    section.heading, section.text, section.statements
```

`narrate_model` reads a model you hold instead, as Turtle or JSON-LD, which is how an
unlisted composed license or one that never left your machine is read:

```python
turtle = str(api.licenses.get("Apache-2.0", format="ttl"))
narration = api.translate.narrate_model(turtle)
```

Both take `without_names=True`, which replaces the creator, licensor and publisher names
by "the licensor" before the model is sent. Use it for a license somebody composed; a
curated record names the organisation that published the license, which is a fact about
the license rather than about a person.

Two fields are worth reading before the prose:

```python
narration.produced_by                  # "provider", or "fallback"
narration.provider                     # which provider wrote it, empty for the fallback
narration.complete                     # did the text cover the whole model?
narration.covered, narration.statements
narration.missing                      # the statements no section named
narration.coverage_note                # the sentence for them, empty when complete
```

`produced_by` is `fallback` when the assistant is not configured on the deployment, the
daily allowance is used up or the provider did not answer: the text is then written from
the DALICC vocabulary alone, which is plainer and always covers the model in full. There
is always a text, so neither method raises for a provider that is away.

A narration is counted in an allowance of its own, so writing a license text never
spends a translation and a translation never spends a license text. By default the two
allowances are the same size, ten a day. The deterministic reading on its own costs
nothing and needs no token:

```python
document = api.licenses.get("Apache-2.0", format="text")
```

`404` for an identifier nothing resolves to, `422` for a model that cannot be read,
`401` without a token.

## 11. The account

The service publishes the per-account limits in the `X-RateLimit-*` headers of every
answer to a token request rather than in an endpoint of its own, because a rate-limit
answer must never tell a caller that a token exists. The client reads them from the
last answer it saw:

```python
limits = api.account.limits()
limits.limit, limits.remaining, limits.reset_at, limits.reset_in
api.account.limits_known()             # has such an answer been seen yet?
api.last_rate_limit                    # the same object, after any call
```

`limits()` makes one cheap authenticated call when it has not seen such an answer yet,
so it raises `AuthError` without a token. Anonymous requests carry no such headers and
the result is then a status whose `known` is `False`.

The four windows with their usage, and the last-used time of every token, are on the
`/account/tokens` page of the website, behind a session; there is no JSON endpoint for
them. `api.account.licenses()` is the same listing as `api.composing.mine()`.

## 12. The GitHub dependency checker

```python
api.github.dependencies("psf", "requests")     # the libraries.io document, verbatim
report = api.github.check("psf", "requests")   # licenses mapped onto DALICC, then checked
report["dependencies"][0]["dalicc_id"]
report["compatibilitycheck"]

api.github.check("https://github.com/psf/requests")   # or address it by URL
```

Leaving `name` out sends the first argument as `?github_url=`, which accepts
`https://github.com/owner/name`, `github.com/owner/name` and the bare `owner/name`;
any other host is a `400`. A deployment without a libraries.io key answers `503`.

`target` is the license the combined work would carry
([API.md section 2.7](API.md#27-to-210-get-githublicensechecker)), in both shapes:

```python
report = api.github.check("psf", "requests", target="GPL-3.0-only")
report["target"]              # {'id': 'GPL-3.0-only', 'uri': ..., 'title': ...}
report["target_conflicts"]    # the conflicts in which the target is one side
api.github.check("https://github.com/psf/requests", target="MIT")
```

Without `target` the answer is exactly what it was. A target that is not a license
identifier is a `422`, one the library does not know a `404`.

## 13. What comes back

Every result carries the parsed body in `.raw` and the response headers in
`.headers`, so a field this client does not model yet is never lost:

```python
listing = api.licenses.list()
listing.raw                 # the SPARQL-JSON envelope
listing.envelope            # the same, named for what it is
listing.headers["x-request-id"]
```

A JSON body the client does not model is a `JsonDocument`, which is a `dict` subclass,
so index it as usual. `ChangeLog`, `HistoryPage` and `Comparison` are `JsonDocument`
subclasses with typed properties on top. A text body (Turtle, RDF/XML, SVG) is a `TextDocument`, which is
a `str` subclass with `.media_type` on it.

The typed results are `LicenseListing`, `LicenseRef`, `CompatibilityResult`,
`ConsistencyResult`, `Conflict`, `ComposeResult`, `MineListing`, `MineEntry`,
`RevisionListing`, `Revision`, `VersionListing`, `VersionEntry`, `AxiomList`, `Axiom`,
`GraphList`, `GraphEntry`, `GraphResult`, `TranslationResult`,
`TranslationStatement`, `UnmodelledClause`, `TranslationQuota`, `Narration`,
`NarrationSection`, `RateLimitStatus`, `LicenseResolution`, `ChangeLogEntry`,
`ChangeLogChange` and `HistoryRecord`, and from version 2 `VersionInfo`, `Change`,
`ChangePage`, `ReleaseInfo`, `Discovery` and `VerifyResult`.
They are plain dataclasses; `dataclasses.asdict` works on them. The listings iterate
and have a length.

Two helpers are exported for endpoints the client does not wrap: `faceted_search_body`
builds the four-part search body, and `paginate(fetch, skip=0, limit=100,
max_items=None)` walks any `skip`/`limit` endpoint until a page comes back short.

## 14. Errors

| Status | Exception | Typical cause |
|---|---|---|
| 400 | `BadRequestError` | a GitHub URL that is not a `github.com` repository |
| 401 | `AuthError` | no token, or a revoked one |
| 403 | `ForbiddenError` | a token that may not see this object |
| 404 | `NotFoundError` | an identifier nothing resolves to |
| 405 | `MethodNotAllowedError` | a path reached with the wrong method; `allow` names the right ones |
| 406 | `NotAcceptableError` | version 2: an `Accept` nothing can answer; `available` lists what can |
| 409 | `ConflictError` | the composed license contradicts itself |
| 410 | `GoneError` | an identifier retired in 2023 that nothing replaced |
| 413 | `PayloadTooLarge` | a text longer than the service reads |
| 415 | `UnsupportedMediaTypeError` | a `format=` the record address does not serve |
| 422 | `ValidationError` | a body or a parameter the service refused |
| 429 | `RateLimitedError` | a rate limit or a daily quota |
| 5xx | `ServerError` | the service, or an upstream of it, failed |
| none | `TransportError` | no connection, no route, a timeout |

All of them derive from `DaliccError` and carry `status_code`, `detail` (the sentence
the service sent), `raw` (the parsed body), `headers` and `url`. An error of a version 2
route is an RFC 9457 problem document; it raises the same class for the same status and
adds `problem_type`, the `type` URI that names the situation
(`...#problem-version-not-found`), which is `None` for a version 1 error. A `304` is not
an error: see `if_none_match` in [section 20](#20-history-releases-and-verification).

```python
from dalicc import DaliccError, NotFoundError

try:
    api.licenses.get("NOPE-123")
except NotFoundError as missing:
    print(missing.detail)          # 'Unknown license id: NOPE-123'
except DaliccError as failure:
    print(failure.status_code, failure)
```

`ConflictError.conflicts` holds the contradicting pairs. `ValidationError` is what a
client-side mistake looks like when the service catches it first; a mistake the client
can see itself (an unknown facet name, a format that does not exist, a missing
consent, axioms and Turtle given together) is a plain `ValueError` and never becomes a
request.

## 15. Rate limits and quotas

Two limiters apply, and they are described in
[API.md section 7](API.md#7-per-account-api-limits): a per-IP one on the anonymous
surface, and a per-account one on everything carrying a token. The per-account one
counts four windows at once (second, minute, hour, day) and weighs expensive endpoints
more heavily: the compatibility check counts as 5 requests, the consistency check as 3
and a translation as 10.

A weight never makes a call impossible: it is capped at each window's own limit, so a
translation weighing ten is one of the five the burst window allows while the minute,
the hour and the day still count it as ten. The assistant also has a **daily quota of
its own**, counted in runs rather than requests (ten a day by default), and a `429`
from `translate` may come from that, from the deployment's cap or from the provider;
`RateLimitedError.window` and the message say which.

Every answer to a token request carries the window closest to refusing the next call:

```python
status = api.last_rate_limit
if status.remaining is not None and status.remaining < 5:
    time.sleep(status.reset_in or 1)
```

Going over is a `429`:

```python
from dalicc import RateLimitedError

try:
    api.reasoning.compatibility(bundle)
except RateLimitedError as limited:
    print(limited.window)        # 'minute'
    print(limited.limit)         # 60
    print(limited.retry_after)   # 19.0 seconds
    print(limited.reset_at)      # datetime, UTC
```

The client can wait it out for you:

```python
api = Client(api_key=token, retry_on_rate_limit=True, max_wait=60.0)
```

A `429` whose `Retry-After` is at most `max_wait` seconds is then waited out and the
call repeated, at most `retries` times. A longer countdown is raised, because a
program that sleeps for twenty minutes inside a library call is a program that looks
hung.

Pacing yourself with `X-RateLimit-Remaining` is better than being refused: a refused
request costs nothing, but it also gets nothing done.

## 16. Asynchronous use

`AsyncClient` mirrors `Client` method for method, with the same arguments and the same
docstrings:

```python
from dalicc import AsyncClient

async with AsyncClient(api_key=token) as api:
    listing = await api.licenses.list(keyword="apache")
    verdict = await api.reasoning.compatibility(["MIT", "GPL-3.0-only"])
```

The work runs in a worker thread, so the event loop is never blocked while a request
is in flight. It is a thread per call in flight rather than a coroutine per call,
which is the right trade for an API that answers in milliseconds and is called a
handful of times per request. A service that needs thousands of concurrent calls
should drive the documented endpoints with `httpx.AsyncClient` directly. Close it with
`await api.aclose()` when not using the context manager.

## 17. Testing

`transport=` takes any `httpx` transport, so your own tests never have to reach the
network or wait out a real rate limit. `httpx.MockTransport` is the way to decide what
the service answers:

```python
import httpx
from dalicc import Client, RateLimitedError

def refuse(request: httpx.Request) -> httpx.Response:
    return httpx.Response(
        429,
        headers={"Retry-After": "19"},
        json={"detail": "...", "window": "minute", "limit": 60, "remaining": 0},
    )

with Client(transport=httpx.MockTransport(refuse)) as api:
    try:
        api.licenses.list()
    except RateLimitedError as limited:
        assert limited.retry_after == 19.0
```

The same trick covers a `503`, a truncated body and a connection that never opens, so
the behaviour your program shows a user on a bad day is testable on a good one.

A client can also be pointed at an ASGI application running in the same process, which
needs no socket at all; that transport is internal to the package and the way this
repository uses it is in
[DEVELOPMENT.md](DEVELOPMENT.md#12-the-python-clients-tests-and-build).

## 18. The command line tool

Installing the package installs a `dalicc` command.

```bash
dalicc search apache
dalicc search --ports exclude --limit 50
dalicc get MIT
dalicc get MIT --format ttl > MIT.ttl
dalicc get Apache-2.0 --version 1 --format ttl
dalicc check MIT GPL-3.0-only
dalicc check MIT Apache-2.0 --graph dg_default
dalicc consistency CC-BY-4.0 --graph dg_default
dalicc compose my-license.json
dalicc compose my-license.ttl --draft
dalicc translate terms.txt --title "Terms of use" --consent
dalicc translate terms.txt --consent --save-draft
dalicc graphs
dalicc graphs --axioms dg_default
dalicc changes --since 2026-09-15
dalicc hash MIT.ttl
```

Nine commands: `search`, `get`, `check`, `consistency`, `compose`, `translate`,
`graphs`, `changes` and `hash`. Four options come before any of them: `--base-url` and
`--api-key` (defaulting to `DALICC_BASE_URL` and `DALICC_API_KEY`), `--timeout` (in
seconds; 75 to read and 10 to connect when left out), and `--json`, which prints the
answer of the service instead of the readable summary, so the tool can be put in a
pipe. `dalicc --version` prints the client's version.

```bash
dalicc --json search apache | jq -r '.results.bindings[].id.value'
```

Each command adds its own: `search` takes `--limit` and `--ports`, `get` takes
`--format` and `--version <n>`, `check` and `consistency` take `--graph`, `compose`
takes `--draft`, `translate` takes `--title`, `--consent` and `--save-draft`,
`graphs` takes `--axioms <graph>`, `changes` takes `--since`, `--kind` and `--limit`, and
`hash` takes `--format` and `--subject`
([section 20](#20-history-releases-and-verification)).

Exit codes:

| Code | Meaning |
|---|---|
| 0 | the call succeeded |
| 1 | the service or the network failed |
| 2 | the command line could not be read |
| 3 | authentication: no token, or one that may not do this |
| 4 | nothing with that identifier, or one that was retired |
| 5 | the request was refused: a bad body, or a contradiction |
| 6 | a rate limit or a quota |

A rate limit prints the countdown:

```
You have used your DALICC API allowance for this minute: at most 60 requests per
minute. Try again in 1 minutes and 35 seconds. Window: minute.
```

`dalicc compose` reads a `.ttl` or `.turtle` file as Turtle and anything else as JSON.
`dalicc translate` refuses without `--consent`, for the reason in
[section 10](#10-the-translation-assistant). Only the standard library and the client
itself are used, so the command line tool brings in no extra dependency; `dalicc hash`
is the one command that parses RDF and needs the `rdf` extra.

## 19. Versioning

The client carries the release number of the service it was written for:
`dalicc.__version__` holds the `major.minor.patch` of the DALICC it was published
beside. Read it as "this client knows the API of that release".

What follows the release number need not match. The client may be published as a
release candidate, `2.0.0rc2` today, while the service is still at a development
version, because the package reaches PyPI before the service is deployed; the client's
final `2.0.0` follows that deployment. The release number itself is what the two share,
and `tests/unit/test_sdk_smoke.py` fails when it drifts.

The ten operations of the documented contract do not change
([BACKWARD_COMPATIBILITY.md](BACKWARD_COMPATIBILITY.md)), so an older client keeps
working against a newer service for everything in
[API.md section 2](API.md#2-the-documented-contract). The additive endpoints may
change under the deprecation policy, and a client older than such a change loses the
method, not the connection.

Nothing in the client is removed without a release that says so in
[CHANGELOG.md](../CHANGELOG.md). A name starting with an underscore,
`dalicc._transport` above all, is internal and may change at any time.

## 20. History, releases and verification

Version 2 of the API ([API.md section 11](API.md#11-version-2-history-releases-and-discovery))
describes every version of a license record, a dependency graph and the vocabulary with
one object, carries a content hash for each, names the state of the whole data with
release identifiers and lists what changed since a date or a release. These methods call
it; everything in sections 6 to 12 calls version 1.

| Method | Endpoint |
|---|---|
| `api.discovery()` | `GET /v2` |
| `licenses.info(license, version=None, *, if_none_match=None)` | `GET /v2/licenses/{id}`, or `.../versions/{n}` |
| `licenses.infos(license)` | `GET /v2/licenses/{id}/versions` |
| `licenses.iter_infos(*, ports="include", page_size=200, max_items=None)` | `GET /v2/licenses`, every page |
| `licenses.canonical(license, version=None)` | the same with `format=nt` |
| `licenses.model(license, version=None, format="ttl")` | the same with `format=ttl`, `jsonld`, `nt` or `rdfxml` |
| `licenses.verify(license, version=None, *, local=None)` | the object and `format=nt`, compared |
| `dependency_graphs.info(graph=None, version=None, *, if_none_match=None)` | `GET /v2/dependency-graphs/{id}`, or `.../versions/{n}` |
| `dependency_graphs.infos(graph=None)` | `.../versions` |
| `dependency_graphs.iter_infos(*, page_size=200, max_items=None)` | `GET /v2/dependency-graphs`, every page |
| `dependency_graphs.canonical(graph=None, version=None)`, `.model(...)`, `.verify(...)` | as for a license |
| `vocabulary.info(version=None, *, if_none_match=None)` | `GET /v2/vocabulary`, or `.../versions/{n}` |
| `vocabulary.infos()` | `GET /v2/vocabulary/versions` |
| `vocabulary.canonical(version=None)`, `.model(...)`, `.verify(...)` | as for a license |
| `releases.current()` | `current` of `GET /v2/releases` |
| `releases.list()` | the registered releases of `GET /v2/releases`, every page |
| `releases.get(release_id)` | `GET /v2/releases/{release_id}`, the manifest |
| `releases.changes(since=None, kinds=None, *, page_size=500, max_items=None)` | `GET /v2/changes`, every page |
| `releases.changes_page(since=None, kinds=None, *, limit=100, cursor=None)` | one page of it |
| `dalicc.hash_of(document, format=None, *, subject=None)` | none: computed on your machine |
| `dalicc.canonical_ntriples(document, format=None, *, subject=None)` | none: computed on your machine |

`graph=None` is `dg_default`, the graph every check uses unless told otherwise.

### What a deployment offers

```python
offer = api.discovery()
[v["version"] for v in offer.api_versions]     # ['1.1', '2.0']
offer.release.release                          # 'data-0df5c0f685b38419'
offer.features                                 # {'text_to_license': True, ...}
offer.rate_limits["per_account"]["windows"]    # {'second': 5, 'minute': 60, ...}
offer["links"]                                 # any key of the document
```

### One version

```python
info = api.licenses.info("MIT")
info.version, info.latest_version, info.status     # 3, 3, 'current'
info.content_hash                                  # 'sha256:1691...'
info.links["predecessor"]                          # '/v2/licenses/MIT/versions/2'

first = api.licenses.info("MIT", 1)
first.superseded, first.deprecation                # True, datetime of the successor
[v.version for v in api.licenses.infos("MIT")]     # [3, 2, 1]
```

`status` is `current`, `superseded` or `withdrawn`; `info.withdrawn` and
`info.superseded` read it. `etag` and `deprecation` come from the response headers.
A composed license is readable by identifier too, and a draft is `404`.

**Asking again only when something changed.** Keep `info.etag` and send it back:

```python
fresh = api.licenses.info("MIT", if_none_match=info.etag)
if fresh is None:
    ...   # the copy you hold is still current; the service answered 304 with no body
```

`api.request(..., if_none_match=etag)` does the same for any route and returns the
reply with `not_modified` true.

**Every record at once.** `iter_infos` walks the cursor of `/v2/licenses`, in the order
of the identifiers, and a record published during the walk does not shift the pages:

```python
for info in api.licenses.iter_infos(ports="exclude"):
    print(info.id, info.version, info.content_hash)
```

### The content hash and verification

The hash covers the triples of a version, not a file: the same version served as
Turtle, JSON-LD or RDF/XML has one hash. `canonical` downloads the canonical
N-Triples the hash is taken over, so checking a download needs nothing but SHA-256, and
`verify` does exactly that:

```python
result = api.licenses.verify("MIT")
result.ok, result.expected, result.actual, result.version

api.dependency_graphs.verify("dg_eu")
api.vocabulary.verify(2)
```

With `local`, the copy you hold is hashed as well and has to agree: a path, a text or
an `rdflib.Graph`, in Turtle, JSON-LD, RDF/XML or N-Triples. For a license only the
record's own closure is hashed, so a file that holds more than the record still matches.

```python
api.licenses.verify("MIT", local="licensedata/licenses/MIT.ttl").ok
```

Hashing a local copy parses RDF, which needs `rdflib`; the client does not install it
unless asked:

```bash
pip install --pre "dalicc[rdf]"
```

```python
from dalicc import hash_of, canonical_ntriples

hash_of("MIT.ttl")                                            # the whole file
hash_of(text, format="jsonld", subject="https://dalicc.net/licenselibrary/MIT")
canonical_ntriples("dg_default.ttl")                          # the text itself
```

A graph with a blank node that is the object of two triples, or a chain of blank nodes
that loops, raises `NotTreeShaped` rather than giving a wrong hash; no DALICC data has
either. The definition and four test vectors are in
[API.md](API.md#the-content-hash).

### Releases and the change feed

```python
current = api.releases.current()
current.release, current.library, current.graphs, current.vocabulary
current.registered                     # False once a version was published on the server

api.releases.list()                    # the registered releases, newest first
manifest = api.releases.get(current.release)   # any of the four identifiers

for change in api.releases.changes(since="2026-09-15", kinds=["license"]):
    print(change.date, change.id, change.version, change.change, change.summary)
```

`since` is a day (a string or a `datetime.date`, inclusive) or a release identifier,
which is exact: every version newer than the one that release had. To poll, walk once,
keep `until["release"]` and ask for what came after it the next time:

```python
page = api.releases.changes_page(since=last_release, limit=1000)
last_release = page.until["release"]
```

Each `Change` carries `content_hash` and `previous_hash`, so a client can tell exactly
which copy it holds. Composed licenses and the graphs of accounts never appear.

`paginate_cursor(fetch, max_items=None)` is exported for walking any cursor list the
client does not wrap: `fetch(cursor)` returns the items of a page and the next cursor.

### On the command line

```bash
dalicc changes --since 2026-09-15 --kind license
dalicc changes --since data-0df5c0f685b38419
dalicc --json changes --limit 20
dalicc hash licensedata/licenses/MIT.ttl
dalicc hash record.jsonld --subject https://dalicc.net/licenselibrary/MIT
```

`changes` prints one line per version, `date kind id vN change summary`. `hash` prints
the content hash and the file name, and needs the `rdf` extra.

## 21. License

Apache-2.0, and not the AGPL-3.0-only of the rest of DALICC. The text ships with the
package as `LICENSE`, the notice beside it as `NOTICE`, and both are in the client's
public repository at
[github.com/dalicc/python-sdk](https://github.com/dalicc/python-sdk). The copyright
holder is DALICC, Verein zur Foerderung der Rechtssicherheit in der
Datenbewirtschaftung (ZVR 1249185710), tassilo.pellegrini@ustp.at or
giray.havur@ustp.at.

The association decided this on 2026-09-23. A client that only talks to a public API
has no reason to carry the service's terms with it, and a permissive license is what
lets you link the client into your own tooling without asking what that does to the
rest of your program. Nothing follows from installing it: you may use, change and
redistribute it under Apache-2.0, including in closed software.

The service is a separate matter. It stays AGPL-3.0-only, with a commercial license
available from the association, and calling it over the network puts you under neither:
see [LICENSING.md](../LICENSING.md) and
[COMMERCIAL-LICENSE.md](../COMMERCIAL-LICENSE.md).
