DEVELOPMENT VERSION: This is an experimental DALICC environment. Features, data and APIs may change or be reset without notice. A stable version of this service, with a potentially different feature set, will be made available soon at dalicc.net.

The Python client


Last changed Markdown source

On this page

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.

Contents: 1. Install · 2. Authenticate · 3. Quick start · 4. The client · 5. Identifiers · 6. The license library · 7. Dependency graphs · 8. Reasoning · 9. Composing · 10. Translation · 11. Account · 12. GitHub · 13. What comes back · 14. Errors · 15. Rate limits · 16. Async · 17. Testing · 18. Command line · 19. Versioning · 20. History, releases and verification · 21. License

The package is developed in sdk/ and published from there to github.com/dalicc/python-sdk, which is the client's own public repository. The HTTP contract it speaks is API.md; what a license, a draft, a submission or a translation is belongs to 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.


1. Install

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).

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, 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). It looks like dalicc_ followed by 40 characters and is shown once, when it is created.

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

or leave it out and put it in the environment:

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 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

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

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. More is on the client itself:

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), and the client hides it: every method takes either spelling, and a LicenseRef from a listing can be handed straight to any of them.

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

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).

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

One document

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 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:

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.

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; 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

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
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.

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
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.

8. Reasoning

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

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).

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

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
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.

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:

from dalicc import ConflictError

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

Afterwards:

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; the endpoint behind these methods is API.md section 8. 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
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.

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:

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:

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:

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:

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:

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:

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:

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:

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

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), in both shapes:

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:

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.

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: 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:

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:

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:

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:

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:

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.

18. The command line tool

Installing the package installs a dalicc command.

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.

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).

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. 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), so an older client keeps working against a newer service for everything in API.md section 2. 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. 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) 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

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

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:

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:

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:

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.

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:

pip install --pre "dalicc[rdf]"
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.

Releases and the change feed

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:

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

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. 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 and COMMERCIAL-LICENSE.md.