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.
- sharepoint_manager-0.0.4/LICENSE +21 -0
- sharepoint_manager-0.0.4/PKG-INFO +17 -0
- sharepoint_manager-0.0.4/setup.cfg +4 -0
- sharepoint_manager-0.0.4/setup.py +19 -0
- sharepoint_manager-0.0.4/sharepoint_manager/__init__.py +16 -0
- sharepoint_manager-0.0.4/sharepoint_manager/core.py +862 -0
- sharepoint_manager-0.0.4/sharepoint_manager/dataclasses.py +89 -0
- sharepoint_manager-0.0.4/sharepoint_manager/decorators.py +125 -0
- sharepoint_manager-0.0.4/sharepoint_manager/exceptions.py +10 -0
- sharepoint_manager-0.0.4/sharepoint_manager/utils.py +48 -0
- sharepoint_manager-0.0.4/sharepoint_manager.egg-info/PKG-INFO +17 -0
- sharepoint_manager-0.0.4/sharepoint_manager.egg-info/SOURCES.txt +13 -0
- sharepoint_manager-0.0.4/sharepoint_manager.egg-info/dependency_links.txt +1 -0
- sharepoint_manager-0.0.4/sharepoint_manager.egg-info/requires.txt +1 -0
- sharepoint_manager-0.0.4/sharepoint_manager.egg-info/top_level.txt +1 -0
|
@@ -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,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,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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
msal
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sharepoint_manager
|