The Python client
On this page
- 1. Install
- 2. Authenticate
- 3. Quick start
- 4. The client
- 5. Identifiers: bare ids and full IRIs
- 6. The license library
- Listing
- One document
- Faceted search
- The rest
- 7. Dependency graphs
- The vocabulary
- 8. Reasoning
- reasoning.compatibility(licenses, dependency_graph=None, timeout=None)
- reasoning.consistency(license=None, composer_input=None, dependency_graph=None, timeout=None)
- 9. Composing
- 10. The translation assistant
- A long license
- The other direction: a model written out as a license text
- 11. The account
- 12. The GitHub dependency checker
- 13. What comes back
- 14. Errors
- 15. Rate limits and quotas
- 16. Asynchronous use
- 17. Testing
- 18. The command line tool
- 19. Versioning
- 20. History, releases and verification
- What a deployment offers
- One version
- The content hash and verification
- Releases and the change feed
- On the command line
- 21. License
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.
Faceted search
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.