Metadata-Version: 2.4
Name: alfresco-rest-client
Version: 1.0.4
Summary: Python client for Alfresco Content Services
Author: Alfresco Contributors
License: MIT
Project-URL: Homepage, https://github.com/Alfresco/alfresco-python-client
Classifier: Development Status :: 1 - Planning
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE.txt
Requires-Dist: requests>=2.28.0
Provides-Extra: oauth
Requires-Dist: authlib>=1.2.0; extra == "oauth"
Provides-Extra: dev
Requires-Dist: setuptools>=68.0; extra == "dev"
Requires-Dist: wheel>=0.41.0; extra == "dev"
Requires-Dist: build>=1.0.0; extra == "dev"
Requires-Dist: authlib>=1.2.0; extra == "dev"
Dynamic: license-file

# alfresco-rest-client

Python client for Alfresco Content Services REST API, modelled after
[nuxeo-python-client](https://github.com/nuxeo/nuxeo-python-client).

## Quick start

```python
from alfresco import Alfresco, BasicAuth

client = Alfresco(url="http://localhost:8080", auth=BasicAuth("admin", "admin"))

# Nodes (files & folders)
root = client.nodes.get("-root-")
children = client.nodes.list_children("-root-")
folder = client.nodes.create_folder("-root-", "Reports")
uploaded = client.nodes.upload(folder.id, file_path="/tmp/q1.pdf")  # streams; supports progress= callback
content = client.nodes.get_content(uploaded.id)
client.nodes.copy(uploaded.id, root.id, name="q1-copy.pdf")
client.nodes.delete(uploaded.id)

# Version + lock signals — populated by default on get()/list_children()/iter_children()
uploaded.version_label     # e.g. "1.0" (empty if not versionable)
uploaded.is_versionable    # True if node carries the cm:versionable aspect
uploaded.is_locked         # True/False
uploaded.lock_owner        # user id string, or None
uploaded.lock_type         # "WRITE_LOCK" / "READ_ONLY_LOCK", or None
uploaded.lock_expires_at   # datetime, or None (incl. indefinite PERSISTENT locks)
uploaded.lock_lifetime     # "PERSISTENT" / "EPHEMERAL", or None

# Lock with an expiry (cm:expiryDate); omit time_to_expire for an indefinite lock
client.nodes.lock(uploaded.id, time_to_expire=3600)  # seconds
client.nodes.unlock(uploaded.id)

# People (users)
me = client.people.get("-me-")

# Groups
groups = client.groups.list()

# Sites
sites = client.sites.list()
site = client.sites.create("eng", "Engineering", visibility="PRIVATE")

# Search (AFTS or CMIS)
results = client.search.search("cm:name:'budget*'")
results = client.search.search("SELECT * FROM cmis:document", language="cmis")

# Trashcan
deleted = client.trashcan.list()

# Comments
client.comments.create(uploaded.id, "LGTM!")

# Versions (node version history)
history = client.versions.list(uploaded.id)
version = client.versions.get(uploaded.id, "1.0")
client.versions.revert(uploaded.id, "1.0", major_version=True, comment="Restore")
client.versions.delete(uploaded.id, "1.0")

# Tags
client.tags.add_to_node(uploaded.id, ["reviewed"])

# Favorites
client.favorites.add("-me-", uploaded.id)

# Context-manager support
with Alfresco(url="http://localhost:8080", auth=BasicAuth("admin", "admin")) as c:
    c.nodes.get("-root-")

client.close()
```

## Device Sync

Alfresco Device Sync (the protocol behind the desktop-sync client) spans **two
hosts**, and the SDK mirrors that split:

| Handler | Host | Purpose |
|---------|------|---------|
| `client.device_sync` | Repository (8080) | Unauthenticated bootstrap config — how a client should authenticate + where the Sync Service lives |
| `client.sync_amp` | Repository (8080) | Provisioning: subscribers, node subscriptions, syncer lookup, node stats (Device Sync AMP REST endpoints) |
| `client.sync_service` | **Sync Service (9090)** | The standalone change-delta engine: health, config, metrics and the change-polling flow |

Subscribers and subscriptions are **provisioned on the repository** (`sync_amp`),
but the actual change feed is **pulled from the standalone Sync Service**
(`sync_service`). Point the client at both hosts:

```python
from alfresco import Alfresco, TicketAuth

client = Alfresco(
    url="http://localhost:8080",
    auth=TicketAuth("admin", "admin"),
    sync_service_url="http://localhost:9090/alfresco",   # required for client.sync_service
)
```

> **Auth note:** `TicketAuth` is host-aware. Repository requests get
> `?alf_ticket=…`; Sync Service requests get `Authorization: Basic base64("<ticket>:")`.
> On OAuth/Identity-Service deployments the bearer token is accepted by both hosts.

### Bootstrap config — `client.device_sync`

Wraps `GET /alfresco/service/devicesync/config` (unauthenticated).

```python
cfg = client.device_sync.get_device_sync_config()
print(cfg.uri, cfg.repo_info.version_label, cfg.dsync_client_version_min)

idp = client.device_sync.get_identity_service_config()   # OIDC settings
print(idp.auth_server_url, idp.realm, idp.client_id, idp.openid_configuration_url())

raw = client.device_sync.get_config()                    # raw JSON document
```

| Method | Endpoint |
|--------|----------|
| `get_config()` → `dict` | `GET /alfresco/service/devicesync/config` |
| `get_device_sync_config()` → `DeviceSyncConfig` | *(parsed)* |
| `get_identity_service_config()` → `IdentityServiceConfig` | *(parsed `identityServiceConfig`)* |

### Provisioning (AMP) — `client.sync_amp`

Device Sync AMP REST endpoints under the repository's private v1 API
(`/alfresco/api/-default-/private/alfresco/versions/1`).

```python
amp = client.sync_amp

# Subscribers
sub = amp.create_subscriber(device_os="linux", client_version="1.0.1")
amp.get_subscriber(sub.id)
amp.list_subscribers()                       # or iter_subscribers()

# Node subscriptions (map a repo folder into the device's sync set)
subn = amp.create_subscription(sub.id, target_node_id=folder.id)   # subscription_type="CONTENT"
amp.get_subscription(sub.id, subn.id)
amp.list_subscriptions(sub.id)               # or iter_subscriptions(sub.id)

# Syncer that handles the subscriber + per-node statistics
syncer = amp.get_syncer(sub.sync_service_id) # -> DeviceSyncConfig (uri, filters, repo info)
stats  = amp.get_node_stats(folder.id)       # -> NodeStats(size_in_bytes, number_of_documents)

# Teardown (delete subscriptions before their subscriber — see note below)
amp.delete_subscription(sub.id, subn.id)
amp.delete_subscriber(sub.id)

# Capability probe — is the Device Sync AMP installed on this repository?
amp.is_available()                           # -> bool
```

| Method | HTTP | Endpoint (under `…/versions/1`) |
|--------|------|--------------------------------|
| `is_available()` → `bool` | GET | `/subscribers` (probe) |
| `get_syncer(syncer_id)` → `DeviceSyncConfig` | GET | `/syncers/{syncerId}` |
| `create_subscriber(device_os, client_version)` → `Subscriber` | POST | `/subscribers` |
| `list_subscribers()` / `iter_subscribers()` → `Subscriber` | GET | `/subscribers` |
| `get_subscriber(subscriber_id)` → `Subscriber` | GET | `/subscribers/{subscriberId}` |
| `delete_subscriber(subscriber_id)` | DELETE | `/subscribers/{subscriberId}` |
| `create_subscription(subscriber_id, target_node_id)` → `Subscription` | POST | `/subscribers/{subscriberId}/subscriptions` |
| `list_subscriptions(subscriber_id)` / `iter_subscriptions(...)` → `Subscription` | GET | `/subscribers/{subscriberId}/subscriptions` |
| `get_subscription(subscriber_id, subscription_id)` → `Subscription` | GET | `/subscribers/{subscriberId}/subscriptions/{nodeSubscriptionId}` |
| `delete_subscription(subscriber_id, subscription_id)` | DELETE | `/subscribers/{subscriberId}/subscriptions/{nodeSubscriptionId}` |
| `get_node_stats(node_id)` → `NodeStats` | GET | `/nodes-stats/{nodeId}` |

> **Note:** deleting a subscription (or its subscriber) whose target node has
> already been **permanently deleted** fails server-side with
> `HTTP 400 nodeRef is a mandatory parameter`. Delete subscriptions while their
> target nodes still exist, and delete them before their subscriber.

### Change-delta engine (App) — `client.sync_service`

The standalone Sync Service on port 9090 (requires `sync_service_url`).

```python
svc = client.sync_service

svc.health()                       # unauthenticated Dropwizard probe
svc.is_available()                 # -> bool (reachable? never raises)
cfg = svc.get_config()             # -> DeviceSyncConfig (filters, repo info, IdP)
svc.get_service_configuration()    # alias endpoint, same shape
svc.metrics()                      # Dropwizard registry (gauges/counters/timers)

# Change-polling flow (needs a subscriber + subscription from sync_amp):
started = svc.start_sync(sub.id, subn.id, client_version="1.0.1")   # changes=[...] optional
status  = svc.get_sync(sub.id, subn.id, started.sync_id)
for change in status.changes:      # plus status.resets / status.missing
    print(change.change_type, change.path, change.name, change.seq_no)
svc.clear_sync(sub.id, subn.id, started.sync_id)
```

`start_sync` builds the required `{"changes": [...], "clientVersion": ...}`
body for you — pass `client_version` (and optional `changes`), or a complete
`sync_request` dict (validated to contain both keys).

The Sync Service URL can also be (re)configured after construction with
`client.set_sync_service_url(url)`, which rebinds a host-aware `TicketAuth` so
the ticket is only ever sent to the newly-configured Sync Service host.

| Method | HTTP | Endpoint |
|--------|------|----------|
| `health()` → `dict` | GET | `{sync_service_url}/healthcheck` |
| `is_available()` → `bool` | GET | `{sync_service_url}/healthcheck` |
| `get_config()` → `DeviceSyncConfig` | GET | `…/versions/1/config/syncService` |
| `get_service_configuration()` → `DeviceSyncConfig` | GET | `…/versions/1/config/syncServiceConfiguration` |
| `metrics()` → `dict` | GET | `…/versions/1/metrics` |
| `start_sync(subscriber_id, subscriptions, client_version=…)` → `SyncStatus` | POST | `…/subscribers/{id}/subscriptions/{query}/sync` |
| `get_sync(subscriber_id, subscriptions, sync_id)` → `SyncStatus` | GET | `…/subscriptions/{query}/sync/{syncId}` |
| `clear_sync(subscriber_id, subscriptions, sync_id)` | DELETE | `…/subscriptions/{query}/sync/{syncId}` |

#### Understanding the change feed

The fields and semantics below were verified against a live Alfresco Content
Services deployment.

**`SyncStatus.status`** (`SyncState` constants):

| Value | Meaning |
|-------|---------|
| `SyncState.OK` (`"ok"`) | Returned by `start_sync` — the sync was accepted; poll it with `get_sync`. |
| `SyncState.NOT_READY` (`"notReady"`) | The service is still preparing changes asynchronously — keep polling. |
| `SyncState.READY` (`"ready"`) | Changes are available in `status.changes`. |
| `SyncState.ERROR` (`"error"`) | Failure (see `status.message`), e.g. an unknown/cleared `syncId` or an unregistered subscription. |

Polling is **asynchronous**: `start_sync` returns `ok`, then `get_sync` may
return `notReady` several times before `ready`. Crash-recovery semantics:
re-polling an **unknown or cleared** `syncId` returns `status == SyncState.ERROR`
(message `"invalid syncId …"`) — it does **not** raise or 404. `clear_sync` on an
unknown id is idempotent. Concurrent `start_sync` calls each allocate a distinct
`syncId`.

**`Change` fields** — one entry per affected node:

| Field | Notes |
|-------|-------|
| `change_type` | `ChangeType` value (see below). |
| `path` / `to_path` | **Absolute** repository path (`/Company Home/…`). Populated even for deletes (pre-delete path). For moves/renames, `path`=old, `to_path`=new. |
| `name` / `to_name` | Node name; `to_name` set on rename/move. |
| `parent_id` / `parent_ids` | Immediate parent id, then ancestors up to the root. `to_parent_id`/`to_parent_ids` for moves. |
| `is_folder` | Whether the node is a folder. |
| `node_type` | e.g. `cm:content`, `cm:folder`. |
| `size` | Byte size when known, else `-1`. |
| `aspects` | Applied aspect QNames. |
| `event_at` / `modified_at` | UTC datetimes parsed from the event / node millisecond timestamps (`modified_at` is `None` for deletes). |
| `seq_no` | Monotonic sequence number; changes are ordered so **parents precede their children**. |
| `checksum` | ⚠️ Currently a server placeholder (`"dummyChecksum"`) — **not** a usable content digest. |

**`ChangeType` vocabulary** (observed live):

| Constant | Value | Emitted when |
|----------|-------|--------------|
| `ChangeType.CREATE` | `CREATE_REPOS` | A node is created. |
| `ChangeType.UPDATE` | `UPDATE_REPOS` | Node content is updated. |
| `ChangeType.DELETE` | `DELETE_REPOS` | A node is deleted. |
| `ChangeType.MOVE` | `MOVE_REPOS` | A node is moved (single change carrying old+new parent/path). |
| `ChangeType.RENAME` | `RENAME_REPOS` | A node is renamed (single change carrying `name`+`to_name`). |
| `ChangeType.CHECKED_IN` | `NODECHECKEDIN` | Emitted alongside `UPDATE_REPOS` when a content update creates a new version. |

A move is a **single `MOVE_REPOS`** change (not a delete+create); a rename is a
single `RENAME_REPOS`. Property-only updates (e.g. changing `cm:title`) produce
no change. `status.resets` lists subscriptions the client must re-sync from
scratch and `status.missing` lists subscriptions the server no longer knows
about (both `list[str]`).

**Delta paging & limits.** A single sync is paged: poll `get_sync` with the same
`sync_id` while `status.more_changes` is `True`, applying `status.changes` each
round, then `clear_sync`. There is **no client page-size parameter** on
`start_sync`/`get_sync`, and the Sync Service does **not** expose a numeric
per-response cap in its configuration — bound your loop on `more_changes` rather
than a fixed batch size. There is **no `hasTooManyChanges` flag**: the
"re-sync from scratch" signal is the `status.resets` list (per subscription),
and an unrecoverable sync surfaces as `status == SyncState.ERROR`.

**Auth matrix** — against the standalone Sync Service (9090):

| Auth | Behaviour |
|------|-----------|
| `BasicAuth` | Accepted (`200`). |
| `TicketAuth` | Host-aware — sends the ticket as HTTP Basic (`base64("<ticket>:")`). |
| `OAuth2Auth` / `OAuth2` | Host-aware — sends `Authorization: Bearer <token>`. On Identity-Service (OIDC) deployments the same IdP-issued token is validated by both the repository and the Sync Service, so one token covers both hosts. *(Verified against a Basic-auth deployment where the Sync Service has no IdP configured; positive bearer acceptance requires an Identity-Service-enabled deployment.)* |
| unauthenticated | `401` (except `/healthcheck`, which is open). |

All bundled auth handlers are **host-aware**: credentials (ticket or bearer) are
only ever sent to the configured repository and Sync Service origins, never to
any other host — including look-alike origins such as
`https://sync.example.com.attacker.test`. Used standalone (without an `Alfresco`
client to bind them), `OAuth2Auth`/`OAuth2` fall back to attaching the token to
every request; the client binds them so this confinement takes effect.

### Subscriber lifecycle, scoping & orphan recovery

`create_subscriber` returns a server-assigned `id` with no client-supplied
identifier — the service **rejects** unknown body fields (`HTTP 400`) and does
not correlate an `X-Device-Id` header, so registration is **not idempotent**. If
a client crashes between a successful `create_subscriber` and persisting the
returned id locally, it orphans a subscriber.

To recover, list your own subscribers and delete the stale ones.
`list_subscribers()` / `iter_subscribers()` are **scoped to the authenticated
user**: a regular user sees only the subscribers they created (verified live —
another user's subscribers are absent from the list and a direct `get_subscriber`
returns `404`), so there is no cross-user enumeration. An **administrator** is
the exception and sees every subscriber on the repository. The endpoint requires
an authenticated session; no extra permission is needed to manage your own
subscribers.

### Thread-safety

A single `Alfresco` instance (and its `requests.Session`) is safe to share
across threads — e.g. one sync-polling thread alongside a pool of upload/download
workers. The lazy handler cache is lock-guarded and the `TicketAuth`/`OAuth2Auth`
handlers synchronise their ticket/token exchange. Configure the client (including
`set_sync_service_url`) during setup, before sharing it across threads;
runtime reconfiguration is not synchronised against in-flight requests.

## Streaming uploads & progress

`client.nodes.upload()` and `client.nodes.update_content()` stream the file
body in bounded blocks instead of buffering the whole file in memory, so memory
stays roughly constant regardless of file size. Both accept an optional
`progress` callback and a `chunk_size` (default 64 KiB):

```python
def on_progress(bytes_read: int, total_bytes: int) -> None:
    pct = (bytes_read * 100 / total_bytes) if total_bytes else 100
    print(f"{bytes_read}/{total_bytes} bytes ({pct:.0f}%)")

# New file — from a local path or an open binary stream
node = client.nodes.upload(
    folder.id,
    file_path="/tmp/large.iso",   # or: file_body=open(...), name="large.iso"
    progress=on_progress,
    chunk_size=1024 * 1024,        # 1 MiB reads
)

# Replace an existing node's content, with progress
client.nodes.update_content(
    node.id,
    file_path="/tmp/large.iso",
    mime_type="application/octet-stream",
    progress=on_progress,
)
```

Callback contract:

- `progress(bytes_read, total_bytes)` is invoked after each non-empty block;
  values are **monotonic** within an attempt and reach the exact file size.
  Only file-payload bytes are counted — multipart framing is excluded.
- Empty files receive a single `(0, 0)` completion notification.
- Exceptions raised by the callback abort the upload (they are not swallowed).
- On an automatic 401 re-authentication retry the body is rebuilt and progress
  **restarts from zero** — treat a decrease as a retry reset.
- A caller-provided `file_body` must be seekable (so its length is known and the
  body can be replayed on retry) and is left open; handles opened by the SDK via
  `file_path` are always closed.

Both parameters are keyword-only and optional, so existing calls keep working
unchanged — the only difference is that request bodies are no longer fully
buffered.

## Authentication

| Method | Class | Notes |
|--------|-------|-------|
| HTTP Basic | `alfresco.BasicAuth(user, password)` | Simple, works everywhere |
| Ticket | `alfresco.TicketAuth(user, password)` | Obtains a login ticket automatically |
| OAuth 2.0 | `alfresco.OAuth2Auth(...)` | Bearer-token auth (Keycloak / Alfresco Identity Service) |

### OAuth 2.0 example

Password (Resource Owner Password Credentials) grant:

```python
from alfresco import Alfresco, OAuth2Auth

auth = OAuth2Auth(
    token_url="http://localhost:8081/auth/realms/alfresco/protocol/openid-connect/token",
    client_id="alfresco",
    username="admin",
    password="admin",
)
client = Alfresco("http://localhost:8080", auth=auth)
me = client.people.get("-me-")
```

Client-credentials grant (service account):

```python
auth = OAuth2Auth(
    token_url="https://idp/realms/r/protocol/openid-connect/token",
    client_id="my-service",
    client_secret="…",
    grant_type="client_credentials",
)
```

Pre-fetched access token (e.g. from a browser-based auth code flow):

```python
auth = OAuth2Auth.from_token(
    access_token="eyJhbGciOi…",
    refresh_token="…",
    token_url="…",
    client_id="…",
)
```

The handler caches the token, refreshes automatically before expiry (with a
30 s skew), and falls back to a full re-authentication if the refresh token
is rejected. Call `auth.invalidate()` to force a re-auth.

## Local development

```bash
# Create & activate a virtual environment
python3 -m venv .venv
source .venv/bin/activate

# Install build / dev tools
make install-dev

# Install the package in editable mode
pip install -e .

# Run smoke test
python3 tests/test_smoke.py

# Build wheel + sdist (PEP 517)
make build

# Remove build artifacts
make clean
```

Built packages are placed in `dist/`.

## Releasing

Publishing is automated with GitHub Actions. Both publishing workflows use
**PyPI Trusted Publishing (OIDC)** — there are no API tokens stored in the repo.

### Release → PyPI (`.github/workflows/release.yml`)

Triggered by a **push to `main` whose commit message contains `[release]`**, or
manually via **Run workflow** (`workflow_dispatch`).

> **Before releasing, set the correct release version.** The version that gets
> tagged and published is read from `__version__` in `alfresco/__init__.py`, so
> edit that value to the version you intend to release **before** triggering the
> workflow. The `check` job fails if the matching `v<version>` git tag already
> exists, so you cannot accidentally re-release an existing version.

1. **check** — reads the version from `alfresco/__init__.py` (`__version__`) and
   fails if the `v<version>` git tag already exists.
2. **tests** — runs the full suite (`tests.yml`). Add `[skip test]` to the commit
   message (or set `skip_tests=true` on a manual run) to bypass this step.
3. **build** — builds the wheel + sdist (PEP 517).
4. **publish** — uploads to [PyPI](https://pypi.org/) from the `pypi` environment.
5. **tag** — creates the `v<version>` git tag and a GitHub Release with the
   built artifacts attached.
6. **bump** — after a successful release, advances `__version__` on `main` to the
   next development version (auto-increments the patch, e.g. `0.1.0 → 0.1.1`, or
   uses the `next_version` input on a manual run) and pushes a
   `Bump version to … [skip ci]` commit.

To cut a release: set `__version__` to the release version, then land a commit on
`main`, e.g. `git commit -m "Release 0.1.0 [release]"`. The next development
version is set automatically by the **bump** step.

### Snapshot → TestPyPI (`.github/workflows/snapshot.yml`)

Publishes dev builds versioned `X.Y.Z.dev<run-number>` to
[TestPyPI](https://test.pypi.org/) from the `testpypi` environment. It runs when:

* any push lands on `main` (automatic), **or**
* a push on a working branch (`feature/**`, `fix/**`, `release/**`) has a commit
  message containing `[snapshot]`.

Release commits (`[release]`) are always skipped here to avoid double-publishing,
and the test suite must pass before a snapshot is published.

Install a snapshot with:

```bash
pip install -i https://test.pypi.org/simple/ --pre alfresco-rest-client
```

> All third-party actions are pinned to commit SHAs, and each publishing job runs
> in a dedicated protected GitHub Environment (`pypi` / `testpypi`).


