sharepoint-manager 0.0.4__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Vinicius Benevides
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: sharepoint_manager
3
+ Version: 0.0.4
4
+ Summary: Library for interacting with sharepoint using Microsoft Graph API
5
+ Home-page: https://github.com/VBenevides/sharepoint_manager
6
+ Author: Vinicius Benevides
7
+ Author-email: viniciusm.benevides@gmail.com
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Requires-Dist: msal
11
+ Dynamic: author
12
+ Dynamic: author-email
13
+ Dynamic: home-page
14
+ Dynamic: license
15
+ Dynamic: license-file
16
+ Dynamic: requires-dist
17
+ Dynamic: summary
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,19 @@
1
+ """
2
+ Build wheel
3
+
4
+ Use: python setup.py bdist_wheel
5
+ """
6
+
7
+ from setuptools import setup
8
+
9
+ _ = setup(
10
+ name="sharepoint_manager",
11
+ version="0.0.4",
12
+ packages=["sharepoint_manager"],
13
+ url="https://github.com/VBenevides/sharepoint_manager",
14
+ license="MIT",
15
+ author="Vinicius Benevides",
16
+ author_email="viniciusm.benevides@gmail.com",
17
+ description="Library for interacting with sharepoint using Microsoft Graph API",
18
+ install_requires=["msal"],
19
+ )
@@ -0,0 +1,16 @@
1
+ __version__ = "0.0.3"
2
+
3
+ # Import core components
4
+ from .exceptions import SPFileNotFound, SPFolderNotEmpty, SPFolderNotFound
5
+ from .dataclasses import SPFile, SPFolder, ClientCredential
6
+ from .core import SharepointManager
7
+
8
+ __all__ = [
9
+ "SharepointManager",
10
+ "SPFile",
11
+ "SPFolder",
12
+ "ClientCredential",
13
+ "SPFileNotFound",
14
+ "SPFolderNotEmpty",
15
+ "SPFolderNotFound",
16
+ ]
@@ -0,0 +1,862 @@
1
+ """
2
+ Module used to interact with sharepoint sites using an approach similar to file systems
3
+ """
4
+
5
+ # ---------------------------------------------------------------------- #
6
+ # Imports
7
+ # ---------------------------------------------------------------------- #
8
+
9
+ from typing import Any
10
+ from collections.abc import Iterator
11
+ import requests
12
+ import os
13
+ import re
14
+ import time
15
+ import logging
16
+
17
+ from msal import ConfidentialClientApplication
18
+
19
+ from .decorators import retry_if_not_exception, retry
20
+ from .dataclasses import ClientCredential, SPFolder, SPFile
21
+ from .exceptions import SPFolderNotEmpty, SPFileNotFound, SPFolderNotFound
22
+ from .utils import get_filename, get_names_to_folder
23
+
24
+
25
+ class SharepointManager:
26
+ """
27
+ Provides an interface for interacting with a SharePoint site.
28
+
29
+
30
+ Supports uploading, downloading, listing, and deleting files/folders
31
+ using Microsoft Graph API.
32
+
33
+
34
+ Examples
35
+ --------
36
+ >>> creds = ClientCredential("app_id", "app_secret")
37
+ >>> manager = SharepointManager(
38
+ ... sharepoint_site_url="https://my_tenant.sharepoint.com/sites/my_site",
39
+ ... credentials=creds,
40
+ ... )
41
+ >>> manager.download_file(
42
+ ... file="file.txt",
43
+ ... local_download_path="./Download_Dir",
44
+ ... sp_relative_folder_path="Folder/Subfolder"
45
+ ... )
46
+ >>> manager.upload_file(
47
+ ... local_file_path="./Download_Dir/file.txt",
48
+ ... sp_relative_folder_path="Folder/Subfolder2"
49
+ ... )
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ sharepoint_site_url: str,
55
+ credentials: ClientCredential,
56
+ document_folder_name: str = "Shared Documents",
57
+ ) -> None:
58
+ """
59
+ Initializes the SharepointManager with a given SharePoint URL and credentials.
60
+
61
+ Parameters
62
+ ----------
63
+ sharepoint_site_url : str
64
+ The URL of the SharePoint site. E.g: 'https://{tenant_url}.sharepoint.com/sites/{site_name}'.
65
+ credentials : ClientCredential
66
+ Graph API credentials for authentication.
67
+ document_folder_name : str, optional
68
+ The name of the document folder in the SharePoint site. Default is "Shared Documents".
69
+
70
+ This is vital to guarantee that the class will be able to find the documents in the site.
71
+
72
+ Returns
73
+ -------
74
+ None
75
+
76
+ Examples
77
+ --------
78
+ >>> user_cred = ClientCredential("graph_id", "graph_secret") # Don't hardcode passwords
79
+ >>> manager = SharepointManager(sharepoint_site_url = "https://my_tenant.sharepoint.com/sites/my_site",
80
+ >>> credentials = user_cred,
81
+ >>> document_folder_name = "Shared Documents",
82
+ >>> )
83
+ """
84
+
85
+ self._session: requests.Session = requests.Session()
86
+
87
+ self.url: str = sharepoint_site_url
88
+ self.tenant_url: str = sharepoint_site_url.split("/sites", maxsplit=1)[0]
89
+ self.tenant_id: str = self._get_tenant_id()
90
+
91
+ # These variables shouldn't be changed manually
92
+ self.site_name: str = self.url.split("/sites/", maxsplit=1)[-1]
93
+ self.cca: ConfidentialClientApplication = ConfidentialClientApplication(
94
+ client_id=credentials.client_id,
95
+ client_credential=credentials.client_secret,
96
+ authority=f"https://login.microsoftonline.com/{self.tenant_id}",
97
+ )
98
+ self.document_folder_name: str = document_folder_name
99
+ self.relative_path_root: str = f"/sites/{self.site_name}/{document_folder_name}"
100
+ self._site_id: str = self._get_site_id()
101
+ self._drive_id: str = self._get_drive_id()
102
+
103
+ self.folder: SPFolder = self._get_folder("")
104
+ self.users: dict[str, Any] = {}
105
+
106
+ # ----------------------------------------------------------
107
+ # Support Methods
108
+ # ----------------------------------------------------------
109
+
110
+ def _get_site_id(self) -> str:
111
+ parts = [x for x in self.url.split("/") if len(x) > 0]
112
+ tenant = [x for x in parts if "share" in x.lower() or ".com" in x.lower()][0]
113
+ site = self.url.split("/sites/")[-1]
114
+ if "/sites/" not in site:
115
+ site = f"/sites/{site}"
116
+ url = f"https://graph.microsoft.com/v1.0/sites/{tenant}:{site}"
117
+ r = self._request("GET", url, headers=self._hdr(), timeout=30)
118
+ r.raise_for_status()
119
+ self._site_id = r.json()["id"]
120
+ return self._site_id
121
+
122
+ def _hdr(self, json_content: bool = False) -> dict[str, Any]:
123
+ token = self._ensure_token()
124
+ headers = {"Authorization": f"Bearer {token}"}
125
+ if json_content:
126
+ headers["Content-Type"] = "application/json"
127
+ return headers
128
+
129
+ def _get_tenant_id(self) -> str:
130
+ """Retrieve the tenant ID from the SharePoint tenant URL."""
131
+
132
+ r = self._request("HEAD", self.tenant_url, headers={"Authorization": "Bearer"}, timeout=20)
133
+ hdr = r.headers.get("WWW-Authenticate", "")
134
+ m = re.search(r'realm="([^"]+)"', hdr)
135
+ if m:
136
+ return m.group(1)
137
+ for item in hdr.split(","):
138
+ if '="' in item:
139
+ k, v = item.split("=", 1)
140
+ v = v.strip()
141
+ if v.startswith('"') and v.endswith('"'):
142
+ v = v[1:-1]
143
+ if k.strip().lower() == "bearer realm":
144
+ return str(v)
145
+
146
+ raise RuntimeError("Cannot determine tenant id from WWW-Authenticate header")
147
+
148
+ def _ensure_token(self) -> str:
149
+ # msal already has an internal cache
150
+ result = self.cca.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])
151
+ if not isinstance(result, dict) or "access_token" not in result.keys():
152
+ error = result
153
+ if isinstance(result, dict):
154
+ error = result.get("error_description", result)
155
+ raise RuntimeError(f"Authentication failed: {error}")
156
+ return str(result["access_token"])
157
+
158
+ def _get_drive_id(self) -> str:
159
+ site_id = self._site_id
160
+ r = self._request(
161
+ "GET",
162
+ f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives",
163
+ headers=self._hdr(),
164
+ timeout=30,
165
+ )
166
+ r.raise_for_status()
167
+ for d in r.json().get("value", []):
168
+ if d.get("name") == self.document_folder_name:
169
+ self._drive_id = d["id"]
170
+ return self._drive_id
171
+
172
+ r = self._request(
173
+ "GET",
174
+ f"https://graph.microsoft.com/v1.0/sites/{site_id}/drive",
175
+ headers=self._hdr(),
176
+ timeout=30,
177
+ )
178
+ if r.status_code == 200:
179
+ self._drive_id = r.json()["id"]
180
+ self.document_folder_name = r.json()["webUrl"].split("/")[-1]
181
+ return self._drive_id
182
+ raise RuntimeError("Drive not found for site")
183
+
184
+ def _get_folder(self, folder_path: str) -> SPFolder:
185
+ """folder_dict, folder_exists"""
186
+ site_id = self._site_id
187
+ drive_id = self._drive_id
188
+ if folder_path != "":
189
+ site = f":/{folder_path}"
190
+ else:
191
+ site = ""
192
+ r = self._request(
193
+ "GET",
194
+ f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives/{drive_id}/root{site}",
195
+ headers=self._hdr(),
196
+ timeout=30,
197
+ )
198
+
199
+ if r.status_code == 404:
200
+ raise SPFolderNotFound(f"SP Folder not found: {folder_path}")
201
+ r.raise_for_status()
202
+ return SPFolder.from_dict(r.json())
203
+
204
+ def _get_file(self, filename: str) -> SPFile:
205
+ site_id = self._site_id
206
+ drive_id = self._drive_id
207
+
208
+ folder_id = str(self.folder.id)
209
+ url = f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives/{drive_id}/items/{folder_id}/children"
210
+ for obj in self._paginate(url):
211
+ if isinstance(obj, dict) and obj.get("name", "") == filename:
212
+ return SPFile.from_dict(obj)
213
+ raise SPFileNotFound("SP file not found")
214
+
215
+ @retry(attempts=4, exceptions=Exception)
216
+ def _create_folder(self, folder_path: str) -> SPFolder | None:
217
+ try:
218
+ return self._get_folder(folder_path)
219
+ except Exception:
220
+ pass
221
+
222
+ drive_id = self._drive_id
223
+ parts = folder_path.split("/")
224
+ parent_folder = "/".join(parts[:-1])
225
+ folder_name = parts[-1]
226
+ try:
227
+ parent_data = self._get_folder(parent_folder)
228
+ except SPFolderNotFound:
229
+ parent_data = self._create_folder(parent_folder)
230
+
231
+ if parent_data is None:
232
+ raise SPFolderNotFound("SP Parent folder not found")
233
+
234
+ parent_id = parent_data.id
235
+
236
+ payload = {"name": folder_name, "folder": {}}
237
+ r = requests.post(
238
+ f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{parent_id}/children",
239
+ headers=self._hdr(),
240
+ timeout=30,
241
+ json=payload,
242
+ )
243
+ r.raise_for_status()
244
+ return SPFolder.from_dict(r.json())
245
+
246
+ # ----------------------------------------------------------
247
+ # Basic file system functions
248
+ # ---------------------------------------------------------
249
+
250
+ def get_file_author(self, file: SPFile) -> dict[str, dict[str, str]]:
251
+ """
252
+ Return author and editor metadata for a SharePoint file.
253
+
254
+
255
+ Parameters
256
+ ----------
257
+ file : SPFile
258
+ File object.
259
+
260
+
261
+ Returns
262
+ -------
263
+ dict
264
+ Dictionary with "author" and "editor" entries.
265
+ """
266
+
267
+ created_by = file.created_by
268
+ author = {}
269
+ user = list(created_by.keys())[0]
270
+ created_by = created_by[user]
271
+ author["id"] = created_by.get("id", "")
272
+ author["display_name"] = created_by.get("displayName", "")
273
+ author["email"] = created_by.get("email", "")
274
+
275
+ modified_by = file.last_modified_by
276
+ editor = {}
277
+ user = list(modified_by.keys())[0]
278
+ modified_by = modified_by[user]
279
+ editor["id"] = modified_by.get("id", "")
280
+ editor["display_name"] = modified_by.get("displayName", "")
281
+ editor["email"] = modified_by.get("email", "")
282
+
283
+ return {"author": author, "editor": editor}
284
+
285
+ @retry_if_not_exception(attempts=3, exceptions=(SPFolderNotFound))
286
+ def set_folder(self, sp_relative_folder_path: str, create_folder: bool = False) -> SPFolder:
287
+ """
288
+ Set the current working folder.
289
+
290
+
291
+ Parameters
292
+ ----------
293
+ sp_relative_folder_path : str
294
+ Relative path within the document library.
295
+ create_folder : bool, optional
296
+ If True, create the folder (and ancestors) if it does not exist.
297
+
298
+
299
+ Returns
300
+ -------
301
+ SPFolder
302
+ The set folder object.
303
+
304
+
305
+ Raises
306
+ ------
307
+ SPFolderNotFound
308
+ If the folder does not exist and `create_folder` is False.
309
+
310
+
311
+ Examples
312
+ --------
313
+ >>> manager = SharepointManager(...)
314
+ >>> try:
315
+ >>> manager.set_folder(sp_relative_folder_path = "Folder1/Folder2/Folder3", create_folder = False)
316
+ >>> except SPFolderNotFound:
317
+ >>> logging.info("Folder does not exist inside Sharepoint!")
318
+ >>> manager.set_folder(sp_relative_folder_path = "Folder1/Folder2/Folder3", create_folder = True) # Creates folder
319
+ """
320
+
321
+ # Change folder to the root folder (we always go from here to the target folder)
322
+ self.folder = self._get_folder("")
323
+
324
+ fnames = get_names_to_folder(sp_relative_folder_path)
325
+ if len(fnames) == 0:
326
+ return self.folder
327
+
328
+ target_folder = "/".join(fnames)
329
+ try:
330
+ folder_data = self._get_folder(target_folder)
331
+ except SPFolderNotFound:
332
+ if create_folder:
333
+ folder_data = self._create_folder(target_folder)
334
+ else:
335
+ raise SPFolderNotFound(f"SP Folder does not exist: {target_folder}")
336
+
337
+ if folder_data is None or folder_data.name != fnames[-1]:
338
+ self.folder = self._get_folder("")
339
+ raise RuntimeError("SP Folder was not set correctly")
340
+ self.folder = folder_data
341
+ return self.folder
342
+
343
+ def list_files(self, sp_relative_folder_path: str | None = None) -> dict[str, SPFile]:
344
+ """
345
+ List files in a SharePoint folder.
346
+
347
+
348
+ Parameters
349
+ ----------
350
+ sp_relative_folder_path : str, optional
351
+ Relative path within the document library. If omitted, uses the current folder.
352
+
353
+
354
+ Returns
355
+ -------
356
+ dict
357
+ Mapping of filename to SPFile objects.
358
+
359
+ Examples
360
+ --------
361
+ >>> manager = SharepointManager(...)
362
+ >>> files = manager.list_files(sp_relative_folder_path = "Folder1/Folder2/Folder3") # Changes self.folder and lists the files
363
+ """
364
+
365
+ if sp_relative_folder_path is not None:
366
+ self.folder = self.set_folder(sp_relative_folder_path)
367
+
368
+ drive_id = self._drive_id
369
+ folder_id = self.folder.id
370
+ url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{folder_id}/children"
371
+ files = {}
372
+ for item in self._paginate(url):
373
+ if "file" in item:
374
+ _file = SPFile.from_dict(item)
375
+ files[_file.name] = _file
376
+
377
+ return files
378
+
379
+ def list_folders(self, sp_relative_folder_path: str | None = None) -> dict[str, SPFolder]:
380
+ """
381
+ List subfolders in a SharePoint folder.
382
+
383
+
384
+ Parameters
385
+ ----------
386
+ sp_relative_folder_path : str, optional
387
+ Relative path within the document library. If omitted, uses the current folder.
388
+
389
+
390
+ Returns
391
+ -------
392
+ dict
393
+ Mapping of folder name to SPFolder objects.
394
+
395
+ Examples
396
+ --------
397
+ >>> manager = SharepointManager(...)
398
+ >>> folders = manager.list_folders(sp_relative_folder_path = "Folder1/Folder2/Folder3") # Changes self.folder and lists the folders
399
+ """
400
+
401
+ if sp_relative_folder_path is not None:
402
+ _ = self.set_folder(sp_relative_folder_path)
403
+
404
+ drive_id = self._drive_id
405
+ folder_id = self.folder.id
406
+ url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{folder_id}/children"
407
+ folders = {}
408
+ for item in self._paginate(url):
409
+ if "folder" in item:
410
+ _folder = SPFolder.from_dict(item)
411
+ folders[_folder.name] = _folder
412
+
413
+ return folders
414
+
415
+ # ----------------------------------------------------------
416
+ # Upload files/folders to Sharepoint
417
+ # ----------------------------------------------------------
418
+
419
+ @retry_if_not_exception(attempts=3, exceptions=(FileNotFoundError))
420
+ def upload_file(self, local_file_path: str, sp_relative_folder_path: str | None = None) -> None:
421
+ """
422
+ Upload a local file to SharePoint.
423
+
424
+
425
+ Parameters
426
+ ----------
427
+ local_file_path : str
428
+ Path to the local file.
429
+ sp_relative_folder_path : str, optional
430
+ Relative path within the document library. If omitted, uses the current folder.
431
+
432
+
433
+ Raises
434
+ ------
435
+ FileNotFoundError
436
+ If the local file does not exist or is not a file.
437
+
438
+ Examples
439
+ --------
440
+ >>> manager = SharepointManager(...)
441
+ >>> manager.upload_file(local_file_path = "file.txt", sp_relative_folder_path = "Folder1/Folder2/Folder3")
442
+ """
443
+
444
+ local_file_path = os.path.abspath(local_file_path)
445
+ if not os.path.exists(local_file_path):
446
+ raise FileNotFoundError(f"Local file does not exist: {local_file_path}")
447
+ if not os.path.isfile(local_file_path):
448
+ raise FileNotFoundError(f"Path does not correspond to a file: {local_file_path}")
449
+
450
+ if sp_relative_folder_path is not None:
451
+ _ = self.set_folder(sp_relative_folder_path, create_folder=True)
452
+
453
+ file_name = get_filename(local_file_path)
454
+ file_size_b = os.path.getsize(local_file_path)
455
+ file_size_mb = file_size_b / (1024 * 1024)
456
+
457
+ logging.info(f"Uploading file {file_name} ({file_size_mb:.1f} MB)")
458
+
459
+ with open(local_file_path, "rb") as file:
460
+ site_id = self._site_id
461
+ drive_id = self._drive_id
462
+ folder_id = self.folder.id
463
+ url = f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives/{drive_id}/items/{folder_id}:/{file_name}:/createUploadSession"
464
+ request_body = {"@microsoft.graph.conflictBehavior": "replace"}
465
+ r = self._request("POST", url, headers=self._hdr(), timeout=30, json=request_body)
466
+ r.raise_for_status()
467
+ upload_session = r.json()
468
+ upload_url = str(upload_session["uploadUrl"])
469
+
470
+ chunk_size = 20 * 327680 # 6.25 MiB
471
+ start_byte = 0
472
+ try:
473
+ while True:
474
+ chunk = file.read(chunk_size)
475
+ if not chunk:
476
+ break
477
+
478
+ end_byte = start_byte + len(chunk) - 1
479
+ content_range = f"bytes {start_byte}-{end_byte}/{file_size_b}"
480
+
481
+ chunk_headers = {
482
+ "Content-Length": str(len(chunk)),
483
+ "Content-Range": content_range,
484
+ }
485
+
486
+ for attempt in range(3):
487
+ try:
488
+ response = self._request(
489
+ "PUT",
490
+ upload_url,
491
+ headers=chunk_headers,
492
+ timeout=60,
493
+ data=chunk,
494
+ )
495
+ response.raise_for_status()
496
+ break
497
+ except Exception as e:
498
+ time.sleep(1)
499
+ if attempt >= 2:
500
+ logging.error(f"Error uploading chunk: {e}")
501
+ raise e
502
+
503
+ start_byte += len(chunk)
504
+ logging.info(
505
+ f"Uploaded {start_byte / (1024 * 1024):.1f} MiB out of {file_size_b / (1024 * 1024):.1f}"
506
+ )
507
+ finally:
508
+ try:
509
+ _ = self._request("DELETE", upload_url, timeout=30)
510
+ except Exception:
511
+ pass
512
+
513
+ logging.info("Upload completed.")
514
+
515
+ def upload_folder(
516
+ self, local_folder_path: str, sp_relative_folder_path: str | None = None
517
+ ) -> None:
518
+ """
519
+ Recursively upload a local folder and its contents to SharePoint.
520
+
521
+
522
+ Parameters
523
+ ----------
524
+ local_folder_path : str
525
+ Path to the local folder.
526
+ sp_relative_folder_path : str, optional
527
+ Relative path within the document library. If omitted, uses the current folder.
528
+
529
+
530
+ Raises
531
+ ------
532
+ FileNotFoundError
533
+ If the local folder does not exist.
534
+ ValueError
535
+ If the path is not a folder.
536
+
537
+ Examples
538
+ --------
539
+ >>> manager = SharepointManager(...)
540
+ >>> manager.upload_folder(local_file_path = "./Folder4", sp_relative_folder_path = "Folder1/Folder2/Folder3")
541
+ """
542
+
543
+ local_folder_path = os.path.abspath(local_folder_path)
544
+ if not os.path.exists(local_folder_path):
545
+ raise FileNotFoundError(f"Local folder does not exist: {local_folder_path}")
546
+ if not os.path.isdir(local_folder_path):
547
+ raise ValueError(f"Path does not correspond to a folder: {local_folder_path}")
548
+
549
+ if sp_relative_folder_path is not None:
550
+ _ = self.set_folder(sp_relative_folder_path, create_folder=True)
551
+
552
+ # Create folder inside of the current Sharepoint folder
553
+ sp_relative_url = self.folder.relative_url
554
+ new_folder_name = os.path.basename(local_folder_path)
555
+ sp_folder_path = f"{sp_relative_url}/{new_folder_name}"
556
+ if len(sp_folder_path) > 0 and sp_folder_path[0] == "/":
557
+ sp_folder_path = sp_folder_path[1:]
558
+ logging.info(f"Uploading folder: {self.folder.name}")
559
+ _ = self.set_folder(sp_folder_path, create_folder=True)
560
+
561
+ list_elements = os.listdir(local_folder_path)
562
+ # Upload files
563
+ list_files = [
564
+ f for f in list_elements if os.path.isfile(os.path.join(local_folder_path, f))
565
+ ]
566
+ for file_name in list_files:
567
+ self.upload_file(os.path.join(local_folder_path, file_name))
568
+
569
+ # Upload folders (recursive)
570
+ list_folders = [
571
+ f for f in list_elements if os.path.isdir(os.path.join(local_folder_path, f))
572
+ ]
573
+ for folder_name in list_folders:
574
+ self.upload_folder(
575
+ os.path.join(local_folder_path, folder_name),
576
+ f"{sp_folder_path}",
577
+ )
578
+
579
+ # ----------------------------------------------------------
580
+ # Download files/folders from Sharepoint
581
+ # ----------------------------------------------------------
582
+
583
+ @retry_if_not_exception(attempts=3, exceptions=(SPFileNotFound))
584
+ def download_file(
585
+ self,
586
+ file: str | SPFile,
587
+ local_download_path: str,
588
+ sp_relative_folder_path: str | None = None,
589
+ new_filename: str | None = None,
590
+ ) -> SPFile:
591
+ """
592
+ Download a file from SharePoint.
593
+
594
+
595
+ Parameters
596
+ ----------
597
+ file : str | SPFile
598
+ Filename or SPFile instance.
599
+ local_download_path : str
600
+ Local folder to download into.
601
+ sp_relative_folder_path : str, optional
602
+ Relative path within the document library. If omitted, uses the current folder.
603
+ new_filename : str, optional
604
+ If provided, rename the downloaded file.
605
+
606
+
607
+ Returns
608
+ -------
609
+ SPFile
610
+ The downloaded file metadata.
611
+
612
+ Examples
613
+ --------
614
+ >>> manager = SharepointManager(...)
615
+ >>> manager.download_file(filename = "file.txt", local_download_path = "./Download_Dir",
616
+ ... sp_relative_folder_path = "Folder1/Folder2/Folder3")
617
+ """
618
+
619
+ local_download_path = os.path.abspath(local_download_path)
620
+
621
+ os.makedirs(local_download_path, exist_ok=True)
622
+
623
+ if isinstance(file, str):
624
+ if sp_relative_folder_path is not None:
625
+ _ = self.set_folder(sp_relative_folder_path)
626
+
627
+ file_obj = self._get_file(file)
628
+ else:
629
+ file_obj = file
630
+
631
+ file_size_bytes = int(file_obj.size)
632
+ file_size_mbytes = round(file_size_bytes / (1024 * 1024), 1)
633
+ download_url = file_obj.download_url
634
+ logging.info(f"Downloading file {file_obj.name} ({file_size_mbytes} MB)")
635
+
636
+ chunk_size = 4 * 1024 * 1024
637
+ downloaded_bytes = 0
638
+
639
+ filename = file_obj.name if new_filename is None else new_filename
640
+ with self._request("GET", download_url, stream=True, timeout=None) as r:
641
+ r.raise_for_status()
642
+ with open(f"{local_download_path}/{filename}", "wb") as f:
643
+ for chunk in r.iter_content(chunk_size=chunk_size):
644
+ _ = f.write(chunk)
645
+ downloaded_bytes += len(chunk)
646
+ logging.info(
647
+ f"Downloaded {downloaded_bytes / (1024 * 1024):.1f} MiB out of {file_size_bytes / (1024 * 1024):.1f}"
648
+ )
649
+
650
+ logging.info("Download completed.")
651
+
652
+ return file_obj
653
+
654
+ @retry_if_not_exception(attempts=3, exceptions=(SPFolderNotFound, SPFileNotFound))
655
+ def download_folder(
656
+ self,
657
+ local_download_path: str,
658
+ sp_relative_folder_path: str | None = None,
659
+ ) -> None:
660
+ """
661
+ Recursively download a SharePoint folder and its contents.
662
+
663
+
664
+ Parameters
665
+ ----------
666
+ local_download_path : str
667
+ Local destination path.
668
+ sp_relative_folder_path : str, optional
669
+ Relative path within the document library. If omitted, uses the current folder.
670
+
671
+ Returns
672
+ -------
673
+ None
674
+
675
+ Examples
676
+ --------
677
+ >>> manager = SharepointManager(...)
678
+ >>> # The code below will create a folder "Folder3" inside "./Download_Dir"
679
+ >>> manager.download_folder(local_download_path = "./Download_Dir",
680
+ ... sp_relative_folder_path = "Folder1/Folder2/Folder3")
681
+ """
682
+
683
+ local_download_path = os.path.abspath(local_download_path)
684
+
685
+ if sp_relative_folder_path is not None:
686
+ _ = self.set_folder(sp_relative_folder_path)
687
+
688
+ # Create local folder
689
+ logging.info(f"Downloading folder: {self.folder.name}")
690
+ cur_folder = self.folder
691
+ cur_folder_download_path = os.path.join(local_download_path, cur_folder.name)
692
+ os.makedirs(cur_folder_download_path, exist_ok=True)
693
+
694
+ # Download Files
695
+ list_files_names = self.list_files()
696
+ for file in list_files_names.values():
697
+ _ = self.download_file(file, cur_folder_download_path)
698
+
699
+ # Download folders (recursive)
700
+ list_folder_names = self.list_folders()
701
+
702
+ for folder_name in list_folder_names:
703
+ folder_srp = f"{cur_folder.relative_url}/{folder_name}"
704
+ if len(folder_srp) > 0 and folder_srp[0] == "/":
705
+ folder_srp = folder_srp[1:]
706
+ self.download_folder(
707
+ cur_folder_download_path,
708
+ folder_srp,
709
+ )
710
+
711
+ def delete_file(self, file: str | SPFile, sp_relative_folder_path: str | None = None) -> None:
712
+ """
713
+ Delete a file from SharePoint.
714
+
715
+
716
+ Parameters
717
+ ----------
718
+ file : str | SPFile
719
+ Filename or SPFile instance.
720
+ sp_relative_folder_path : str, optional
721
+ Relative path within the document library. If omitted, uses the current folder.
722
+
723
+
724
+ Returns
725
+ -------
726
+ None
727
+
728
+
729
+ Raises
730
+ ------
731
+ SPFileNotFound
732
+ If the file does not exist.
733
+
734
+
735
+ Examples
736
+ --------
737
+ >>> manager = SharepointManager(...)
738
+ >>> manager.delete_file(filename = "file.txt", sp_relative_folder_path = "Folder1/Folder2/Folder3")
739
+ """
740
+
741
+ if sp_relative_folder_path is not None:
742
+ _ = self.set_folder(sp_relative_folder_path)
743
+
744
+ if isinstance(file, str):
745
+ file = self._get_file(file)
746
+
747
+ drive_id = self._drive_id
748
+ item_id = file.id
749
+ r = requests.delete(
750
+ f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{item_id}",
751
+ headers=self._hdr(),
752
+ timeout=30,
753
+ )
754
+ r.raise_for_status()
755
+
756
+ def delete_folder(self, folder: str | SPFolder, force_delete: bool = False) -> None:
757
+ """
758
+ Delete a SharePoint folder.
759
+
760
+
761
+ Parameters
762
+ ----------
763
+ folder : str | SPFolder
764
+ Relative path or folder object.
765
+ force_delete : bool, optional
766
+ If False (default), only empty folders are deleted. If True, delete regardless.
767
+
768
+
769
+ Raises
770
+ ------
771
+ SPFolderNotEmpty
772
+ If the folder is not empty and `force_delete` is False.
773
+
774
+
775
+ Returns
776
+ -------
777
+ None
778
+
779
+
780
+ Examples
781
+ --------
782
+ >>> manager = SharepointManager(...)
783
+ >>> # Consider that the folder is not empty
784
+ >>> try:
785
+ >>> manager.delete_folder(sp_relative_folder_path = "Folder1/Folder2/Folder3", force_delete=False)
786
+ >>> except SPFolderNotEmpty:
787
+ >>> logging.info("Sharepoint folder is not empty")
788
+ >>> manager.delete_folder(sp_relative_folder_path = "Folder1/Folder2/Folder3", force_delete=True)
789
+ """
790
+
791
+ if isinstance(folder, str):
792
+ folder = self.set_folder(folder)
793
+
794
+ files = self.list_files()
795
+ folders = self.list_folders()
796
+
797
+ if (len(files) == 0 and len(folders) == 0) or force_delete:
798
+ drive_id = self._drive_id
799
+ folder_id = folder.id
800
+ r = self._request(
801
+ "DELETE",
802
+ f"https://graph.microsoft.com/v1.0/drives/{drive_id}/items/{folder_id}",
803
+ headers=self._hdr(),
804
+ timeout=30,
805
+ )
806
+ r.raise_for_status()
807
+ else:
808
+ raise SPFolderNotEmpty("Sharepoint folder not empty")
809
+
810
+ # ----------------------------------------------------------
811
+ # Internal HTTP helpers
812
+ # ----------------------------------------------------------
813
+
814
+ def _request(
815
+ self,
816
+ method: str,
817
+ url: str,
818
+ headers: dict[str, Any] | None = None,
819
+ timeout: int | None = 30,
820
+ json: Any | None = None,
821
+ data: Any | None = None,
822
+ params: dict[str, Any] | None = None,
823
+ stream: bool = False,
824
+ max_attempts: int = 5,
825
+ ) -> requests.Response:
826
+ attempt = 1
827
+ while True:
828
+ resp = self._session.request(
829
+ method=method,
830
+ url=url,
831
+ headers=headers,
832
+ timeout=timeout,
833
+ json=json,
834
+ data=data,
835
+ params=params,
836
+ stream=stream,
837
+ )
838
+ # Handle 429/503 with Retry-After
839
+ if resp.status_code in (429, 503) and attempt < max_attempts:
840
+ retry_after = resp.headers.get("Retry-After")
841
+ delay = None
842
+ try:
843
+ delay = int(retry_after) if retry_after is not None else None
844
+ except Exception:
845
+ delay = None
846
+ if delay is None:
847
+ delay = min(2**attempt, 60)
848
+ time.sleep(delay)
849
+ attempt += 1
850
+ continue
851
+ return resp
852
+
853
+ def _paginate(self, url: str) -> Iterator[dict[str, Any]]:
854
+ """Yield items across Graph pages following @odata.nextLink."""
855
+ next_url = url
856
+ while next_url:
857
+ r = self._request("GET", next_url, headers=self._hdr(), timeout=30)
858
+ r.raise_for_status()
859
+ data = r.json()
860
+ for item in data.get("value", []):
861
+ yield item
862
+ next_url = data.get("@odata.nextLink")
@@ -0,0 +1,89 @@
1
+ from dataclasses import dataclass, fields, field
2
+ from typing import Any
3
+
4
+ from .utils import camel_to_snake
5
+
6
+
7
+ @dataclass
8
+ class ClientCredential:
9
+ client_id: str
10
+ client_secret: str
11
+
12
+
13
+ @dataclass
14
+ class SPObject:
15
+ created_datetime: str
16
+ id: str
17
+ last_modified_datetime: str
18
+ name: str
19
+ parent_reference: dict[str, str]
20
+ web_url: str
21
+ file_system_info: dict[str, str]
22
+ size: int
23
+ created_by: dict[str, dict[str, str]] = field(default_factory=dict)
24
+ last_modified_by: dict[str, dict[str, str]] = field(default_factory=dict)
25
+ shared: dict[str, str] = field(default_factory=dict)
26
+ c_tag: str = ""
27
+ e_tag: str = ""
28
+
29
+
30
+ def dataclass_from_dict(cls, data: dict[str, Any], extra_mapping: dict[str, str] | None = None):
31
+ valid_fields = {f.name for f in fields(cls)}
32
+ normalized = {}
33
+
34
+ if extra_mapping:
35
+ for k, v in extra_mapping.items():
36
+ if k in data:
37
+ data[v] = data[k]
38
+
39
+ for k, v in data.items():
40
+ snake = camel_to_snake(k)
41
+ if snake in valid_fields:
42
+ normalized[snake] = v
43
+ return cls(**normalized)
44
+
45
+
46
+ @dataclass
47
+ class SPFolder(SPObject):
48
+ context: str = ""
49
+ folder: dict[str, Any] = field(default_factory=dict)
50
+
51
+ @property
52
+ def child_count(self) -> int:
53
+ return self.folder.get("childCount", 0)
54
+
55
+ @property
56
+ def is_root(self) -> bool:
57
+ return self.name == ""
58
+
59
+ @property
60
+ def relative_url(self) -> str:
61
+ """
62
+ The most common url format is https://tenant.sharepoint.com/sites/site_name/documents_folder/folder1/folder2
63
+ We want to get everything after the documents folder: folder1/folder2
64
+ """
65
+
66
+ # include "/" because root url ends with /documents_folder
67
+ parts = (self.web_url + "/").split("/")
68
+ # skip sites, site_name, documents_folder
69
+ id_start = parts.index("sites") + 3
70
+ relative_url = "/".join(parts[id_start:])
71
+ if relative_url and relative_url[-1] == "/":
72
+ relative_url = relative_url[:-1]
73
+ return relative_url
74
+
75
+ @classmethod
76
+ def from_dict(cls, data: dict[str, Any]) -> "SPFolder":
77
+ if "root" in data:
78
+ data["name"] = ""
79
+ return dataclass_from_dict(cls, data, {"@odata.context": "context"})
80
+
81
+
82
+ @dataclass
83
+ class SPFile(SPObject):
84
+ download_url: str = ""
85
+ file: dict[str, Any] = field(default_factory=dict)
86
+
87
+ @classmethod
88
+ def from_dict(cls, data: dict[str, Any]) -> "SPFile":
89
+ return dataclass_from_dict(cls, data, {"@microsoft.graph.downloadUrl": "download_url"})
@@ -0,0 +1,125 @@
1
+ import time
2
+ import functools
3
+ from random import randint
4
+ from typing import Callable, Any, TypeVar, cast
5
+ import warnings
6
+
7
+
8
+ class RepetitionException(Exception):
9
+ """Error created to set specific Exceptions to be repeated
10
+ by the `retry` decorator."""
11
+
12
+
13
+ F = TypeVar("F", bound=Callable[..., Any])
14
+
15
+
16
+ def exponential_time(
17
+ initial: float, exp_base: float, max_delay: float, jitter_ms: float, attempt: int
18
+ ) -> float:
19
+ next_delay: float = initial * exp_base ** (attempt - 1) + randint(0, int(abs(jitter_ms))) / 1000
20
+ return min(next_delay, max_delay)
21
+
22
+
23
+ def retry(
24
+ attempts: int,
25
+ exceptions: type[BaseException] | tuple[type[BaseException], ...],
26
+ delay_base: int = 2,
27
+ max_delay: int = 60,
28
+ jitter_ms: int = 0,
29
+ ) -> Callable[[F], F]:
30
+ """Retry decorator."""
31
+
32
+ if not (isinstance(exceptions, type) or isinstance(exceptions, tuple)): # pyright: ignore[reportUnnecessaryIsInstance]
33
+ raise ValueError("exceptions must be an Error/Exception or a tuple of Error/Exception") # pyright: ignore[reportUnreachable]
34
+
35
+ def decorator(func: F) -> F:
36
+ @functools.wraps(func)
37
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
38
+ attempt = 1
39
+ while attempt < attempts:
40
+ try:
41
+ return func(*args, **kwargs)
42
+ except exceptions:
43
+ time.sleep(
44
+ exponential_time(
45
+ initial=1,
46
+ exp_base=delay_base,
47
+ max_delay=max_delay,
48
+ jitter_ms=jitter_ms,
49
+ attempt=attempt,
50
+ )
51
+ )
52
+ attempt += 1
53
+ return func(*args, **kwargs)
54
+
55
+ return cast(F, wrapper)
56
+
57
+ return decorator
58
+
59
+
60
+ def retry_if_not_exception(
61
+ attempts: int,
62
+ exceptions: type[BaseException] | tuple[type[BaseException], ...],
63
+ delay_base: int = 2,
64
+ max_delay: int = 60,
65
+ jitter_ms: int = 0,
66
+ ) -> Callable[[F], F]:
67
+ """Retry if not (mapped) exception decorator."""
68
+
69
+ if not (isinstance(exceptions, type) or isinstance(exceptions, tuple)): # pyright: ignore[reportUnnecessaryIsInstance]
70
+ raise ValueError("exceptions must be an Error/Exception or a tuple of Error/Exception") # pyright: ignore[reportUnreachable]
71
+
72
+ def decorator(func: F) -> F:
73
+ @functools.wraps(func)
74
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
75
+ attempt = 1
76
+ while attempt < attempts:
77
+ try:
78
+ return func(*args, **kwargs)
79
+ except exceptions as exc:
80
+ raise exc
81
+ except Exception:
82
+ time.sleep(
83
+ exponential_time(
84
+ initial=1,
85
+ exp_base=delay_base,
86
+ max_delay=max_delay,
87
+ jitter_ms=jitter_ms,
88
+ attempt=attempt,
89
+ )
90
+ )
91
+ attempt += 1
92
+ return func(*args, **kwargs)
93
+
94
+ return cast(F, wrapper)
95
+
96
+ return decorator
97
+
98
+
99
+ def deprecated(
100
+ deprecated_on_version: str | None = None,
101
+ removed_on_version: str | None = None,
102
+ current_version: str | None = None,
103
+ details: str | None = None,
104
+ ) -> Callable[[F], F]:
105
+ """Deprecation decorator that prints a warning if the function is deprecated."""
106
+
107
+ def decorator(func: F) -> F:
108
+ @functools.wraps(func)
109
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
110
+ warnings.warn(
111
+ (
112
+ f"Call to deprecated function: {func.__name__}\n"
113
+ f"Function was deprecated on version: {deprecated_on_version}\n"
114
+ f"Function will be removed on version: {removed_on_version}\n"
115
+ f"Current version: {current_version}\n"
116
+ f"Details: {details}"
117
+ ),
118
+ category=DeprecationWarning,
119
+ stacklevel=2,
120
+ )
121
+ return func(*args, **kwargs)
122
+
123
+ return cast(F, wrapper)
124
+
125
+ return decorator
@@ -0,0 +1,10 @@
1
+ class SPFolderNotFound(Exception):
2
+ """Sharepoint folder not found"""
3
+
4
+
5
+ class SPFolderNotEmpty(Exception):
6
+ """Sharepoint folder was not empty"""
7
+
8
+
9
+ class SPFileNotFound(Exception):
10
+ """Sharepoint file was not found"""
@@ -0,0 +1,48 @@
1
+ import re
2
+ import ntpath
3
+
4
+
5
+ def camel_to_snake(name: str) -> str:
6
+ s1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
7
+ return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).lower().replace("date_time", "datetime")
8
+
9
+
10
+ def get_filename(target_path: str) -> str:
11
+ """
12
+ Returns the name of a file from the given path.
13
+
14
+ This function accepts paths terminating in '/' and works in any OS.
15
+
16
+ Parameters
17
+ ----------
18
+ target_path : str
19
+ Path to reach a file.
20
+
21
+ Returns
22
+ -------
23
+ str
24
+ Name of the file.
25
+ """
26
+ head, tail = ntpath.split(target_path)
27
+ return tail or ntpath.basename(head)
28
+
29
+
30
+ def get_names_to_folder(target_path: str) -> list[str]:
31
+ """
32
+ Returns a list of names (str) of all folders from root to the target folder.
33
+
34
+ Parameters
35
+ ----------
36
+ target_path : str
37
+ Path to reach the target folder.
38
+
39
+ Returns
40
+ -------
41
+ list of str
42
+ Names of folders to reach the target folder (in order), including the name of the target folder.
43
+ """
44
+
45
+ if len(target_path) == 0:
46
+ return []
47
+ target_path = target_path[:-1] if (target_path[-1] in ["/", "\\"]) else target_path
48
+ return target_path.replace("\\", "/").split("/")
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: sharepoint_manager
3
+ Version: 0.0.4
4
+ Summary: Library for interacting with sharepoint using Microsoft Graph API
5
+ Home-page: https://github.com/VBenevides/sharepoint_manager
6
+ Author: Vinicius Benevides
7
+ Author-email: viniciusm.benevides@gmail.com
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Requires-Dist: msal
11
+ Dynamic: author
12
+ Dynamic: author-email
13
+ Dynamic: home-page
14
+ Dynamic: license
15
+ Dynamic: license-file
16
+ Dynamic: requires-dist
17
+ Dynamic: summary
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ setup.py
3
+ sharepoint_manager/__init__.py
4
+ sharepoint_manager/core.py
5
+ sharepoint_manager/dataclasses.py
6
+ sharepoint_manager/decorators.py
7
+ sharepoint_manager/exceptions.py
8
+ sharepoint_manager/utils.py
9
+ sharepoint_manager.egg-info/PKG-INFO
10
+ sharepoint_manager.egg-info/SOURCES.txt
11
+ sharepoint_manager.egg-info/dependency_links.txt
12
+ sharepoint_manager.egg-info/requires.txt
13
+ sharepoint_manager.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ sharepoint_manager