Skip to content

Commit a5ba093

Browse files
authored
Add support and tests for document libraries (#15)
* Add support and tests for document libraries * Remove mypy entry * bump version v26.8.0 -> v26.10.0 * Update other calls to handle new URL logic * bump version v26.10.0 -> v26.10.1 * Correct comment * Version * bump version v26.10.1 -> v26.10.2
1 parent 1ad232e commit a5ba093

6 files changed

Lines changed: 254 additions & 49 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,4 @@ test/testFiles
180180
test/volume
181181
creds
182182
refresh_token*.txt
183+
.envrc

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## v26.10.2
4+
5+
- Add support for document libraries in Sharepoint
6+
- Fixed post copy action renaming to overwrite existing files if they already exist
7+
38
## v26.8.0
49

510
- Add conditionals property to sharepoint_source schema

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ As part of the upload, the `bucket-owner-full-control` ACL flag is applied to al
6060

6161
JSON configs for transfers can be defined as follows:
6262

63+
### Handling of document libraries
64+
65+
If you have a document library in your Sharepoint site, you can specify the name of the document library as part of the path. By default, if the destination path does not start with a `/`, then the file will be uploaded to the root of the site (The default Document Library), otherwise it will be uploaded to the document library specified in the first component of the path.
66+
6367
## Example File Watch Only
6468

6569
```json

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "otf-addons-o365"
7-
version = "v26.8.0"
7+
version = "v26.10.2"
88
authors = [{ name = "Adam McDonagh", email = "adam@elitemonkey.net" }]
99
license = { text = "GPLv3" }
1010
classifiers = [
@@ -46,7 +46,7 @@ dev = [
4646
profile = 'black'
4747

4848
[tool.bumpver]
49-
current_version = "v26.8.0"
49+
current_version = "v26.10.2"
5050
version_pattern = "vYY.WW.PATCH[-TAG]"
5151
commit_message = "bump version {old_version} -> {new_version}"
5252
commit = true

src/opentaskpy/addons/o365/remotehandlers/sharepoint.py

Lines changed: 153 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,13 @@ def handle_post_copy_action(self, files: dict) -> int:
154154
for file_name, attributes in files.items():
155155
# Build up file path below site root
156156
file_path = f"{attributes['directory']}/{file_name}"
157-
# Get the file id and delete
158-
file_id = self.get_file_id_from_path(file_path)
159-
delete_url = f"https://graph.microsoft.com/v1.0/sites/{self.site_id}/drive/items/{file_id}"
157+
# Get the file url and delete
158+
file_url = self.get_file_url_from_path(file_path)
159+
if not file_url:
160+
self.logger.error(f"Failed to get file URL for {file_path}")
161+
return 1
160162
response = requests.delete(
161-
delete_url,
163+
file_url,
162164
headers={
163165
"Authorization": "Bearer " + self.credentials["access_token"],
164166
},
@@ -177,7 +179,11 @@ def handle_post_copy_action(self, files: dict) -> int:
177179
for file_name, attributes in files.items():
178180
# Build up file path below site root
179181
file_path = f"{attributes['directory']}/{file_name}"
180-
file_id = self.get_file_id_from_path(file_path)
182+
file_url = self.get_file_url_from_path(file_path)
183+
if not file_url:
184+
self.logger.error(f"Failed to get file URL for {file_path}")
185+
return 1
186+
181187
new_file = f"{file_name.split('/')[-1]}"
182188

183189
# getting the archiving path
@@ -199,19 +205,57 @@ def handle_post_copy_action(self, files: dict) -> int:
199205
self.spec["postCopyAction"]["sub"],
200206
file_name,
201207
)
202-
update_url = f"https://graph.microsoft.com/v1.0/sites/{self.site_id}/drive/items/{file_id}"
208+
patch_body = {
209+
"parentReference": {"id": f"{destination_id}"},
210+
"name": f"{new_file}",
211+
}
212+
patch_headers = {
213+
"Authorization": "Bearer " + self.credentials["access_token"],
214+
"Content-Type": "application/json",
215+
}
203216
response = requests.patch(
204-
update_url,
205-
headers={
206-
"Authorization": "Bearer " + self.credentials["access_token"],
207-
"Content-Type": "application/json",
208-
},
217+
file_url,
218+
headers=patch_headers,
209219
timeout=60,
210-
json={
211-
"parentReference": {"id": f"{destination_id}"},
212-
"name": f"{new_file}",
213-
},
220+
json=patch_body,
214221
)
222+
if response.status_code == 409:
223+
# Target already exists - delete it and retry (unix-style overwrite)
224+
self.logger.info(
225+
f"Destination file {new_file} already exists, overwriting"
226+
)
227+
conflict_url = self.get_file_url_from_path(
228+
f"{destination_path}/{new_file}"
229+
)
230+
if not conflict_url:
231+
self.logger.error(
232+
f"Failed to get file URL for {destination_path}/{new_file}"
233+
)
234+
return 1
235+
response = requests.delete(
236+
conflict_url,
237+
headers={
238+
"Authorization": (
239+
"Bearer " + self.credentials["access_token"]
240+
),
241+
},
242+
timeout=60,
243+
)
244+
# Check the response was a success
245+
if response.status_code != 204:
246+
self.logger.error(
247+
f"Failed to delete conflicting file: {new_file}"
248+
)
249+
self.logger.error(f"Got return code: {response.status_code}")
250+
self.logger.error(response.json())
251+
return 1
252+
253+
response = requests.patch(
254+
file_url,
255+
headers=patch_headers,
256+
timeout=60,
257+
json=patch_body,
258+
)
215259
if response.status_code != 200:
216260
self.logger.error(f"Failed to move file: {file_name}")
217261
self.logger.error(f"Got return code: {response.status_code}")
@@ -233,13 +277,35 @@ def create_or_get_folder(self, destination_path: str) -> str | None:
233277
for folder in folders:
234278
# build the path depending on if parent exists
235279
current_path = f"{current_parent}/{folder}" if current_parent else folder
236-
# get folder id from current path
237-
folder_id = self.get_file_id_from_path(current_path)
280+
# get folder url from current path
281+
folder_url = self.get_file_url_from_path(current_path)
238282

239-
# if folder doesn't exist, create it; else, update parent details
240-
if folder_id is None:
283+
# if folder doesn't exist, create it and get the actual item ID back
284+
if folder_url is None:
241285
self.logger.info(f"Folder {folder} does not exist, creating")
242286
folder_id = self.create_folder(parent_id, folder)
287+
else:
288+
# get_file_url_from_path returns a path-based URL, not a drive item ID;
289+
# resolve it to the actual item ID so it can be used in parentReference.id
290+
response = requests.get(
291+
folder_url,
292+
headers={
293+
"Authorization": "Bearer " + self.credentials["access_token"],
294+
},
295+
timeout=60,
296+
)
297+
if response.status_code == 404:
298+
self.logger.info(f"Folder {folder} does not exist, creating")
299+
folder_id = self.create_folder(parent_id, folder)
300+
elif response.status_code == 200:
301+
folder_id = response.json()["id"]
302+
else:
303+
self.logger.error(f"Failed to resolve folder: {current_path}")
304+
self.logger.error(response.json())
305+
raise RemoteTransferError(
306+
f"Failed to resolve folder: {current_path}"
307+
)
308+
243309
# updating parent info for the next folder in sequence
244310
current_parent = current_path
245311
parent_id = folder_id
@@ -365,6 +431,10 @@ def push_files_from_worker(
365431
for file in files:
366432
# Strip the directory from the file
367433
file_name = file.split("/")[-1]
434+
file_url = self.get_file_url_from_path(
435+
self.spec["directory"] + "/" + file_name
436+
)
437+
368438
# Handle any rename that might be specified in the spec
369439
if "rename" in self.spec:
370440
rename_regex = self.spec["rename"]["pattern"]
@@ -391,7 +461,8 @@ def push_files_from_worker(
391461
continue
392462

393463
# Otherwise do a normal upload
394-
upload_url = f"https://graph.microsoft.com/v1.0/sites/{self.site_id}/drive/root:/{file_name}:/content"
464+
upload_url = f"{file_url}:/content"
465+
self.logger.info(f"Using upload url: {upload_url}")
395466
with open(file, "rb") as f:
396467
max_retries = 5
397468
retry_delay = 1
@@ -450,18 +521,20 @@ def _do_upload_session(self, file: str, file_name: str) -> int:
450521
# 3. Trigger the appropriate URI endpoint to upload the file
451522

452523
# Determine if the file already exists
453-
file_id = self.get_file_id_from_path(file_name)
454-
if file_id is None:
524+
file_url = self.get_file_url_from_path(file_name)
525+
if file_url is None:
455526
self.logger.info(f"File {file_name} does not already exist.")
456-
# Get the parent item id, using the dirname of the file
457-
parent_folder_id = self.get_file_id_from_path(path.dirname(file_name))
527+
# Get the parent item url, using the dirname of the file
528+
parent_folder_url = self.get_file_url_from_path(path.dirname(file_name))
458529
file_name_basename = path.basename(file_name)
459530

460-
upload_session_url = f"https://graph.microsoft.com/v1.0/sites/{self.site_id}/drive/items/{parent_folder_id}:/{file_name_basename}:/createUploadSession"
531+
upload_session_url = (
532+
f"{parent_folder_url}:/{file_name_basename}:/createUploadSession"
533+
)
461534

462535
else:
463536
self.logger.info(f"File {file_name} already exists. Replacing file.")
464-
upload_session_url = f"https://graph.microsoft.com/v1.0/sites/{self.site_id}/drive/items/{file_id}/createUploadSession"
537+
upload_session_url = f"{file_url}:/createUploadSession"
465538

466539
response = requests.post(
467540
upload_session_url,
@@ -531,10 +604,11 @@ def _do_upload_session(self, file: str, file_name: str) -> int:
531604

532605
# If it's a 201, then we are done with the upload
533606
if response.status_code == 201 or (
534-
response.status_code == 200 and file_id is not None
607+
response.status_code == 200 and file_url is not None
535608
):
536-
file_id = response.json()["id"]
537-
self.logger.info(f"Successfully uploaded file. File ID: {file_id}")
609+
self.logger.info(
610+
f"Successfully uploaded file. File ID: {response.json()['id']}"
611+
)
538612
else:
539613
self.logger.info(f"Uploaded chunk {i + 1} of {num_chunks}")
540614

@@ -564,11 +638,11 @@ def pull_files_to_worker(self, files: dict, local_staging_directory: str) -> int
564638
file_path = f"{attributes['directory']}/{file_name}"
565639

566640
try:
567-
# Get the item id based on source path
568-
file_id = self.get_file_id_from_path(file_path)
569-
# Download file using item id
641+
# Get the item url based on source path
642+
file_url = self.get_file_url_from_path(file_path)
643+
# Download file using item url
570644
self.logger.info(f"Downloading file: {file_name}")
571-
download_url = f"https://graph.microsoft.com/v1.0/sites/{self.site_id}/drive/items/{file_id}/content"
645+
download_url = f"{file_url}:/content"
572646
response = requests.get(
573647
download_url,
574648
headers={
@@ -611,14 +685,58 @@ def create_flag_files(self) -> int:
611685
def tidy(self) -> None:
612686
"""Nothing to tidy."""
613687

614-
def get_file_id_from_path(self, file_path: str) -> str | None:
688+
def get_file_url_from_path(self, file_path: str) -> str | None:
615689
"""Returns the id for a sharepoint drive item from the path."""
616690
if file_path == "": # We are dealing with the root folder
617691
item_url = (
618692
f"https://graph.microsoft.com/v1.0/sites/{self.site_id}/drive/root"
619693
)
620694
else:
621-
item_url = f"https://graph.microsoft.com/v1.0/sites/{self.site_id}/drive/root:/{file_path}"
695+
parts = file_path.split("/")
696+
file_name = parts[-1]
697+
# Remove the filename from the path
698+
o365_file_path = "/".join(parts[:-1])
699+
700+
if o365_file_path.startswith("/"):
701+
path_parts = o365_file_path.split("/")
702+
# Get the first part of the path, which is the document library name
703+
library_name = path_parts[1]
704+
# file path then needs to be the rest of the path
705+
o365_file_path = "/".join(path_parts[2:])
706+
707+
# If the path starts with a / then it's a document library, we need to get the id of the document library
708+
# Do a GET request to /sites/{siteId}/drives to get the document libraries
709+
response = requests.get(
710+
f"https://graph.microsoft.com/v1.0/sites/{self.site_id}/drives",
711+
headers={
712+
"Authorization": "Bearer " + self.credentials["access_token"],
713+
},
714+
timeout=60,
715+
)
716+
if response.status_code != 200:
717+
self.logger.error("Failed to get document libraries")
718+
self.logger.error(response.json())
719+
raise RemoteTransferError("Failed to get document libraries")
720+
721+
document_libraries = response.json()["value"]
722+
723+
for document_library in document_libraries:
724+
if document_library["name"] == library_name:
725+
item_path = (
726+
f"{o365_file_path}/{file_name}"
727+
if o365_file_path
728+
else file_name
729+
)
730+
return f"https://graph.microsoft.com/v1.0/sites/{self.site_id}/drives/{document_library['id']}/root:/{item_path}"
731+
732+
self.logger.error(
733+
f"Failed to find document library with name {library_name}"
734+
)
735+
raise RemoteTransferError(
736+
f"Failed to find Document Library named {library_name}"
737+
)
738+
739+
return f"https://graph.microsoft.com/v1.0/sites/{self.site_id}/drive/root:/{re.sub(r'/+', '/', file_path)}"
622740

623741
response = requests.get(
624742
item_url,

0 commit comments

Comments
 (0)