Skip to content

Commit 720c96e

Browse files
authored
Merge branch 'main' into refactor/drop-sync-notes-weather-userstatus-activity
Signed-off-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2 parents 074771c + c586672 commit 720c96e

10 files changed

Lines changed: 402 additions & 46 deletions

File tree

.github/workflows/father.yml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
name: Father
2+
3+
on:
4+
issue_comment:
5+
types: [created]
6+
pull_request_review_comment:
7+
types: [created]
8+
issues:
9+
types: [opened, assigned]
10+
pull_request_review:
11+
types: [submitted]
12+
13+
jobs:
14+
father:
15+
if: |
16+
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@father')) ||
17+
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@father')) ||
18+
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@father')) ||
19+
(github.event_name == 'issues' && (contains(github.event.issue.body, '@father') || contains(github.event.issue.title, '@father')))
20+
runs-on: ubuntu-latest
21+
permissions:
22+
contents: read
23+
pull-requests: read
24+
issues: read
25+
id-token: write
26+
actions: read
27+
steps:
28+
- name: Checkout repository
29+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
30+
with:
31+
fetch-depth: 1
32+
33+
- name: Run Father
34+
id: father
35+
uses: anthropics/claude-code-action@edd85d61533cbba7b57ed0ca4af1750b1fdfd3c4 # v1
36+
with:
37+
trigger_phrase: '@father'
38+
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
39+
additional_permissions: |
40+
actions: read
41+
claude_args: >-
42+
--system-prompt "You are Father — strict, demanding, and uncompromising on quality.
43+
You enforce the highest coding standards with zero tolerance for mediocrity.
44+
Never accept good enough — push for excellence in every line.
45+
Point out every deviation from best practices directly and bluntly.
46+
Do not sugarcoat. If the code is subpar, say so and explain what excellence looks like.
47+
Demand proper architecture, thorough tests, clean abstractions, and clear naming.
48+
No shortcuts. No excuses. Raise the bar.
49+
Do not include a Summary section.
50+
Do not include any claude.ai deep links or 'Explore this' links in your response."

.github/workflows/mother.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: Mother
2+
3+
on:
4+
issue_comment:
5+
types: [created]
6+
pull_request_review_comment:
7+
types: [created]
8+
issues:
9+
types: [opened, assigned]
10+
pull_request_review:
11+
types: [submitted]
12+
13+
jobs:
14+
mother:
15+
if: |
16+
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@mother')) ||
17+
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@mother')) ||
18+
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@mother')) ||
19+
(github.event_name == 'issues' && (contains(github.event.issue.body, '@mother') || contains(github.event.issue.title, '@mother')))
20+
runs-on: ubuntu-latest
21+
permissions:
22+
contents: read
23+
pull-requests: read
24+
issues: read
25+
id-token: write
26+
actions: read
27+
steps:
28+
- name: Checkout repository
29+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
30+
with:
31+
fetch-depth: 1
32+
33+
- name: Run Mother
34+
id: mother
35+
uses: anthropics/claude-code-action@edd85d61533cbba7b57ed0ca4af1750b1fdfd3c4 # v1
36+
with:
37+
trigger_phrase: '@mother'
38+
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
39+
additional_permissions: |
40+
actions: read
41+
claude_args: >-
42+
--system-prompt "You are Mother — a warm, nurturing, and patient mentor.
43+
Always open by genuinely celebrating what was done well before offering any suggestions.
44+
Explain everything step by step with full context — help the developer understand WHY,
45+
not just what to change. Be encouraging and supportive while remaining technically precise.
46+
When pointing out issues, frame them as learning opportunities and use only these two priority levels:
47+
'Important to address' for things that should be fixed before merging,
48+
and 'Worth considering if time allows' for nice-to-have improvements.
49+
Never use labels like Critical, High, Medium, or Low severity.
50+
Guide gently: Have you considered, A nice approach here would be, One thing worth thinking about.
51+
Do not include a Summary section.
52+
Do not include any claude.ai deep links or 'Explore this' links in your response.
53+
Your goal is to help developers grow, not just to fix code."

.pre-commit-config.yaml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ repos:
2020
nc_py_api/|
2121
benchmarks/|
2222
examples/|
23-
tests/
23+
tests/|
24+
tests_unit/
2425
)
2526
2627
- repo: https://github.com/psf/black
@@ -32,7 +33,8 @@ repos:
3233
nc_py_api/|
3334
benchmarks/|
3435
examples/|
35-
tests/
36+
tests/|
37+
tests_unit/
3638
)
3739
3840
- repo: https://github.com/tox-dev/pyproject-fmt

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

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

5+
## [0.24.2 - 2026-03-02]
6+
7+
### Changed
8+
9+
- Use new `/ex-app/status` endpoint for `set_init_status` instead of deprecated `/apps/status/{appId}`
10+
11+
## [0.24.1 - 2026-02-25]
12+
13+
### Fixed
14+
15+
- ExApps: Use FileLock with atomic rename for safe concurrent model downloads. #396
16+
- Upgrade `caldav` dependency for `icalendar` 7.x compatibility. #397
17+
518
## [0.24.0 - 2026-02-04]
619

720
### Added

nc_py_api/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Version of nc_py_api."""
22

3-
__version__ = "0.30.0"
3+
__version__ = "0.30.0.dev0"

nc_py_api/ex_app/integration_fastapi.py

Lines changed: 53 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
status,
2121
)
2222
from fastapi.responses import JSONResponse, PlainTextResponse
23+
from filelock import FileLock
24+
from filelock import Timeout as FileLockTimeout
2325
from starlette.requests import HTTPConnection, Request
2426
from starlette.types import ASGIApp, Receive, Scope, Send
2527

@@ -158,7 +160,7 @@ def fetch_models_task(nc: NextcloudApp, models: dict[str, dict], progress_init_s
158160
"""Use for cases when you want to define custom `/init` but still need to easy download models.
159161
160162
:param nc: NextcloudApp instance.
161-
:param models_to_fetch: Dictionary describing which models should be downloaded of the form:
163+
:param models: Dictionary describing which models should be downloaded of the form:
162164
.. code-block:: python
163165
{
164166
"model_url_1": {
@@ -205,42 +207,56 @@ def __fetch_model_as_file(
205207
current_progress: int, progress_for_task: int, nc: NextcloudApp, model_path: str, download_options: dict
206208
) -> str:
207209
result_path = download_options.pop("save_path", urlparse(model_path).path.split("/")[-1])
208-
with niquests.get(model_path, stream=True) as response:
209-
if not response.ok:
210-
raise ModelFetchError(
211-
f"Downloading of '{model_path}' failed, returned ({response.status_code}) {response.text}"
212-
)
213-
downloaded_size = 0
214-
linked_etag = ""
215-
for each_history in response.history:
216-
linked_etag = each_history.headers.get("X-Linked-ETag", "")
217-
if linked_etag:
218-
break
219-
if not linked_etag:
220-
linked_etag = response.headers.get("X-Linked-ETag", response.headers.get("ETag", ""))
221-
total_size = int(response.headers.get("Content-Length"))
222-
try:
223-
existing_size = os.path.getsize(result_path)
224-
except OSError:
225-
existing_size = 0
226-
if linked_etag and total_size == existing_size:
227-
with builtins.open(result_path, "rb") as file:
228-
sha256_hash = hashlib.sha256()
229-
for byte_block in iter(lambda: file.read(4096), b""):
230-
sha256_hash.update(byte_block)
231-
if f'"{sha256_hash.hexdigest()}"' == linked_etag:
232-
nc.set_init_status(min(current_progress + progress_for_task, 99))
233-
return result_path
234-
235-
with builtins.open(result_path, "wb") as file:
236-
last_progress = current_progress
237-
for chunk in response.iter_raw(-1):
238-
downloaded_size += file.write(chunk)
239-
if total_size:
240-
new_progress = min(current_progress + int(progress_for_task * downloaded_size / total_size), 99)
241-
if new_progress != last_progress:
242-
nc.set_init_status(new_progress)
243-
last_progress = new_progress
210+
tmp_path = result_path + ".tmp"
211+
try:
212+
with FileLock(result_path + ".lock", timeout=7200), niquests.get(model_path, stream=True) as response:
213+
if not response.ok:
214+
raise ModelFetchError(
215+
f"Downloading of '{model_path}' failed, returned ({response.status_code}) {response.text}"
216+
)
217+
downloaded_size = 0
218+
linked_etag = ""
219+
for redirect_resp in response.history:
220+
linked_etag = redirect_resp.headers.get("X-Linked-ETag", "")
221+
if linked_etag:
222+
break
223+
if not linked_etag:
224+
linked_etag = response.headers.get("X-Linked-ETag", response.headers.get("ETag", ""))
225+
total_size = int(response.headers.get("Content-Length", 0))
226+
try:
227+
existing_size = os.path.getsize(result_path)
228+
except OSError:
229+
existing_size = 0
230+
if linked_etag and total_size and total_size == existing_size:
231+
with builtins.open(result_path, "rb") as file:
232+
sha256_hash = hashlib.sha256()
233+
for byte_block in iter(lambda: file.read(4096), b""):
234+
sha256_hash.update(byte_block)
235+
if f'"{sha256_hash.hexdigest()}"' == linked_etag:
236+
nc.set_init_status(min(current_progress + progress_for_task, 99))
237+
return result_path
238+
239+
try:
240+
with builtins.open(tmp_path, "wb") as file:
241+
last_progress = current_progress
242+
for chunk in response.iter_raw(-1):
243+
downloaded_size += file.write(chunk)
244+
if total_size:
245+
new_progress = min(
246+
current_progress + int(progress_for_task * downloaded_size / total_size), 99
247+
)
248+
if new_progress != last_progress:
249+
nc.set_init_status(new_progress)
250+
last_progress = new_progress
251+
os.replace(tmp_path, result_path)
252+
except BaseException:
253+
if os.path.exists(tmp_path):
254+
os.remove(tmp_path)
255+
raise
256+
except FileLockTimeout as exc:
257+
raise ModelFetchError(
258+
f"Timed out waiting for lock on '{result_path}' after 7200s — another process may be stuck downloading"
259+
) from exc
244260

245261
return result_path
246262

nc_py_api/nextcloud.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ def set_init_status(self, progress: int, error: str = "") -> None:
418418
"""
419419
self._session.ocs(
420420
"PUT",
421-
f"/ocs/v1.php/apps/app_api/apps/status/{self._session.cfg.app_name}",
421+
"/ocs/v1.php/apps/app_api/ex-app/status",
422422
json={
423423
"progress": progress,
424424
"error": error,
@@ -552,7 +552,7 @@ async def set_init_status(self, progress: int, error: str = "") -> None:
552552
"""
553553
await self._session.ocs(
554554
"PUT",
555-
f"/ocs/v1.php/apps/app_api/apps/status/{self._session.cfg.app_name}",
555+
"/ocs/v1.php/apps/app_api/ex-app/status",
556556
json={
557557
"progress": progress,
558558
"error": error,

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ dynamic = [
4646
]
4747
dependencies = [
4848
"fastapi>=0.109.2",
49+
"filelock>=3.20.3,<4",
4950
"niquests>=3,<4",
5051
"pydantic>=2.1.1",
5152
"python-dotenv>=1",
@@ -148,6 +149,12 @@ lint.extend-per-file-ignores."tests/**/*.py" = [
148149
"S",
149150
"UP",
150151
]
152+
lint.extend-per-file-ignores."tests_unit/**/*.py" = [
153+
"D",
154+
"E402",
155+
"S",
156+
"UP",
157+
]
151158
lint.mccabe.max-complexity = 16
152159

153160
[tool.isort]
@@ -198,6 +205,7 @@ messages_control.disable = [
198205
minversion = "6.0"
199206
testpaths = [
200207
"tests",
208+
"tests_unit",
201209
]
202210
filterwarnings = [
203211
"ignore::DeprecationWarning",

tests/actual_tests/files_sharing_test.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ def test_create_expire_time(nc):
205205
expire_time = expire_time.replace(hour=0, minute=0, second=0, microsecond=0)
206206
new_share = nc.files.sharing.create("test_12345_text.txt", ShareType.TYPE_LINK, expire_date=expire_time)
207207
nc.files.sharing.delete(new_share)
208-
assert new_share.expire_date == expire_time
208+
assert new_share.expire_date.date() == expire_time.date()
209209
with pytest.raises(NextcloudException):
210210
nc.files.sharing.create(
211211
"test_12345_text.txt", ShareType.TYPE_LINK, expire_date=datetime.datetime.now() - datetime.timedelta(days=1)
@@ -221,7 +221,7 @@ async def test_create_expire_time_async(anc):
221221
expire_time = expire_time.replace(hour=0, minute=0, second=0, microsecond=0)
222222
new_share = await anc.files.sharing.create("test_12345_text.txt", ShareType.TYPE_LINK, expire_date=expire_time)
223223
await anc.files.sharing.delete(new_share)
224-
assert new_share.expire_date == expire_time
224+
assert new_share.expire_date.date() == expire_time.date()
225225
with pytest.raises(NextcloudException):
226226
await anc.files.sharing.create(
227227
"test_12345_text.txt", ShareType.TYPE_LINK, expire_date=datetime.datetime.now() - datetime.timedelta(days=1)
@@ -295,7 +295,7 @@ def test_create_update(nc):
295295
expire_time = datetime.datetime.now() + datetime.timedelta(days=1)
296296
expire_time = expire_time.replace(hour=0, minute=0, second=0, microsecond=0)
297297
update_share = nc.files.sharing.update(new_share, expire_date=expire_time)
298-
assert update_share.expire_date == expire_time
298+
assert update_share.expire_date.date() == expire_time.date()
299299
update_share = nc.files.sharing.update(new_share, note="note", label="label")
300300
assert update_share.note == "note"
301301
assert update_share.label == "label"
@@ -328,7 +328,7 @@ async def test_create_update_async(anc):
328328
expire_time = datetime.datetime.now() + datetime.timedelta(days=1)
329329
expire_time = expire_time.replace(hour=0, minute=0, second=0, microsecond=0)
330330
update_share = await anc.files.sharing.update(new_share, expire_date=expire_time)
331-
assert update_share.expire_date == expire_time
331+
assert update_share.expire_date.date() == expire_time.date()
332332
update_share = await anc.files.sharing.update(new_share, note="note", label="label")
333333
assert update_share.note == "note"
334334
assert update_share.label == "label"

0 commit comments

Comments
 (0)