From 419a66de79c6cc8bb465e8988e29645e72c5f4d7 Mon Sep 17 00:00:00 2001 From: Pasquale Ventrella Date: Fri, 2 Oct 2026 05:12:36 +0200 Subject: [PATCH] feat(sharepoint): add path option to sync a single library folder Scan starts at the given folder and uploaded paths are relative to it. --- docs/guide.md | 13 ++++ src/oikb/cli.py | 2 +- src/oikb/connectors/sharepoint.py | 6 +- tests/test_sharepoint.py | 106 ++++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 tests/test_sharepoint.py diff --git a/docs/guide.md b/docs/guide.md index 2d31d52..f09f4db 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -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: diff --git a/src/oikb/cli.py b/src/oikb/cli.py index 1ad59d4..b962e2e 100644 --- a/src/oikb/cli.py +++ b/src/oikb/cli.py @@ -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 diff --git a/src/oikb/connectors/sharepoint.py b/src/oikb/connectors/sharepoint.py index c382a17..75ff189 100644 --- a/src/oikb/connectors/sharepoint.py +++ b/src/oikb/connectors/sharepoint.py @@ -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 diff --git a/tests/test_sharepoint.py b/tests/test_sharepoint.py new file mode 100644 index 0000000..af8749a --- /dev/null +++ b/tests/test_sharepoint.py @@ -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()