feat(sharepoint): add path option to sync a single library folder

Scan starts at the given folder and uploaded paths are relative to it.
This commit is contained in:
Pasquale Ventrella 2026-10-02 05:12:36 +02:00
parent 5fd27193da
commit 419a66de79
4 changed files with 124 additions and 3 deletions

View file

@ -414,6 +414,19 @@ sources:
interval: 1h
```
To sync only one folder of a library and drop that folder from the uploaded paths,
set `path` (or pass `--source-path`). Files are uploaded relative to that folder:
```yaml
sources:
- name: exceptions
source: sharepoint:contoso.sharepoint.com/sites/Team/Docs
path: Exceptions # Exceptions/report.xlsx -> report.xlsx
kb-id: abc123
filter:
include: ["*.xlsx"] # filters match paths relative to `path`
```
### Nextcloud
Sync a Nextcloud folder via WebDAV:

View file

@ -141,7 +141,7 @@ def _resolve_connector(
if source.startswith("sharepoint:"):
from oikb.connectors.sharepoint import SharePointConnector, parse_sharepoint_source
parsed = parse_sharepoint_source(source)
return SharePointConnector(site=parsed["site"], site_path=parsed["site_path"], library=parsed.get("library", "Documents"), **auth)
return SharePointConnector(site=parsed["site"], site_path=parsed["site_path"], library=parsed.get("library", "Documents"), root_path=path, **auth)
if source.startswith("nextcloud:"):
from oikb.connectors.nextcloud import NextcloudConnector, parse_nextcloud_source

View file

@ -54,6 +54,7 @@ class SharePointConnector(BaseConnector):
site: str,
site_path: str = "",
library: str = "Documents",
root_path: str | None = None,
tenant_id: str | None = None,
client_id: str | None = None,
client_secret: str | None = None,
@ -64,6 +65,7 @@ class SharePointConnector(BaseConnector):
self.site = site
self.site_path = site_path.strip("/")
self.library = library
self.root_path = (root_path or "").strip("/")
tid = tenant_id or os.environ.get("SHAREPOINT_TENANT_ID", "")
cid = client_id or os.environ.get("SHAREPOINT_CLIENT_ID", "")
@ -141,7 +143,7 @@ class SharePointConnector(BaseConnector):
def build_manifest(self) -> list[ManifestEntry]:
entries: list[ManifestEntry] = []
self._walk_folder("/", "", entries)
self._walk_folder(self.root_path or "/", "", entries)
entries.sort(key=lambda e: e.display_path)
return entries
@ -165,7 +167,7 @@ class SharePointConnector(BaseConnector):
))
def read_file(self, path: str, filename: str) -> bytes:
file_path = f"{path}/{filename}" if path else filename
file_path = "/".join(p for p in (self.root_path, path, filename) if p)
resp = self._http.get(f"/drives/{self._drive_id}/root:/{quote(file_path)}:/content", follow_redirects=True)
resp.raise_for_status()
return resp.content

106
tests/test_sharepoint.py Normal file
View file

@ -0,0 +1,106 @@
from __future__ import annotations
import httpx
import pytest
import respx
from oikb.cli import _resolve_connector
from oikb.connectors.sharepoint import SharePointConnector
GRAPH = "https://graph.microsoft.com/v1.0"
SOURCE = "sharepoint:contoso.sharepoint.com/sites/Team/Docs"
@pytest.fixture(autouse=True)
def _sharepoint_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("SHAREPOINT_TENANT_ID", "tenant")
monkeypatch.setenv("SHAREPOINT_CLIENT_ID", "client")
monkeypatch.setenv("SHAREPOINT_CLIENT_SECRET", "secret")
def _mock_graph(router: respx.MockRouter) -> None:
router.post("https://login.microsoftonline.com/tenant/oauth2/v2.0/token").respond(
json={"access_token": "token"}
)
router.get(f"{GRAPH}/sites/contoso.sharepoint.com:/sites/Team").respond(json={"id": "site-id"})
router.get(f"{GRAPH}/sites/site-id/drives").respond(json={"value": [{"id": "drive-id", "name": "Docs"}]})
router.get(f"{GRAPH}/drives/drive-id/root/children").respond(
json={
"value": [
{"name": "Exceptions", "folder": {}},
{"name": "top.pdf", "file": {}, "eTag": '"top-etag"', "size": 1},
]
}
)
router.get(f"{GRAPH}/drives/drive-id/root:/Exceptions:/children").respond(
json={
"value": [
{"name": "VM_POWERED_OFF.xlsx", "file": {}, "eTag": '"vm-etag"', "size": 2},
{"name": "nested", "folder": {}},
]
}
)
router.get(f"{GRAPH}/drives/drive-id/root:/Exceptions/nested:/children").respond(
json={"value": [{"name": "deep.xlsx", "file": {}, "eTag": '"deep-etag"', "size": 3}]}
)
@respx.mock(assert_all_called=False)
def test_default_root_is_library_root(respx_mock: respx.MockRouter) -> None:
_mock_graph(respx_mock)
manifest = _resolve_connector(SOURCE).build_manifest()
assert [e.display_path for e in manifest] == [
"Exceptions/VM_POWERED_OFF.xlsx",
"Exceptions/nested/deep.xlsx",
"top.pdf",
]
@respx.mock(assert_all_called=False)
def test_root_path_scopes_manifest_and_strips_the_folder(respx_mock: respx.MockRouter) -> None:
_mock_graph(respx_mock)
manifest = _resolve_connector(SOURCE, path="Exceptions").build_manifest()
assert [e.display_path for e in manifest] == [
"VM_POWERED_OFF.xlsx",
"nested/deep.xlsx",
]
@respx.mock(assert_all_called=False)
def test_root_path_tolerates_surrounding_slashes(respx_mock: respx.MockRouter) -> None:
_mock_graph(respx_mock)
manifest = _resolve_connector(SOURCE, path="/Exceptions/").build_manifest()
assert [e.display_path for e in manifest] == ["VM_POWERED_OFF.xlsx", "nested/deep.xlsx"]
@respx.mock(assert_all_called=False)
def test_read_file_resolves_paths_against_root_path(respx_mock: respx.MockRouter) -> None:
_mock_graph(respx_mock)
root_file = respx_mock.get(f"{GRAPH}/drives/drive-id/root:/Exceptions/VM_POWERED_OFF.xlsx:/content").respond(
content=b"top"
)
nested_file = respx_mock.get(f"{GRAPH}/drives/drive-id/root:/Exceptions/nested/deep.xlsx:/content").respond(
content=b"deep"
)
connector = SharePointConnector(site="contoso.sharepoint.com", site_path="sites/Team", library="Docs", root_path="Exceptions")
assert connector.read_file("", "VM_POWERED_OFF.xlsx") == b"top"
assert connector.read_file("nested", "deep.xlsx") == b"deep"
assert root_file.called and nested_file.called
@respx.mock(assert_all_called=False)
def test_missing_root_path_raises(respx_mock: respx.MockRouter) -> None:
_mock_graph(respx_mock)
respx_mock.get(f"{GRAPH}/drives/drive-id/root:/Nope:/children").respond(
404, json={"error": {"code": "itemNotFound"}}
)
with pytest.raises(httpx.HTTPStatusError):
_resolve_connector(SOURCE, path="Nope").build_manifest()