Skip to content
Draft
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

All notable changes to this project will be documented in this file.

## [1.0.0 - Unreleased]

### Removed (BREAKING)

- All synchronous API classes: `Nextcloud`, `NextcloudApp`, `TalkBot`, `nc_app`, `talk_bot_msg`, sync `enabled_handler`/`trigger_handler` in `set_handlers`, and the `FilesAPI`/`_TalkAPI`/etc. sync counterparts. The async classes (`AsyncNextcloud`, `AsyncNextcloudApp`, `AsyncTalkBot`, `anc_app`, `atalk_bot_msg`, …) are now the only implementation and have been renamed to drop the `Async` prefix; their sync namesakes were deprecated in v0.30.0 and have now been deleted. Backward-compat aliases (`AsyncNextcloud = Nextcloud`, etc.) remain exported for migration convenience and will be removed in a future major release.
- The `caldav` integration is no longer reachable through `Nextcloud.cal` / `NextcloudApp.cal`; the underlying library is sync-only.

### Fixed

- ExApp logger handler (`setup_nextcloud_logging`) now actually delivers records to Nextcloud: the sync `logging.Handler.emit` schedules the now-async `NextcloudApp.log` on the captured event loop via `asyncio.run_coroutine_threadsafe`. Without this fix the coroutine was never awaited and log records were silently dropped.
- `fetch_models_task` (used by the default `/init` handler) no longer leaves `NextcloudApp.set_init_status` coroutines unawaited; progress updates are dispatched onto the main event loop from the `BackgroundTasks` worker thread.
- Test conftest resets `niquests` session adapters after the import-time capability/version probe so pytest-asyncio's loop populates fresh connection pools, preventing `RuntimeError: got Future <Future pending> attached to a different loop` on the first request.

## [0.30.1 - 2026-04-26]

### Added
Expand Down
18 changes: 4 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,11 @@ Python library that provides a robust and well-documented API that allows develo
* **Reliable**: Minimum number of incompatible changes.
* **Robust**: All code is covered with tests as much as possible.
* **Easy**: Designed to be easy to use.
* **Async-first**: Full async API with sync wrappers available for most modules.

### Deprecation notice: sync API

Starting with version **0.30.0**, we are gradually removing sync wrappers in favour of
the async API. The following modules have already lost their sync counterparts:
**Activity**, **Notes**, **User Status**, and **Weather Status**.

All remaining sync methods will be phased out in future releases. If you are still
using the sync `Nextcloud` / `NextcloudApp` classes, we recommend migrating to
`AsyncNextcloud` / `AsyncNextcloudApp` as soon as possible.
* **Async**: Fully async API built on top of `niquests`.

### Differences between the Nextcloud and NextcloudApp classes

The **Nextcloud** class functions as a standard Nextcloud client,
The **Nextcloud** class functions as a standard async Nextcloud client,
enabling you to make API requests using a username and password.

On the other hand, the **NextcloudApp** class is designed for creating applications for Nextcloud.<br>
Expand All @@ -52,7 +42,7 @@ from contextlib import asynccontextmanager

from fastapi import FastAPI

from nc_py_api import AsyncNextcloudApp
from nc_py_api import NextcloudApp
from nc_py_api.ex_app import AppAPIAuthMiddleware, LogLvl, run_app, set_handlers


Expand All @@ -66,7 +56,7 @@ APP = FastAPI(lifespan=lifespan)
APP.add_middleware(AppAPIAuthMiddleware)


async def enabled_handler(enabled: bool, nc: AsyncNextcloudApp) -> str:
async def enabled_handler(enabled: bool, nc: NextcloudApp) -> str:
if enabled:
await nc.log(LogLvl.WARNING, "Hello from nc_py_api.")
else:
Expand Down
4 changes: 2 additions & 2 deletions examples/as_app/talk_bot/lib/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from fastapi import BackgroundTasks, Depends, FastAPI, Response

from nc_py_api import NextcloudApp, talk_bot
from nc_py_api.ex_app import AppAPIAuthMiddleware, atalk_bot_msg, run_app, set_handlers
from nc_py_api.ex_app import AppAPIAuthMiddleware, run_app, set_handlers, talk_bot_msg


# The same stuff as for usual External Applications
Expand Down Expand Up @@ -69,7 +69,7 @@ def currency_talk_bot_process_request(message: talk_bot.TalkBotMessage):

@APP.post("/currency_talk_bot")
async def currency_talk_bot(
message: Annotated[talk_bot.TalkBotMessage, Depends(atalk_bot_msg)],
message: Annotated[talk_bot.TalkBotMessage, Depends(talk_bot_msg)],
background_tasks: BackgroundTasks,
):
# As during converting, we do not process converting locally, we perform this in background, in the background task.
Expand Down
4 changes: 2 additions & 2 deletions examples/as_app/talk_bot_ai/lib/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
from nc_py_api import NextcloudApp, talk_bot
from nc_py_api.ex_app import (
AppAPIAuthMiddleware,
atalk_bot_msg,
get_model_path,
run_app,
set_handlers,
talk_bot_msg,
)


Expand All @@ -41,7 +41,7 @@ def ai_talk_bot_process_request(message: talk_bot.TalkBotMessage):

@APP.post("/ai_talk_bot")
async def ai_talk_bot(
message: Annotated[talk_bot.TalkBotMessage, Depends(atalk_bot_msg)],
message: Annotated[talk_bot.TalkBotMessage, Depends(talk_bot_msg)],
background_tasks: BackgroundTasks,
):
if message.object_name == "message":
Expand Down
82 changes: 44 additions & 38 deletions examples/as_app/to_gif/lib/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Simplest example of files_dropdown_menu + notification."""

import asyncio
import tempfile
from contextlib import asynccontextmanager
from os import path
Expand All @@ -26,46 +27,51 @@ async def lifespan(app: FastAPI):
APP.add_middleware(AppAPIAuthMiddleware)


def convert_video_to_gif(input_file: FsNode, nc: NextcloudApp):
def _build_gif(in_path: str, out_path: str) -> None:
"""Synchronous CPU/IO-heavy work — extracted so it can run via :func:`asyncio.to_thread`."""
cap = cv2.VideoCapture(in_path)
image_lst = []
previous_frame = None
skip = 0
while True:
skip += 1
_ret, frame = cap.read()
if frame is None:
break
if skip == 2:
skip = 0
continue
if previous_frame is not None:
diff = numpy.mean(previous_frame != frame)
if diff < 0.91:
continue
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
image_lst.append(frame_rgb)
previous_frame = frame
if len(image_lst) > 60:
break
cap.release()
imageio.mimsave(out_path, image_lst)
optimize(out_path)


async def convert_video_to_gif(input_file: FsNode, nc: NextcloudApp) -> None:
save_path = path.splitext(input_file.user_path)[0] + ".gif"
nc.log(LogLvl.WARNING, f"Processing:{input_file.user_path} -> {save_path}")
await nc.log(LogLvl.WARNING, f"Processing:{input_file.user_path} -> {save_path}")
try:
with tempfile.NamedTemporaryFile(mode="w+b") as tmp_in:
nc.files.download2stream(input_file, tmp_in)
nc.log(LogLvl.WARNING, "File downloaded")
await nc.files.download2stream(input_file, tmp_in)
await nc.log(LogLvl.WARNING, "File downloaded")
tmp_in.flush()
cap = cv2.VideoCapture(tmp_in.name)
with tempfile.NamedTemporaryFile(mode="w+b", suffix=".gif") as tmp_out:
image_lst = []
previous_frame = None
skip = 0
while True:
skip += 1
_ret, frame = cap.read()
if frame is None:
break
if skip == 2:
skip = 0
continue
if previous_frame is not None:
diff = numpy.mean(previous_frame != frame)
if diff < 0.91:
continue
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
image_lst.append(frame_rgb)
previous_frame = frame
if len(image_lst) > 60:
break
cap.release()
imageio.mimsave(tmp_out.name, image_lst)
optimize(tmp_out.name)
nc.log(LogLvl.WARNING, "GIF is ready")
nc.files.upload_stream(save_path, tmp_out)
nc.log(LogLvl.WARNING, "Result uploaded")
nc.notifications.create(f"{input_file.name} finished!", f"{save_path} is waiting for you!")
except Exception as e:
nc.log(LogLvl.ERROR, str(e))
nc.notifications.create("Error occurred", "Error information was written to log file")
await asyncio.to_thread(_build_gif, tmp_in.name, tmp_out.name)
await nc.log(LogLvl.WARNING, "GIF is ready")
await nc.files.upload_stream(save_path, tmp_out)
await nc.log(LogLvl.WARNING, "Result uploaded")
await nc.notifications.create(f"{input_file.name} finished!", f"{save_path} is waiting for you!")
except Exception as e: # noqa: BLE001
await nc.log(LogLvl.ERROR, str(e))
await nc.notifications.create("Error occurred", "Error information was written to log file")


@APP.post("/video_to_gif")
Expand All @@ -79,18 +85,18 @@ async def video_to_gif(
return responses.Response()


def enabled_handler(enabled: bool, nc: NextcloudApp) -> str:
async def enabled_handler(enabled: bool, nc: NextcloudApp) -> str:
print(f"enabled={enabled}")
try:
if enabled:
nc.ui.files_dropdown_menu.register_ex(
await nc.ui.files_dropdown_menu.register_ex(
"to_gif",
"To GIF",
"/video_to_gif",
mime="video",
icon="img/icon.svg",
)
except Exception as e:
except Exception as e: # noqa: BLE001
return str(e)
return ""

Expand Down
2 changes: 1 addition & 1 deletion examples/as_client/files/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

async def main():
# run this example after ``files_upload.py`` or adjust the image file path.
nc = nc_py_api.AsyncNextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin")
nc = nc_py_api.Nextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin")
rgb_image = await nc.files.download("RGB.png")
Image.open(BytesIO(rgb_image)).show() # wrap `bytes` into BytesIO for Pillow

Expand Down
2 changes: 1 addition & 1 deletion examples/as_client/files/find.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

async def main():
# create Nextcloud client instance class
nc = nc_py_api.AsyncNextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin")
nc = nc_py_api.Nextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin")

print("Searching for all files which names ends with `.txt`:")
result = await nc.files.find(["like", "name", "%.txt"])
Expand Down
2 changes: 1 addition & 1 deletion examples/as_client/files/listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

async def main():
# create Nextcloud client instance class
nc = nc_py_api.AsyncNextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin")
nc = nc_py_api.Nextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin")

async def list_dir(directory):
# usual recursive traversing over directories
Expand Down
2 changes: 1 addition & 1 deletion examples/as_client/files/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@


async def main():
nc = nc_py_api.AsyncNextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin")
nc = nc_py_api.Nextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin")
buf = BytesIO()
Image.merge(
"RGB",
Expand Down
26 changes: 1 addition & 25 deletions nc_py_api/_preferences.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,10 @@
"""Nextcloud API for working with classics app's storage with user's context (table oc_preferences)."""

from ._misc import check_capabilities, require_capabilities
from ._session import AsyncNcSessionBasic, NcSessionBasic
from ._session import AsyncNcSessionBasic


class PreferencesAPI:
"""API for setting/removing configuration values of applications that support it."""

_ep_base: str = "/ocs/v1.php/apps/provisioning_api/api/v1/config/users"

def __init__(self, session: NcSessionBasic):
self._session = session

@property
def available(self) -> bool:
"""Returns True if the Nextcloud instance supports this feature, False otherwise."""
return not check_capabilities("provisioning_api", self._session.capabilities)

def set_value(self, app_name: str, key: str, value: str) -> None:
"""Sets the value for the key for the specific application."""
require_capabilities("provisioning_api", self._session.capabilities)
self._session.ocs("POST", f"{self._ep_base}/{app_name}/{key}", params={"configValue": value})

def delete(self, app_name: str, key: str) -> None:
"""Removes a key and its value for a specific application."""
require_capabilities("provisioning_api", self._session.capabilities)
self._session.ocs("DELETE", f"{self._ep_base}/{app_name}/{key}")


class AsyncPreferencesAPI:
"""Async API for setting/removing configuration values of applications that support it."""

_ep_base: str = "/ocs/v1.php/apps/provisioning_api/api/v1/config/users"
Expand Down
76 changes: 3 additions & 73 deletions nc_py_api/_preferences_ex.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from ._exceptions import NextcloudExceptionNotFound
from ._misc import require_capabilities
from ._session import AsyncNcSessionBasic, NcSessionBasic
from ._session import AsyncNcSessionBasic


@dataclasses.dataclass
Expand All @@ -19,64 +19,6 @@ def __init__(self, raw_data: dict):
self.value = raw_data["configvalue"]


class _BasicAppCfgPref:
_url_suffix: str

def __init__(self, session: NcSessionBasic):
self._session = session

def get_value(self, key: str, default=None) -> str | None:
"""Returns the value of the key, if found, or the specified default value."""
if not key:
raise ValueError("`key` parameter can not be empty")
require_capabilities("app_api", self._session.capabilities)
r = self.get_values([key])
if r:
return r[0].value
return default

def get_values(self, keys: list[str]) -> list[CfgRecord]:
"""Returns the :py:class:`CfgRecord` for each founded key."""
if not keys:
return []
if not all(keys):
raise ValueError("`key` parameter can not be empty")
require_capabilities("app_api", self._session.capabilities)
data = {"configKeys": keys}
results = self._session.ocs("POST", f"{self._session.ae_url}/{self._url_suffix}/get-values", json=data)
return [CfgRecord(i) for i in results]

def delete(self, keys: str | list[str], not_fail=True) -> None:
"""Deletes config/preference entries by the provided keys."""
if isinstance(keys, str):
keys = [keys]
if not keys:
return
if not all(keys):
raise ValueError("`key` parameter can not be empty")
require_capabilities("app_api", self._session.capabilities)
try:
self._session.ocs("DELETE", f"{self._session.ae_url}/{self._url_suffix}", json={"configKeys": keys})
except NextcloudExceptionNotFound as e:
if not not_fail:
raise e from None

def set_value(self, key: str, value: str, sensitive: bool | None = None) -> None:
"""Sets a value and if specified the sensitive flag for a key.

.. note:: A sensitive flag ensures key value are encrypted and truncated in Nextcloud logs.
Default for new records is ``False`` when sensitive is *unspecified*, if changes existing record and
sensitive is *unspecified* it will not change the existing `sensitive` flag.
"""
if not key:
raise ValueError("`key` parameter can not be empty")
require_capabilities("app_api", self._session.capabilities)
params: dict = {"configKey": key, "configValue": value}
if sensitive is not None:
params["sensitive"] = sensitive
self._session.ocs("POST", f"{self._session.ae_url}/{self._url_suffix}", json=params)


class _AsyncBasicAppCfgPref:
_url_suffix: str

Expand Down Expand Up @@ -135,25 +77,13 @@ async def set_value(self, key: str, value: str, sensitive: bool | None = None) -
await self._session.ocs("POST", f"{self._session.ae_url}/{self._url_suffix}", json=params)


class PreferencesExAPI(_BasicAppCfgPref):
"""User specific preferences API, available as **nc.preferences_ex.<method>**."""

_url_suffix = "ex-app/preference"


class AsyncPreferencesExAPI(_AsyncBasicAppCfgPref):
class PreferencesExAPI(_AsyncBasicAppCfgPref):
"""User specific preferences API."""

_url_suffix = "ex-app/preference"


class AppConfigExAPI(_BasicAppCfgPref):
"""Non-user(App) specific preferences API, available as **nc.appconfig_ex.<method>**."""

_url_suffix = "ex-app/config"


class AsyncAppConfigExAPI(_AsyncBasicAppCfgPref):
class AppConfigExAPI(_AsyncBasicAppCfgPref):
"""Non-user(App) specific preferences API."""

_url_suffix = "ex-app/config"
Loading