A Python client for the Uwazi API with Pydantic models and pandas support.
pip install git+https://github.com/huridocs/python_uwazi_API@2026.10.13.5git clone https://github.com/huridocs/python_uwazi_API.git
cd python_uwazi_API
uv syncRequirements: Python >= 3.12
from uwazi_api.client import UwaziClient
# Authenticated client (full access)
client = UwaziClient(user="admin", password="admin", url="http://localhost:3000")
# Authenticated client with two-factor authentication (TOTP)
# Generate a fresh TOTP code before constructing the client.
client = UwaziClient(user="admin", password="admin", url="http://localhost:3000", token="123456")
# Public client (read-only, no credentials needed)
client = UwaziClient.public("https://public.uwazi.org")
# Search entities by text
results = client.search.search_by_text(search_term="Malawi")
# Get an entity
entity = client.entities.get_one(shared_id="shared_id", language="en")
# Export to DataFrame
import pandas as pd
df = client.exports.to_dataframe(start_from=0, batch_size=100, template_name="template_name", language="en", published=False)src/uwazi_admin_agent ships a NiceGUI web app for creating and running Uwazi
admin agent tasks from a browser. It is not part of the library above — it
runs as its own server on port 5055.
Create a .env file in the repo root with:
UWAZI_URL=http://localhost:3000
UWAZI_USER=admin
UWAZI_PASSWORD=admin
OLLAMA_API_KEY=<api-key>
UWAZI_URL,UWAZI_USER,UWAZI_PASSWORD— required; validated at the web login gate.OLLAMA_API_KEY— required by the LLM adapter. This is a key for the hosted API athttps://ollama.com/v1, not a local Ollama install.ADMIN_WEB_PORT(default5055),ADMIN_WEB_STORAGE_SECRET(has a dev default),UWAZI_ADMIN_FILE_CACHE=0(disables the per-instance disk cache, which can grow up to 4 GiB undersrc/uwazi_admin_agent/data/file_cache/) are all optional.
uv sync
uv run uwazi-admin-webRequirements: Python >= 3.12 and uv (e.g. winget install astral-sh.uv).
No Postgres, Docker, or WSL is needed for this app; all its data paths are
resolved relative to the package and work on NTFS.
uv sync
set PYTHONUTF8=1
uv run uwazi-admin-webset PYTHONUTF8=1 avoids UnicodeEncodeError from the logging output on
cp1252 consoles (use setx PYTHONUTF8 1 to make it permanent).
Open http://localhost:5055 and log in with your Uwazi credentials. The server
binds 0.0.0.0, so other LAN hosts can reach it if port 5055 is opened on the
host firewall. Run data (prompts, runs, audit logs) lives under
src/uwazi_admin_agent/data/.
Every task has a review page (/review/<run_id>, reachable from the runs
table's fact_check action or its row menu). The review flow is built on the
two-script model — a generation turn emits TWO scripts:
- Selection script (
script.py, read-only) — discovers the affected entities and declares one intended change per entity. Run it with "Scan entities": a dry pass (real reads, zero writes) whose records become the review table's suboperations, grouped one row per affected entity and scored against the values Uwazi currently holds (stored in a local SQLite file,data/suggestions.db). - Apply script (
apply_script.py) — applies the change to EXACTLY ONE entity via the boundTARGET_SHARED_ID. It is the only place LLM work (llm_complete/llm_map) survives generation, so the expensive pass never runs at task creation.
On the review page:
- the table lists ONE row per affected entity (the operation), with a checkbox; expanding the row (or Expand all) lists its suboperations — one per property value per language, one per relationship link — each with its own checkbox, status badge and actions. One entity written in two languages is still ONE row, with one part per language;
- Run (N) writes the checked suboperations through one accumulating,
revertable apply run (
<run>-apply). Property rows are pushed as batched partial updates; whole-entity rows (delete/publish/...) and creates go through their dedicated helpers. Ticking an operation ticks all its parts, and ticking its last part ticks the operation. An operation's own Run button runs its ticked parts (all of them when none is ticked); a suboperation's Run writes exactly that property. A locked part (delete/publish/merge) runs with its operation, since it is one action on Uwazi's side; - Revert on an operation restores that entity to the backup the run made BEFORE any of its writes (all its languages share one backup). A written property suboperation also offers its own Revert, which puts back just that property and leaves the run's other writes in place. Revert task (or the apply run's own revert) undoes everything in one action;
- the entity window (click the title, or a suboperation's Edit icon) shows the primary PDF beside the entity's property rows; opening it from a suboperation highlights that property;
- filters (Status, Discarded, Op, Property with its accuracy, Language, Match, Search) are SUBOPERATION filters: an operation whose every part a filter removed is not shown, and a Run under a filter writes only the parts the filter left. Discard (N) (and the per-row Discard) marks the checked suboperations as discarded — "the end user does not want this executed". A discard is a MARK, not a delete: a Run never writes a discarded row, the reason the operator typed is stored with it, and Restore brings the operation back into the review queue. The Discarded filter reads both halves of that decision ("discarded" / "not discarded");
- Improve the prompt shows the run's current prompt editable plus this round's discard/mismatch evidence; submitting regenerates BOTH scripts (selection + apply) with the original discovery context still in view.
CLI equivalences: scan = the selection scan; apply = accept everything of
the latest attempt and push it into the apply run. There is deliberately no
execute step: a task-scope script never executes whole-set.
A script can call the LLM per entity through two helpers bound into the sandbox:
llm_complete(instruction, text) and llm_map(instruction, items) (batched).
Before any LLM-spending action — generating a script, a prediction pass, or a
prompt-repair turn — a dialog shows the projected request/token counts and an
estimated cost and lets you tighten the hard limits that are enforced at
runtime. When a limit is hit the pass aborts cleanly: predictions never write to
Uwazi, so everything recorded before the halt is kept and reviewable.
Pricing is a placeholder and must be set for your provider via
LLM_PRICE_INPUT_PER_MTOK / LLM_PRICE_OUTPUT_PER_MTOK; the enforced limits are
request/token counts, not money.
The New Task wizard accepts extra step prompts (one per line) to create a
multistep task — one generated run (selection + apply scripts) per step
(/task/<task_id>). Each step card shows the status, its selection scan's
implication counts ("Refresh implications"), and a "Review & apply" link to
the step's own review page — reviewing there is what writes; the task page has
no whole-set Execute button. Reverting is whole-task only: a single
"Revert task" button undoes every executed step AND every accept in reverse
order (later steps first, a step's apply run before its own run), stopping at
the first failure so the remainder stays visible for retry. Standalone runs
keep their per-run revert in the runs table.
uwazi-admin-agent text-property-e2e exercises the per-language text-property
path against the live instance, driving the UI's own steps in the UI's own
order (create the task → plan → Run → revert → verify). It is the regression
harness for the case where one entity carries a text property in two
languages (en and es) with a different value per language — Uwazi
stores one entity-language ROW per translation (same sharedId, different
_id), a text property holds exactly ONE value per row, and the write's
locale cookie picks the row.
uv run uwazi-admin-agent text-property-e2e --user admin --password adminIt creates three tasks, each a different angle on the same use case:
| task | shape | what it proves |
|---|---|---|
| 1 | two steps — one per language, over the same entities | the two language rows are written independently, and the whole-task revert undoes both |
| 2 | one step covering both languages of every entity | a single run can write both rows without merging them |
| 3 | one step with a different batch size per language (2 vs 4) | the language groups are independent in count too |
Every task writes real values into real entities, so the driver is
idempotent: it captures the current values first, reverts through the
ordinary revert path, and restores anything the revert did not cover through a
direct write to the same endpoint. A second run therefore starts from the same
state as the first, and the report ends with an explicit
idempotency: property values unchanged=True whole entities unchanged=True
line. The run folders and the task files are deleted at the end unless
--keep-runs is passed.
The driver compares the whole entity raw (every metadata property and every top-level field), not just the property under test, so it answers the data-loss question directly:
- no data loss on the write — "the run changed ONLY the target property", "the run changed no other field of the entity (title, files, ...)" and "the run changed NOTHING on the rows it did not select" (the sibling language's row is byte-identical);
- the revert really restores — "the task's revert restored the WHOLE
entity (every property)" and "the task's revert restored every other field",
plus the agent's own post-revert verification (
ok=True mismatches=0).
editDate (server-managed, advances on every save) and relations (a
read-only denormalized view) are excluded from the comparison — exactly like
the agent's own verification does.
Options: --template (default DOCUMENT), --property (default: the
template's first non-title text property), --entities (entities per
language for tasks 1 and 2, default 2), --keep-runs.
The UwaziClient provides access to these services:
| Service | Property | Description |
|---|---|---|
| Entities | client.entities |
CRUD operations on entities |
| Templates | client.templates |
Manage entity templates |
| Files | client.files |
File upload/download operations |
| Thesauri | client.thesauris |
Manage thesauri/vocabularies |
| Thesauri from DataFrame | client.thesauri_from_df |
Add thesauri values from a DataFrame |
| Settings | client.settings |
System settings and languages |
| Search | client.search |
Search entities with filters |
| CSV | client.csv |
CSV import operations |
| Relationships | client.relationships |
Create entity relationships |
| Exports | client.exports |
Export entities to DataFrame |
from uwazi_api.domain.entity import Entity
# Get entities
entity = client.entities.get_one(shared_id="id", language="en")
entity = client.entities.get_by_id(entity_id="internal_id")
entities = client.entities.get(start_from=0, batch_size=100, template_name="tpl", language="en", published=False)
entities = client.entities.get_shared_ids(template="tpl", batch_size=100, unpublished=True)
# Search
entities = client.entities.search_by_text(
search_term="query", template_name="tpl", start_from=0, batch_size=100, language="en"
)
# Search with filters
from uwazi_api.domain.search_filters import SearchFilters, DateRange, SelectFilter
filters = SearchFilters()
filters.add("date_property", DateRange(from_=date(2026, 1, 1), to=None))
filters.add("select_property", SelectFilter(values=["value1", "value2"]))
entities = client.entities.search_by_filter(
filters=filters, template_name="tpl", start_from=0, batch_size=100, language="en", published=False
)
# Create/Update
shared_id = client.entities.upload(entity=entity, language="en")
shared_id = client.entities.update_partially(entity=entity, language="en")
# Upload entity with files
# Build a list of files to attach. The `fieldname` decides how Uwazi classifies each file:
# - FileFieldname.FILE → primary document (what the entity is about)
# - FileFieldname.DOCUMENT → primary document (alternative name)
# - FileFieldname.ATTACHMENT → supporting file (attachment)
# `content` is raw bytes, `content_type` any FileType MIME constant (FileType.PDF, .ODT, ...).
# `filename` is preserved intact (the `field[index]`/`originalname` convention keeps full names).
from uwazi_api.domain.entity_file_upload import EntityFileUpload
from uwazi_api.domain.file_fieldname import FileFieldname
from uwazi_api.domain.FileType import FileType
with open("document.pdf", "rb") as f:
pdf_bytes = f.read()
files = [
EntityFileUpload(
fieldname=FileFieldname.FILE, # → primary document
filename="document.pdf",
content=pdf_bytes,
content_type=FileType.PDF,
),
EntityFileUpload(
fieldname=FileFieldname.ATTACHMENT, # → supporting file
filename="supporting.pdf",
content=pdf_bytes,
content_type=FileType.PDF,
),
]
# Create an entity and attach a primary document + supporting file in one request
shared_id = client.entities.upload(entity=entity, language="en", files=files)
# `update_partially` accepts files too:
shared_id = client.entities.update_partially(entity=entity, language="en", files=files)
# Bulk operations
responses = client.entities.create_or_update_entities_from_dataframe(df=df, language="en", template="template_name")
client.entities.publish_entities(shared_ids=["id1", "id2"])
client.entities.delete_entities(shared_ids=["id1", "id2"])
client.entities.delete_empty_template(shared_id="id")
# DataFrame export
df = client.entities.search_by_filter_to_dataframe(filters=filters, template_name="tpl", language="en", batch_size=100)# Download
pdf_bytes = client.files.get_document(shared_id="id", language="en")
pdf_bytes = client.files.get_document_by_file_name(file_name="doc.pdf")
client.files.save_document_to_path(shared_id="id", languages=["en"], path="/path/to/save")
# Upload
client.files.upload_file(pdf_file_path="/path/to/file.pdf", share_id="entity_id", language="en", title="Document Title")
# Upload from bytes
from uwazi_api.domain.FileType import FileType
with open("document.pdf", "rb") as f:
client.files.upload_document_from_bytes(file_bytes=f.read(), shared_id="entity_id", language="en", title="document.pdf")
# Upload other file types
with open("document.odt", "rb") as f:
client.files.upload_file_from_bytes(
file_bytes=f.read(), shared_id="entity_id", language="en", title="document.odt", file_type=FileType.ODT
)
# Images
client.files.upload_image(image_binary=image_bytes, title="image.jpg", entity_shared_id="entity_id", language="en")
# Delete
client.files.delete_file(file_id="file_id")# Get templates
templates = client.templates.get()
template = client.templates.get_by_name(template_name="Template Name")
template = client.templates.get_by_id(template_id="id")
# Resolve template (accepts name or ID)
template_id = client.templates.resolve_template_id("Template Name or ID")
# Find property in template (pass template name or ID)
prop = client.templates.find_property(template_name_or_id="template_name_or_id", prop_name="property_name")
# Create/Update
client.templates.set(language="en", template=template)
# Delete template
client.templates.delete_empty_template(template_id="template_id")
# Clear cache
client.templates.clear_cache()# Get thesauri
thesauri = client.thesauris.get(language='en')
# Add values
client.thesauris.add_value(
thesauri_name='Thesaurus Name',
thesauri_id='thesaurus_id',
thesauri_values=['New Value 1', 'New Value 2'],
language='en'
)
# Clear cache
client.thesauris.clear_cache(language='en')
### Thesauri from DataFrame
Automatically add new thesauri values from a DataFrame by matching columns to template properties:
```python
import pandas as pd
df = pd.DataFrame({
'country': ['Malawi', 'Zambia', 'Mozambique'],
'city': ['Lilongwe', 'Lusaka', 'Maputo'],
})
# Adds "Malawi", "Zambia", "Mozambique" to the thesaurus linked to the
# "country" property, and "Lilongwe", "Lusaka", "Maputo" to the thesaurus
# linked to the "city" property in the given template.
client.thesauri_from_df.execute(df=df, template_name='Template Name', language='en')Existing values are preserved — only new, unseen values are added.
settings = client.settings.get()
languages = client.settings.get_languages() # Returns list of language codesThe search service provides the same methods as client.entities for search operations:
# Text search
entities = client.search.search_by_text(...)
# Filtered search
entities = client.search.search_by_filter(...)
# To DataFrame
df = client.search.search_by_filter_to_dataframe(...)
# Get entities (same as entities.get)
entities = client.search.get(...)
# Get shared IDs
ids = client.search.get_shared_ids(...)import pandas as pd
df = pd.DataFrame({"title": ["Entity 1", "Entity 2"], "property1": ["value1", "value2"]})
# Upload DataFrame
client.csv.upload_dataframe(df=df, template_name="template_name")
# Upload and get shared ID
shared_id = client.csv.upload_dataframe_and_get_shared_id(df=df, template_name="template_name")from uwazi_api.domain.reference import Reference
from uwazi_api.domain.selection_rectangle import SelectionRectangle
reference = Reference(
text="Reference text",
selection_rectangles=[SelectionRectangle(top=172.94, left=335.66, width=155.34, height=17.62, page="1")],
)
client.relationships.create(
file_entity_shared_id="source_entity_id",
file_id="file_id",
reference=reference,
to_entity_shared_id="target_entity_id",
relationship_type_id="relationship_type_id",
language="en",
)# Export entities to DataFrame
df = client.exports.to_dataframe(start_from=0, batch_size=100, template_name="template_name", language="en", published=False)When working with relationship properties in DataFrames, the format differs from other property types.
Relationship properties are exported with both the entity name and sharedId:
# Single relationship
# "Entity Name (id:abc123)"
# Multiple relationships
# "Entity 1 (id:abc123)|Entity 2 (id:def456)"
# Example DataFrame output:
df = client.exports.to_dataframe(template_name="template_name")
# | title | related_entities |
# |--------------|-------------------------------------------|
# | My Entity | Target Entity (id:abc123)|Other (id:xyz) |When uploading a DataFrame, relationship properties can be provided in multiple formats:
import pandas as pd
df = pd.DataFrame(
{
"title": ["Entity with Relationship"],
# Format 1: Using "name (id:sharedId)" format
"related_entities": ["Target Entity (id:abc123)"],
# Multiple relationships with new format
"related_entities": ["Entity A (id:abc123)|Entity B (id:def456)"],
# Format 2: Using just sharedId (works both ways)
"related_entities": ["abc123"],
# Multiple with just sharedIds
"related_entities": ["abc123|def456"],
# Format 3: Mixed (some with name, some without)
"related_entities": ["Target Entity (id:abc123)|def456"],
}
)
# Upload
responses = client.entities.create_or_update_entities_from_dataframe(df, language="en", template="template_name")The upload parser automatically extracts:
- The sharedId from
name (id:sharedId)format - Raw sharedIds from
sharedIdorsharedId|sharedIdformats - Strips extra whitespace from
sharedId | sharedIdformat
The create_or_update_entities_from_dataframe method automatically determines whether to create or update based on the presence of the sharedId column in the DataFrame:
import pandas as pd
# CREATE: sharedId column is NOT present or is empty
df_create = pd.DataFrame(
{
"title": ["New Entity 1", "New Entity 2"],
"template": ["template_name", "template_name"],
}
)
responses = client.entities.create_or_update_entities_from_dataframe(df_create, language="en", template="template_name")
# UPDATE: sharedId column IS present with values
df_update = pd.DataFrame(
{
"sharedId": ["existing_id_1", "existing_id_2"],
"title": ["Updated Title 1", "Updated Title 2"],
}
)
responses = client.entities.create_or_update_entities_from_dataframe(df_update, language="en", template="template_name")
# MIXED: Some rows have sharedId (update), some don't (create)
df_mixed = pd.DataFrame(
{
"sharedId": ["existing_id", None, None],
"title": ["Updated", "New Entity 1", "New Entity 2"],
"template": [None, "template_name", "template_name"],
}
)
responses = client.entities.create_or_update_entities_from_dataframe(df_mixed, language="en", template="template_name")Logic:
- If
sharedIdcolumn exists in the DataFrame and has a value → UPDATE existing entity - If
sharedIdcolumn is NOT present or is empty/NaN → CREATE new entity
Important: The template column is required for creating new entities but is ignored when updating (existing template is preserved).
The template parameter (passed as function argument) is used to resolve property type mappings for proper DataFrame column-to-entity property conversion. Pass the template name or ID. This parameter is optional but recommended for accurate property type handling.
All responses use Pydantic models for type safety and validation.
class Entity(BaseModel):
id: str | None # Internal ID
shared_id: str | None # Shared ID across translations
title: str | None
template: str | None
language: str | None
published: bool | None
creation_date: Any | None
edit_date: Any | None
documents: list[Document]
attachments: list[Attachment]
metadata: dict[str, Any]class Template(BaseModel):
id: str
name: str
properties: list[PropertySchema]
common_properties: list[PropertySchema] # Defaults to: title, creationDate, editDateBy default, templates include three common properties:
- title (text) - The entity title
- creationDate (date) - Date the entity was created
- editDate (date) - Date the entity was last modified
Available property types: text, date, select, numeric, daterange, multidate, link, image, multidaterange, markdown, media, generatedid, multiselect, geolocation, relationship, preview, nested
Note: preview is a template-only property that tells Uwazi to render the entity's primary document (the file the entity is about) as an image at the top of the entity view — Uwazi auto-generates it from the file itself. It never carries an entity-side value.
Note: nested is a template-only parent group that gathers a repeatable sub-set of other properties under one parent key. The parent itself never carries an entity-side value — only its child properties do.
class Thesauri(BaseModel):
id: str
name: str
values: list[ThesauriValue]
class ThesauriValue(BaseModel):
label: str
id: strCreate a .env file in your working directory (only needed for authenticated instances):
UWAZI_USER=admin
UWAZI_PASSWORD=admin
UWAZI_URL=http://localhost:3000
Authentication is handled automatically when user and password are provided. The client logs in via /api/login and stores the session cookie for subsequent requests.
For public instances, no credentials are needed — simply use UwaziClient.public(url) or UwaziClient(url=url). Public endpoints like search work without authentication, while write operations will return a 401 error.
The library defines custom exceptions:
AuthenticationError- Login failedEntityNotFoundError- Entity not foundUploadError- Upload failedSearchError- Search failedTemplateNotFoundError- Template not foundDomainError- Base exception for all domain errors
The library follows Clean Architecture:
- domain/ - Pydantic models and exceptions
- ports/ - Abstract interfaces
- adapters/ - HTTP client implementation with retry logic
- use_cases/ - Business logic, repositories, and services
# Create the venv and install the project + dev group
uv sync
# Run tests
uv run pytest
# Format code
ruff format
# Lint
ruff check .MIT