nextcloud-agent 2.3.7__tar.gz → 2.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/PKG-INFO +2 -2
  2. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/api/api_client_base.py +19 -60
  3. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/api/api_client_files.py +73 -92
  4. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/ontology/certification.json +1 -1
  5. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent.egg-info/PKG-INFO +2 -2
  6. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent.egg-info/SOURCES.txt +1 -0
  7. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent.egg-info/requires.txt +1 -1
  8. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/pyproject.toml +2 -2
  9. nextcloud_agent-2.4.0/tests/test_files_webdav.py +145 -0
  10. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/LICENSE +0 -0
  11. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/README.md +0 -0
  12. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/__init__.py +0 -0
  13. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/__main__.py +0 -0
  14. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/api/__init__.py +0 -0
  15. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/api/api_client_calendar.py +0 -0
  16. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/api/api_client_contacts.py +0 -0
  17. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/api/xml_security.py +0 -0
  18. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/api_client.py +0 -0
  19. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/auth.py +0 -0
  20. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/connector_sources.py +0 -0
  21. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/kg_ingest.py +0 -0
  22. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/mcp_config.json +0 -0
  23. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/mcp_server.py +0 -0
  24. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/ontology/__init__.py +0 -0
  25. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/ontology/fixtures/records.json +0 -0
  26. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/ontology/mappings/source.yaml +0 -0
  27. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/ontology/migrations/manifest.json +0 -0
  28. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/ontology/nextcloud.ttl +0 -0
  29. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/ontology/shapes/connector.shacl.ttl +0 -0
  30. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/prompts/file_steward.json +0 -0
  31. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-agent-development/SKILL.md +0 -0
  32. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-agent-operations/SKILL.md +0 -0
  33. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-agent-operations/agents/openai.yaml +0 -0
  34. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-agent-operations/references/catalog.md +0 -0
  35. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-files/SKILL.md +0 -0
  36. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-files/WORKFLOW.md +0 -0
  37. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-ingest/SKILL.md +0 -0
  38. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-ingest/WORKFLOW.md +0 -0
  39. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-shares/SKILL.md +0 -0
  40. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-shares/WORKFLOW.md +0 -0
  41. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent.egg-info/dependency_links.txt +0 -0
  42. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent.egg-info/entry_points.txt +0 -0
  43. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent.egg-info/top_level.txt +0 -0
  44. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/setup.cfg +0 -0
  45. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/tests/conftest.py +0 -0
  46. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/tests/test_api_client.py +0 -0
  47. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/tests/test_auth.py +0 -0
  48. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/tests/test_fleet_name.py +0 -0
  49. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/tests/test_init.py +0 -0
  50. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/tests/test_init_dynamics.py +0 -0
  51. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/tests/test_kg_ingest.py +0 -0
  52. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/tests/test_mcp_server.py +0 -0
  53. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/tests/test_publication_workflow.py +0 -0
  54. {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/tests/test_xml_security.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nextcloud-agent
3
- Version: 2.3.7
3
+ Version: 2.4.0
4
4
  Summary: Nextcloud MCP Server for Agentic AI!
5
5
  Author-email: Repository Maintainers <maintainers@knuckles.team>
6
6
  License: MIT
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3
12
12
  Requires-Python: >=3.12
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
- Requires-Dist: scion-sdk[mcp]<0.7.0,>=0.6.3
15
+ Requires-Dist: scion-sdk[mcp]<0.9,>=0.8.0
16
16
  Requires-Dist: requests>=2.34.2
17
17
  Requires-Dist: defusedxml>=0.7.1
18
18
  Requires-Dist: python-dateutil>=2.9.0.post0
@@ -1,19 +1,19 @@
1
1
  import ipaddress
2
2
  import json
3
3
  import logging
4
- import os
5
4
  from collections.abc import Iterator
6
5
  from typing import Any, Protocol
7
6
  from urllib.parse import quote, urljoin, urlsplit
8
7
 
9
8
  import requests
9
+ from scion_sdk.config import csv_values, setting
10
+ from scion_sdk.storage import WebDavBackend
10
11
  from scion_sdk.tls.profile import ResolvedTLSProfile
11
12
  from scion_sdk.tls.resolve import resolve_tls_profile
12
13
 
13
14
  from nextcloud_agent.api.xml_security import (
14
15
  MAX_XML_BYTES,
15
16
  XmlSecurityError,
16
- parse_untrusted_xml,
17
17
  )
18
18
 
19
19
  logger = logging.getLogger(__name__)
@@ -163,9 +163,26 @@ class BaseApiClient:
163
163
  )
164
164
  self.carddav_base = f"{self.base_url}/remote.php/dav/addressbooks/users/{quote(self.username, safe='')}"
165
165
  self.ocs_base = f"{self.base_url}/ocs/v2.php"
166
+ self._files_dav: WebDavBackend | None = None
167
+
168
+ @property
169
+ def files_dav(self) -> WebDavBackend:
170
+ """The SDK WebDAV backend on the files root (CONN-NEXTCLOUD-AGENT-R002)."""
171
+ if self._files_dav is None:
172
+ hosts = tuple(csv_values(setting("NEXTCLOUD_ALLOWED_PRIVATE_HOSTS", "")))
173
+ self._files_dav = WebDavBackend(
174
+ self.webdav_base,
175
+ username=self.username,
176
+ password=self.password,
177
+ tls=self.tls_profile,
178
+ allowed_private_hosts=hosts,
179
+ )
180
+ return self._files_dav
166
181
 
167
182
  def close(self) -> None:
168
183
  """Close transport state and erase any profile materialized for this client."""
184
+ if self._files_dav is not None:
185
+ self._files_dav.close()
169
186
  self._session.close()
170
187
  if self._owns_tls_profile:
171
188
  self.tls_profile.cleanup()
@@ -240,64 +257,6 @@ class BaseApiClient:
240
257
  finally:
241
258
  response.close()
242
259
 
243
- def _parse_propfind_response(
244
- self, xml_content: str | bytes | bytearray
245
- ) -> list[dict]:
246
- """Parse PROPFIND XML response into a list of file dictionaries."""
247
- try:
248
- root = parse_untrusted_xml(xml_content)
249
- except XmlSecurityError:
250
- return []
251
-
252
- ns = {
253
- "d": "DAV:",
254
- "oc": "http://owncloud.org/ns",
255
- "nc": "http://nextcloud.org/ns",
256
- }
257
-
258
- files = []
259
- for response in root.findall("d:response", ns):
260
- href = response.find("d:href", ns).text # type: ignore[union-attr]
261
- propstat = response.find("d:propstat", ns)
262
- if propstat is None:
263
- continue
264
-
265
- prop = propstat.find("d:prop", ns)
266
- if prop is None:
267
- continue
268
-
269
- file_data = {
270
- "href": href,
271
- "name": os.path.basename(href.rstrip("/")), # type: ignore[union-attr]
272
- "is_folder": href.endswith("/"), # type: ignore[union-attr]
273
- "last_modified": prop.findtext(
274
- "d:getlastmodified", default="", namespaces=ns
275
- ),
276
- "etag": prop.findtext("d:getetag", default="", namespaces=ns),
277
- "content_type": prop.findtext(
278
- "d:getcontenttype", default="", namespaces=ns
279
- ),
280
- "content_length": prop.findtext(
281
- "d:getcontentlength", default="0", namespaces=ns
282
- ),
283
- "permissions": prop.findtext(
284
- "oc:permissions", default="", namespaces=ns
285
- ),
286
- "favorite": prop.findtext("oc:favorite", default="0", namespaces=ns),
287
- "file_id": prop.findtext("oc:fileid", default="", namespaces=ns),
288
- }
289
-
290
- resourcetype = prop.find("d:resourcetype", ns)
291
- if (
292
- resourcetype is not None
293
- and resourcetype.find("d:collection", ns) is not None
294
- ):
295
- file_data["is_folder"] = True
296
-
297
- files.append(file_data)
298
-
299
- return files
300
-
301
260
  def _get_absolute_url(self, href: str) -> str:
302
261
  """Convert a WebDAV href to a full URL."""
303
262
  absolute = urljoin(self.base_url + "/", href)
@@ -1,51 +1,44 @@
1
- import os
1
+ from scion_sdk.storage import AlreadyExistsError, DavEntry, NotFoundError
2
2
 
3
3
  from nextcloud_agent.api.api_client_base import BaseApiClient
4
4
  from nextcloud_agent.api.xml_security import parse_untrusted_xml
5
5
 
6
+ _OC = "http://owncloud.org/ns"
7
+ #: The Nextcloud properties a listing keeps beside the standard DAV fields.
8
+ NC_PROPS = ((_OC, "permissions"), (_OC, "favorite"), (_OC, "fileid"))
9
+
10
+
11
+ def _entry_dict(webdav_base: str, entry: DavEntry) -> dict:
12
+ """One SDK listing entry in the connector's listing shape."""
13
+ suffix = "/" if entry.is_collection else ""
14
+ href = f"{webdav_base.split('://', 1)[-1].split('/', 1)[-1]}/{entry.path}".rstrip(
15
+ "/"
16
+ )
17
+ return {
18
+ "href": f"/{href}{suffix}",
19
+ "name": entry.name,
20
+ "is_folder": entry.is_collection,
21
+ "last_modified": entry.last_modified or "",
22
+ "etag": f'"{entry.etag}"' if entry.etag else "",
23
+ "content_type": entry.content_type or "",
24
+ "content_length": str(entry.size),
25
+ "permissions": entry.props.get("permissions", ""),
26
+ "favorite": entry.props.get("favorite") or "0",
27
+ "file_id": entry.props.get("fileid", ""),
28
+ }
6
29
 
7
- class Api(BaseApiClient):
8
- def list_contents(self, path: str = "") -> list[dict]:
9
- """List files and folders in a directory using PROPFIND."""
10
- url = self._get_full_url(path)
11
-
12
- body = """<?xml version="1.0" encoding="UTF-8"?>
13
- <d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
14
- <d:prop>
15
- <d:getlastmodified/>
16
- <d:getcontentlength/>
17
- <d:getcontenttype/>
18
- <oc:permissions/>
19
- <d:resourcetype/>
20
- <d:getetag/>
21
- <oc:favorite/>
22
- <oc:fileid/>
23
- </d:prop>
24
- </d:propfind>"""
25
-
26
- response = self._session.request(
27
- "PROPFIND",
28
- url,
29
- data=body,
30
- headers={"Depth": "1", "Content-Type": "application/xml"},
31
- stream=True,
32
- timeout=(10, 30),
33
- )
34
-
35
- if response.status_code == 404:
36
- response.close()
37
- raise FileNotFoundError("Configured remote path was not found")
38
30
 
39
- files = self._parse_propfind_response(self._read_xml_response(response))
40
-
41
- filtered_files = []
42
- for f in files:
43
- if f["name"] and f["name"] != os.path.basename(path.strip("/")):
44
- filtered_files.append(f)
45
- elif not path.strip("/") and f["name"]:
46
- filtered_files.append(f)
31
+ class Api(BaseApiClient):
32
+ """File operations run through the SDK WebDAV backend (CONN-NEXTCLOUD-AGENT-R001)."""
47
33
 
48
- return files
34
+ def list_contents(self, path: str = "") -> list[dict]:
35
+ """List files and folders in a directory (PROPFIND, depth 1)."""
36
+ self._get_full_url(path) # the shared path guard
37
+ try:
38
+ entries = self.files_dav.propfind(path.strip("/"), 1, extra_props=NC_PROPS)
39
+ except NotFoundError:
40
+ raise FileNotFoundError("Configured remote path was not found") from None
41
+ return [_entry_dict(self.webdav_base, entry) for entry in entries]
49
42
 
50
43
  def list_files(self, path: str = "") -> list[dict]:
51
44
  """Alias for list_contents to support MCP server action."""
@@ -53,42 +46,34 @@ class Api(BaseApiClient):
53
46
 
54
47
  def read_file(self, path: str) -> bytes:
55
48
  """Download a file."""
56
- url = self._get_full_url(path)
57
- response = self._session.get(url)
58
- response.raise_for_status()
59
- return response.content
49
+ self._get_full_url(path)
50
+ try:
51
+ return self.files_dav.read_path(path.strip("/"))
52
+ except NotFoundError:
53
+ raise FileNotFoundError("Configured remote path was not found") from None
60
54
 
61
55
  def write_file(
62
56
  self, path: str, content: str | bytes, overwrite: bool = True
63
57
  ) -> bool:
64
- """Upload a file."""
65
- url = self._get_full_url(path)
66
-
58
+ """Upload a file, creating missing parent folders."""
59
+ self._get_full_url(path)
67
60
  if isinstance(content, str):
68
61
  content = content.encode("utf-8")
69
-
70
- if not overwrite:
71
- headers = {"If-None-Match": "*"}
72
- else:
73
- headers = {}
74
-
75
- response = self._session.put(url, data=content, headers=headers)
76
-
77
- if not overwrite and response.status_code == 412:
78
- raise FileExistsError("Configured remote file already exists")
79
-
80
- response.raise_for_status()
62
+ try:
63
+ self.files_dav.write_path(path.strip("/"), content, overwrite=overwrite)
64
+ except AlreadyExistsError:
65
+ raise FileExistsError("Configured remote file already exists") from None
81
66
  return True
82
67
 
83
68
  def create_directory(self, path: str) -> bool:
84
69
  """Create a directory (MKCOL)."""
85
- url = self._get_full_url(path)
86
- response = self._session.request("MKCOL", url)
87
-
88
- if response.status_code == 405:
89
- raise FileExistsError("Configured remote directory likely already exists")
90
-
91
- response.raise_for_status()
70
+ self._get_full_url(path)
71
+ try:
72
+ self.files_dav.make_collection(path.strip("/"), exist_ok=False)
73
+ except AlreadyExistsError:
74
+ raise FileExistsError(
75
+ "Configured remote directory likely already exists"
76
+ ) from None
92
77
  return True
93
78
 
94
79
  def create_folder(self, path: str) -> bool:
@@ -97,30 +82,36 @@ class Api(BaseApiClient):
97
82
 
98
83
  def delete_resource(self, path: str) -> bool:
99
84
  """Delete a file or directory."""
100
- url = self._get_full_url(path)
101
- response = self._session.delete(url)
102
- response.raise_for_status()
85
+ self._get_full_url(path)
86
+ try:
87
+ self.files_dav.delete_path(path.strip("/"))
88
+ except NotFoundError:
89
+ raise FileNotFoundError("Configured remote path was not found") from None
103
90
  return True
104
91
 
105
92
  def delete_item(self, path: str) -> bool:
106
93
  """Alias for delete_resource to support MCP server action."""
107
94
  return self.delete_resource(path)
108
95
 
96
+ def _transfer(self, move: bool, source: str, dest: str, overwrite: bool) -> bool:
97
+ self._get_full_url(source)
98
+ self._get_full_url(dest)
99
+ method = self.files_dav.move_path if move else self.files_dav.copy_path
100
+ try:
101
+ method(source.strip("/"), dest.strip("/"), overwrite=overwrite)
102
+ except AlreadyExistsError:
103
+ raise FileExistsError(
104
+ "Configured remote destination already exists"
105
+ ) from None
106
+ except NotFoundError:
107
+ raise FileNotFoundError("Configured remote path was not found") from None
108
+ return True
109
+
109
110
  def move_resource(
110
111
  self, source_path: str, dest_path: str, overwrite: bool = False
111
112
  ) -> bool:
112
113
  """Move a file or directory."""
113
- source_url = self._get_full_url(source_path)
114
- dest_url = self._get_full_url(dest_path)
115
-
116
- headers = {"Destination": dest_url, "Overwrite": "T" if overwrite else "F"}
117
- response = self._session.request("MOVE", source_url, headers=headers)
118
-
119
- if response.status_code == 412:
120
- raise FileExistsError("Configured remote destination already exists")
121
-
122
- response.raise_for_status()
123
- return True
114
+ return self._transfer(True, source_path, dest_path, overwrite)
124
115
 
125
116
  def move_item(
126
117
  self, source_path: str, dest_path: str, overwrite: bool = False
@@ -132,17 +123,7 @@ class Api(BaseApiClient):
132
123
  self, source_path: str, dest_path: str, overwrite: bool = False
133
124
  ) -> bool:
134
125
  """Copy a file or directory."""
135
- source_url = self._get_full_url(source_path)
136
- dest_url = self._get_full_url(dest_path)
137
-
138
- headers = {"Destination": dest_url, "Overwrite": "T" if overwrite else "F"}
139
- response = self._session.request("COPY", source_url, headers=headers)
140
-
141
- if response.status_code == 412:
142
- raise FileExistsError("Configured remote destination already exists")
143
-
144
- response.raise_for_status()
145
- return True
126
+ return self._transfer(False, source_path, dest_path, overwrite)
146
127
 
147
128
  def copy_item(
148
129
  self, source_path: str, dest_path: str, overwrite: bool = False
@@ -29,5 +29,5 @@
29
29
  "signer": "UNSIGNED-PREVIEW",
30
30
  "signing_public_key": "",
31
31
  "status": "source-validated",
32
- "validated_at": "2026-10-30T22:45:59Z"
32
+ "validated_at": "2026-11-05T11:12:30Z"
33
33
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nextcloud-agent
3
- Version: 2.3.7
3
+ Version: 2.4.0
4
4
  Summary: Nextcloud MCP Server for Agentic AI!
5
5
  Author-email: Repository Maintainers <maintainers@knuckles.team>
6
6
  License: MIT
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3
12
12
  Requires-Python: >=3.12
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
- Requires-Dist: scion-sdk[mcp]<0.7.0,>=0.6.3
15
+ Requires-Dist: scion-sdk[mcp]<0.9,>=0.8.0
16
16
  Requires-Dist: requests>=2.34.2
17
17
  Requires-Dist: defusedxml>=0.7.1
18
18
  Requires-Dist: python-dateutil>=2.9.0.post0
@@ -42,6 +42,7 @@ nextcloud_agent/skills/nextcloud-shares/WORKFLOW.md
42
42
  tests/conftest.py
43
43
  tests/test_api_client.py
44
44
  tests/test_auth.py
45
+ tests/test_files_webdav.py
45
46
  tests/test_fleet_name.py
46
47
  tests/test_init.py
47
48
  tests/test_init_dynamics.py
@@ -1,4 +1,4 @@
1
- scion-sdk[mcp]<0.7.0,>=0.6.3
1
+ scion-sdk[mcp]<0.9,>=0.8.0
2
2
  requests>=2.34.2
3
3
  defusedxml>=0.7.1
4
4
  python-dateutil>=2.9.0.post0
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nextcloud-agent"
7
- version = "2.3.7"
7
+ version = "2.4.0"
8
8
  description = "Nextcloud MCP Server for Agentic AI!"
9
9
  readme = "README.md"
10
10
  classifiers = [ "Development Status :: 3 - Alpha", "License :: OSI Approved :: MIT License", "Environment :: Console", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3",]
11
11
  requires-python = ">=3.12"
12
- dependencies = [ "scion-sdk[mcp]>=0.6.3,<0.7.0", "requests>=2.34.2", "defusedxml>=0.7.1", "python-dateutil>=2.9.0.post0", "icalendar>=7.3.0", "vobject>=0.9.9",]
12
+ dependencies = [ "scion-sdk[mcp]>=0.8.0,<0.9", "requests>=2.34.2", "defusedxml>=0.7.1", "python-dateutil>=2.9.0.post0", "icalendar>=7.3.0", "vobject>=0.9.9",]
13
13
  [[project.authors]]
14
14
  name = "Repository Maintainers"
15
15
  email = "maintainers@knuckles.team"
@@ -0,0 +1,145 @@
1
+ """CONN-NEXTCLOUD-AGENT: the file tools run through the SDK WebDAV backend."""
2
+
3
+ from urllib.parse import unquote, urlsplit
4
+
5
+ import httpx
6
+ import pytest
7
+ from scion_sdk.storage import WebDavBackend
8
+
9
+ from nextcloud_agent.api_client import NextcloudAPI
10
+
11
+ ROOT = "/remote.php/dav/files/svc"
12
+
13
+
14
+ class _Dav:
15
+ """A minimal in-memory WebDAV double."""
16
+
17
+ def __init__(self):
18
+ self.files, self.cols, self.requests = {}, {""}, []
19
+
20
+ def _path(self, url):
21
+ return unquote(urlsplit(str(url)).path)[len(ROOT) :].strip("/")
22
+
23
+ def _resp(self, path):
24
+ href = f"{ROOT}/{path}".rstrip("/") + ("/" if path in self.cols else "")
25
+ if path in self.cols:
26
+ props = "<d:resourcetype><d:collection/></d:resourcetype>"
27
+ else:
28
+ props = f"<d:resourcetype/><d:getcontentlength>{len(self.files[path])}</d:getcontentlength>"
29
+ props += "<oc:permissions>RGDNVW</oc:permissions><oc:favorite>1</oc:favorite><oc:fileid>7</oc:fileid>"
30
+ return f"<d:response><d:href>{href}</d:href><d:propstat><d:prop>{props}</d:prop></d:propstat></d:response>"
31
+
32
+ def handle(self, request):
33
+ path, method = self._path(request.url), request.method
34
+ self.requests.append((method, path))
35
+ if method == "PROPFIND":
36
+ if path not in self.cols and path not in self.files:
37
+ return httpx.Response(404)
38
+ items = (
39
+ [path]
40
+ + sorted(
41
+ p
42
+ for p in (*self.files, *self.cols)
43
+ if p and p.rsplit("/", 1)[0] == path and p != path
44
+ )
45
+ if path
46
+ else [path]
47
+ + sorted(p for p in (*self.files, *self.cols) if p and "/" not in p)
48
+ )
49
+ body = (
50
+ '<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">'
51
+ + "".join(self._resp(p) for p in items)
52
+ + "</d:multistatus>"
53
+ )
54
+ return httpx.Response(207, content=body.encode())
55
+ if method == "MKCOL":
56
+ if path in self.cols:
57
+ return httpx.Response(405)
58
+ self.cols.add(path)
59
+ return httpx.Response(201)
60
+ if method == "PUT":
61
+ if request.headers.get("If-None-Match") == "*" and path in self.files:
62
+ return httpx.Response(412)
63
+ self.files[path] = request.read()
64
+ return httpx.Response(201)
65
+ if method == "GET":
66
+ return (
67
+ httpx.Response(200, content=self.files[path])
68
+ if path in self.files
69
+ else httpx.Response(404)
70
+ )
71
+ if method == "DELETE":
72
+ return (
73
+ httpx.Response(204)
74
+ if self.files.pop(path, None) is not None
75
+ else httpx.Response(404)
76
+ )
77
+ if method in ("COPY", "MOVE"):
78
+ target = self._path(request.headers["Destination"])
79
+ self.files[target] = self.files[path]
80
+ if method == "MOVE":
81
+ del self.files[path]
82
+ return httpx.Response(201)
83
+ return httpx.Response(405)
84
+
85
+
86
+ def _client(dav):
87
+ client = NextcloudAPI(
88
+ base_url="https://cloud.example", username="svc", password="pw"
89
+ )
90
+ client._files_dav = WebDavBackend(
91
+ client.webdav_base,
92
+ username="svc",
93
+ password="pw",
94
+ transport=httpx.MockTransport(dav.handle),
95
+ )
96
+ return client
97
+
98
+
99
+ @pytest.mark.spec("CONN-NEXTCLOUD-AGENT-R001")
100
+ def test_file_methods_route_through_sdk_webdav_backend():
101
+ dav = _Dav()
102
+ client = _client(dav)
103
+ assert client.write_file("Docs/a.txt", "hello") is True
104
+ assert [m for m, _ in dav.requests][:2] == ["MKCOL", "PUT"]
105
+ with pytest.raises(FileExistsError):
106
+ client.write_file("Docs/a.txt", "again", overwrite=False)
107
+ assert client.read_file("Docs/a.txt") == b"hello"
108
+ listing = {e["name"]: e for e in client.list_files("Docs")}
109
+ entry = listing["a.txt"]
110
+ assert (
111
+ entry["permissions"] == "RGDNVW"
112
+ and entry["favorite"] == "1"
113
+ and entry["file_id"] == "7"
114
+ )
115
+ assert entry["is_folder"] is False and entry["content_length"] == "5"
116
+ client.copy_item("Docs/a.txt", "Docs/b.txt")
117
+ client.move_item("Docs/b.txt", "Docs/c.txt")
118
+ client.delete_item("Docs/c.txt")
119
+ assert set(dav.files) == {"Docs/a.txt"}
120
+ with pytest.raises(FileNotFoundError):
121
+ client.read_file("Docs/missing.txt")
122
+ with pytest.raises(ValueError):
123
+ client.read_file("../escape")
124
+
125
+
126
+ @pytest.mark.spec("CONN-NEXTCLOUD-AGENT-R002")
127
+ def test_backend_uses_files_root_and_private_host_setting(monkeypatch):
128
+ client = NextcloudAPI(
129
+ base_url="https://cloud.example", username="svc", password="pw"
130
+ )
131
+ assert client.webdav_base.endswith("/remote.php/dav/files/svc")
132
+ monkeypatch.delenv("NEXTCLOUD_ALLOWED_PRIVATE_HOSTS", raising=False)
133
+ private = NextcloudAPI(base_url="https://10.9.8.7", username="svc", password="pw")
134
+ with pytest.raises(httpx.TransportError, match="rejected"):
135
+ private.read_file("a.txt")
136
+ monkeypatch.setenv("NEXTCLOUD_ALLOWED_PRIVATE_HOSTS", "10.9.8.7")
137
+ allowed = NextcloudAPI(base_url="https://10.9.8.7", username="svc", password="pw")
138
+ assert allowed.files_dav is not None
139
+
140
+
141
+ @pytest.mark.spec("CONN-NEXTCLOUD-AGENT-R003")
142
+ def test_sdk_pin_carries_storage_backend():
143
+ from scion_sdk.storage import WebDavBackend as Backend
144
+
145
+ assert Backend.backend_type == "webdav"
File without changes