From db7e87dc8be183552060f5cdfd7cb0a4338713ca Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Mon, 10 Feb 2025 11:25:52 -0500 Subject: [PATCH 01/22] :pencil2: Fix docstring typo --- redcap/methods/logging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redcap/methods/logging.py b/redcap/methods/logging.py index 65a94a6..1941765 100644 --- a/redcap/methods/logging.py +++ b/redcap/methods/logging.py @@ -1,4 +1,4 @@ -"""REDCap API methods for Project field names""" +"""REDCap API methods for Project logs""" from datetime import datetime from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Union, cast From fdb196a645d03270e16f268a034b92c1247805d4 Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Wed, 5 Mar 2025 15:54:18 -0500 Subject: [PATCH 02/22] :construction: Add method for creating folders --- redcap/methods/base.py | 1 + redcap/methods/file_repository.py | 80 ++++++++++++++++++++++++ tests/integration/test_simple_project.py | 7 +++ 3 files changed, 88 insertions(+) create mode 100644 redcap/methods/file_repository.py diff --git a/redcap/methods/base.py b/redcap/methods/base.py index 5007fbf..a98ef59 100644 --- a/redcap/methods/base.py +++ b/redcap/methods/base.py @@ -339,6 +339,7 @@ def _return_data( "dag", "event", "exportFieldNames", + "fileRepository", "formEventMapping", "instrument", "log", diff --git a/redcap/methods/file_repository.py b/redcap/methods/file_repository.py new file mode 100644 index 0000000..066df4f --- /dev/null +++ b/redcap/methods/file_repository.py @@ -0,0 +1,80 @@ +"""REDCap API methods for Project file repository""" + +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Union, cast + +from redcap.methods.base import Base, Json + +if TYPE_CHECKING: + import pandas as pd + + +class FileRepository(Base): + """Responsible for all API methods under 'File Repository' in the API Playground""" + + def create_folder( + self, + name: str, + folder_id: Optional[int] = None, + dag_id: Optional[int] = None, + role_id: Optional[int] = None, + format_type: Literal["json", "csv", "xml"] = "json", + return_format_type: Optional[Literal["json", "csv", "xml"]] = None, + ): + """ + Create a New Folder in the File Repository + + Args: + name: + The desired name of the folder to be created (max length = 150 characters) + folder_id: + The folder_id of a specific folder in the File Repository for which you wish + to create this sub-folder. If none is provided, the folder will be created in + the top-level directory of the File Repository. + dag_id: + The dag_id of the DAG (Data Access Group) to which you wish to restrict + access for this folder. If none is provided, the folder will accessible to + users in all DAGs and users in no DAGs. + role_id: + The role_id of the User Role to which you wish to restrict access for this + folder. If none is provided, the folder will accessible to users in all + User Roles and users in no User Roles. + format_type: + Return the metadata in native objects, csv or xml. + return_format_type: + Response format. By default, response will be json-decoded. + Returns: + Union[str, List[Dict[str, Any]]]: + List of all changes made to this project, including data exports, + data changes, and the creation or deletion of users + + Examples: + >>> proj.create_folder(name="New Folder") + [{"folder_id": ..., "name": "New Folder"}] + """ + payload: Dict[str, Any] = self._initialize_payload( + content="fileRepository", format_type=format_type + ) + + payload["action"] = "createFolder" + payload["name"] = name + + if folder_id: + payload["folder_id"] = folder_id + + if dag_id: + payload["dag_id"] = dag_id + + if role_id: + payload["role_id"] = role_id + + if return_format_type: + payload["returnFormat"] = return_format_type + + return_type = self._lookup_return_type(format_type, request_type="export") + response = cast(Union[Json, str], self._call_api(payload, return_type)) + + return self._return_data( + response=response, + content="fileRepository", + format_type=format_type, + ) diff --git a/tests/integration/test_simple_project.py b/tests/integration/test_simple_project.py index 7114ac3..889349a 100644 --- a/tests/integration/test_simple_project.py +++ b/tests/integration/test_simple_project.py @@ -298,3 +298,10 @@ def test_export_events(simple_project): def test_export_instrument_event_mapping(simple_project): with pytest.raises(RedcapError): simple_project.export_instrument_event_mappings() + + +@pytest.mark.integration +def test_create_folder(simple_project): + folder_name = "New Folder" + new_folder = simple_project.create_folder(name=folder_name) + assert new_folder[0]["name"] == folder_name From 4c76b6e48a4ab9cae3ff6e0c50684f6f3779591c Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Thu, 6 Mar 2025 12:19:18 -0500 Subject: [PATCH 03/22] :alien: Update redcapdemo site --- pytest.ini | 2 +- redcap/conftest.py | 6 +++--- redcap/project.py | 2 +- tests/integration/conftest.py | 29 ++++++++++++++++++----------- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/pytest.ini b/pytest.ini index d00b7cb..064e25f 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,7 +2,7 @@ doctest_optionflags = NORMALIZE_WHITESPACE ELLIPSIS FAIL_FAST REPORT_NDIFF addopts = -rsxX -l --tb=short --strict --pylint --black --cov=redcap --cov-report=xml --mypy markers = - integration: test connects to redcapdemo.vanderbilt.edu server + integration: test connects to redcapdemo.vumc.org server # Keep current format for future version of pytest junit_family=xunit1 # Ignore unimportant warnings diff --git a/redcap/conftest.py b/redcap/conftest.py index 713e37e..9381e3c 100644 --- a/redcap/conftest.py +++ b/redcap/conftest.py @@ -12,6 +12,7 @@ from tests.integration.conftest import ( create_project, grant_superuser_rights, + redcapdemo_url, SUPER_TOKEN, ) @@ -19,14 +20,13 @@ @pytest.fixture(scope="session", autouse=True) def add_doctest_objects(doctest_namespace): """Add the doctest project instance to the doctest_namespace""" - url = "https://redcapdemo.vanderbilt.edu/api/" doctest_project_xml = Path("tests/data/doctest_project.xml") doctest_token = create_project( - url=url, + url=redcapdemo_url(), super_token=SUPER_TOKEN, project_xml_path=doctest_project_xml, ) - doctest_project = Project(url, doctest_token) + doctest_project = Project(redcapdemo_url(), doctest_token) doctest_project = grant_superuser_rights(doctest_project) doctest_namespace["proj"] = doctest_project doctest_namespace["TOKEN"] = doctest_token diff --git a/redcap/project.py b/redcap/project.py index e839d57..d8f6d3a 100755 --- a/redcap/project.py +++ b/redcap/project.py @@ -50,7 +50,7 @@ class Project( Examples: >>> from redcap import Project - >>> URL = "https://redcapdemo.vanderbilt.edu/api/" + >>> URL = "https://redcapdemo.vumc.org/api/" >>> proj = Project(URL, TOKEN) >>> proj.field_names ['record_id', 'field_1', 'checkbox_field', 'upload_field'] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 075116c..b688403 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -16,7 +16,7 @@ def create_project(url: str, super_token: str, project_xml_path: Path) -> str: - """Create a project for testing on redcapdemo.vanderbilt.edu + """Create a project for testing on redcapdemo.vumc.org This API method returns the token for the newly created project, which used for the integration tests """ @@ -47,19 +47,24 @@ def create_project(url: str, super_token: str, project_xml_path: Path) -> str: return res.text[-32:] -@pytest.fixture(scope="module") def redcapdemo_url() -> str: """API url for redcapdemo testing site""" - return "https://redcapdemo.vanderbilt.edu/api/" + return "https://redcapdemo.vumc.org/api/" @pytest.fixture(scope="module") -def simple_project_token(redcapdemo_url) -> str: +def redcapdemo_url_fixture() -> str: + """API url for redcapdemo testing site, as a testing fixture""" + return redcapdemo_url() + + +@pytest.fixture(scope="module") +def simple_project_token(redcapdemo_url_fixture) -> str: """Create a simple project and return it's API token""" simple_project_xml_path = Path("tests/data/test_simple_project.xml") super_token = cast(str, SUPER_TOKEN) project_token = create_project( # type: ignore - redcapdemo_url, super_token, simple_project_xml_path + redcapdemo_url_fixture, super_token, simple_project_xml_path ) return project_token @@ -83,26 +88,28 @@ def grant_superuser_rights(proj: Project) -> Project: @pytest.fixture(scope="module") -def simple_project(redcapdemo_url, simple_project_token): +def simple_project(redcapdemo_url_fixture, simple_project_token): """A simple REDCap project""" - simple_proj = Project(redcapdemo_url, simple_project_token) + simple_proj = Project(redcapdemo_url_fixture, simple_project_token) simple_proj = grant_superuser_rights(simple_proj) return simple_proj @pytest.fixture(scope="module") -def long_project_token(redcapdemo_url) -> str: +def long_project_token(redcapdemo_url_fixture) -> str: """Create a long project and return it's API token""" long_project_xml_path = Path("tests/data/test_long_project.xml") super_token = cast(str, SUPER_TOKEN) - project_token = create_project(redcapdemo_url, super_token, long_project_xml_path) + project_token = create_project( + redcapdemo_url_fixture, super_token, long_project_xml_path + ) return project_token @pytest.fixture(scope="module") -def long_project(redcapdemo_url, long_project_token): +def long_project(redcapdemo_url_fixture, long_project_token): """A long REDCap project""" - long_proj = Project(redcapdemo_url, long_project_token) + long_proj = Project(redcapdemo_url_fixture, long_project_token) long_proj = grant_superuser_rights(long_proj) return long_proj From ca315552d0777d916985c8b8f1cee0a875ff013a Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Thu, 6 Mar 2025 12:20:02 -0500 Subject: [PATCH 04/22] :building_construction: Connect FileRepository to Project --- redcap/methods/__init__.py | 1 + redcap/project.py | 1 + 2 files changed, 2 insertions(+) diff --git a/redcap/methods/__init__.py b/redcap/methods/__init__.py index 491aac0..ca0e954 100644 --- a/redcap/methods/__init__.py +++ b/redcap/methods/__init__.py @@ -4,6 +4,7 @@ import redcap.methods.data_access_groups import redcap.methods.events import redcap.methods.field_names +import redcap.methods.file_repository import redcap.methods.files import redcap.methods.instruments import redcap.methods.logging diff --git a/redcap/project.py b/redcap/project.py index d8f6d3a..6674ea5 100755 --- a/redcap/project.py +++ b/redcap/project.py @@ -23,6 +23,7 @@ class Project( methods.data_access_groups.DataAccessGroups, methods.events.Events, methods.field_names.FieldNames, + methods.file_repository.FileRepository, methods.files.Files, methods.instruments.Instruments, methods.logging.Logging, From 57844a2168f3ac7964767d9eb14f3d556094e562 Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Thu, 6 Mar 2025 12:20:21 -0500 Subject: [PATCH 05/22] :bug: Fix create folder tests --- redcap/methods/file_repository.py | 2 +- tests/integration/test_simple_project.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/redcap/methods/file_repository.py b/redcap/methods/file_repository.py index 066df4f..02c0b98 100644 --- a/redcap/methods/file_repository.py +++ b/redcap/methods/file_repository.py @@ -49,7 +49,7 @@ def create_folder( Examples: >>> proj.create_folder(name="New Folder") - [{"folder_id": ..., "name": "New Folder"}] + [{'folder_id': ...}] """ payload: Dict[str, Any] = self._initialize_payload( content="fileRepository", format_type=format_type diff --git a/tests/integration/test_simple_project.py b/tests/integration/test_simple_project.py index 889349a..2703992 100644 --- a/tests/integration/test_simple_project.py +++ b/tests/integration/test_simple_project.py @@ -304,4 +304,4 @@ def test_export_instrument_event_mapping(simple_project): def test_create_folder(simple_project): folder_name = "New Folder" new_folder = simple_project.create_folder(name=folder_name) - assert new_folder[0]["name"] == folder_name + assert new_folder[0]["folder_id"] > 0 From 9ba13c43fd78e1992d01d739e9b82cb5ce09ee6c Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Fri, 7 Mar 2025 12:20:23 -0500 Subject: [PATCH 06/22] :alien: Modify test to reflect API change Previously exporting arms when the weren't assigned to events would throw an error --- tests/integration/test_long_project.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/integration/test_long_project.py b/tests/integration/test_long_project.py index 4215b09..0534bb2 100644 --- a/tests/integration/test_long_project.py +++ b/tests/integration/test_long_project.py @@ -207,10 +207,8 @@ def test_arms_import_override(long_project): response = long_project.import_arms(new_arms, override=1) assert response == 2 - # Confirm that there are no events associated with new override arms - with pytest.raises(RedcapError): - response = long_project.export_arms() + # Restore project state response = long_project.import_events(state_dict["events"]) assert response == 16 From c67ff9a30a7d6732bc5f0282c5fb73b048b7d585 Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Tue, 11 Mar 2025 10:54:38 -0400 Subject: [PATCH 07/22] :recycle: Refactor untouched code --- redcap/request.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/redcap/request.py b/redcap/request.py index a416b37..5e5474f 100644 --- a/redcap/request.py +++ b/redcap/request.py @@ -190,19 +190,19 @@ def execute( return_bytes=self.config.return_bytes, ) + bad_request = False + if self.fmt == "json": try: bad_request = "error" in content.keys() # type: ignore except AttributeError: # we're not dealing with an error dict - bad_request = False + pass elif self.fmt == "csv": bad_request = content.lower().startswith("error:") # type: ignore # xml is the default returnFormat for error messages elif self.fmt == "xml" or self.fmt is None: bad_request = "" in str(content).lower() - else: - raise ValueError(f"Unsupported format { self.fmt }") if bad_request: raise RedcapError(content) From 7c5c4eb4afe6fb3e8056569bb24afa516e8264ae Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Tue, 11 Mar 2025 10:55:41 -0400 Subject: [PATCH 08/22] :bath: Lint --- tests/integration/test_long_project.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/integration/test_long_project.py b/tests/integration/test_long_project.py index 0534bb2..a506674 100644 --- a/tests/integration/test_long_project.py +++ b/tests/integration/test_long_project.py @@ -5,9 +5,6 @@ import pytest -from redcap import RedcapError - - if not os.getenv("REDCAPDEMO_SUPERUSER_TOKEN"): pytest.skip( "Super user token not found, skipping integration tests", From 5903d63bf1561d019a8a8c1d5eb7280cc6167664 Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Tue, 11 Mar 2025 10:58:33 -0400 Subject: [PATCH 09/22] :white_check_mark: Add unit tests for create folder --- redcap/methods/file_repository.py | 9 ++++----- tests/unit/callback_utils.py | 13 +++++++++++++ tests/unit/test_long_project.py | 5 +++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/redcap/methods/file_repository.py b/redcap/methods/file_repository.py index 02c0b98..9f21354 100644 --- a/redcap/methods/file_repository.py +++ b/redcap/methods/file_repository.py @@ -18,7 +18,7 @@ def create_folder( dag_id: Optional[int] = None, role_id: Optional[int] = None, format_type: Literal["json", "csv", "xml"] = "json", - return_format_type: Optional[Literal["json", "csv", "xml"]] = None, + return_format_type: Literal["json", "csv", "xml"] = "json", ): """ Create a New Folder in the File Repository @@ -52,7 +52,9 @@ def create_folder( [{'folder_id': ...}] """ payload: Dict[str, Any] = self._initialize_payload( - content="fileRepository", format_type=format_type + content="fileRepository", + format_type=format_type, + return_format_type=return_format_type, ) payload["action"] = "createFolder" @@ -67,9 +69,6 @@ def create_folder( if role_id: payload["role_id"] = role_id - if return_format_type: - payload["returnFormat"] = return_format_type - return_type = self._lookup_return_type(format_type, request_type="export") response = cast(Union[Json, str], self._call_api(payload, return_type)) diff --git a/tests/unit/callback_utils.py b/tests/unit/callback_utils.py index 3175a2a..4ede922 100644 --- a/tests/unit/callback_utils.py +++ b/tests/unit/callback_utils.py @@ -312,6 +312,18 @@ def handle_long_project_file_request(**kwargs) -> Any: return (201, headers, json.dumps(resp)) +def handle_long_project_file_repository_request(**kwargs) -> Any: + """Handle file repository requests""" + data = kwargs["data"] + headers = kwargs["headers"] + resp = {} + if "createFolder" in data.get("action", "other"): + assert data["name"] + resp = [{"folder_id": 101}] + + return (201, headers, json.dumps(resp)) + + def handle_generate_next_record_name_request(**kwargs) -> Any: """Handle generating next record name""" headers = kwargs["headers"] @@ -778,6 +790,7 @@ def get_long_project_request_handler(request_type: str) -> Callable: "arm": handle_long_project_arms_request, "event": handle_long_project_events_request, "file": handle_long_project_file_request, + "fileRepository": handle_long_project_file_repository_request, "formEventMapping": handle_long_project_form_event_mapping_request, "instrument": handle_long_project_instruments_request, "repeatingFormsEvents": handle_long_project_repeating_form_request, diff --git a/tests/unit/test_long_project.py b/tests/unit/test_long_project.py index c283336..6998716 100644 --- a/tests/unit/test_long_project.py +++ b/tests/unit/test_long_project.py @@ -254,3 +254,8 @@ def test_events_delete(long_project): response = long_project.delete_events(events) assert response == 1 + + +def test_file_repo_folder_create(long_project): + response = long_project.create_folder(name="test", folder_id=1, dag_id=2, role_id=3) + assert response[0]["folder_id"] From ca2f153f9380eed4378b8dfe3260aa445e4fa0b5 Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Tue, 18 Mar 2025 13:30:53 -0400 Subject: [PATCH 10/22] :sparkles: Add export_file_directory --- redcap/methods/file_repository.py | 50 +++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/redcap/methods/file_repository.py b/redcap/methods/file_repository.py index 9f21354..ba80469 100644 --- a/redcap/methods/file_repository.py +++ b/redcap/methods/file_repository.py @@ -77,3 +77,53 @@ def create_folder( content="fileRepository", format_type=format_type, ) + + def export_file_directory( + self, + folder_id: Optional[int] = None, + format_type: Literal["json", "csv", "xml"] = "json", + return_format_type: Literal["json", "csv", "xml"] = "json", + ): + """ + Export of list of files/folders in the File Repository + + Only exports the top-level of files/folders. To see which files are contained + within a folder, use the `folder_id` parameter + + Args: + folder_id: + The folder_id of a specific folder in the File Repository for which you wish + to search for files/folders. If none is provided, the search will be conducted + in the top-level directory of the File Repository. + format_type: + Return the metadata in native objects, csv or xml. + return_format_type: + Response format. By default, response will be json-decoded. + Returns: + Union[str, List[Dict[str, Any]]]: + List of all changes made to this project, including data exports, + data changes, and the creation or deletion of users + + Examples: + >>> proj.export_file_directory() + [{'folder_id': ..., 'name': 'A Test Folder'}, ...] + """ + payload: Dict[str, Any] = self._initialize_payload( + content="fileRepository", + format_type=format_type, + return_format_type=return_format_type, + ) + + payload["action"] = "list" + + if folder_id: + payload["folder_id"] = folder_id + + return_type = self._lookup_return_type(format_type, request_type="export") + response = cast(Union[Json, str], self._call_api(payload, return_type)) + + return self._return_data( + response=response, + content="fileRepository", + format_type=format_type, + ) From 2646be668348a7fb51a0cb5aef392139182978ea Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Tue, 18 Mar 2025 13:31:12 -0400 Subject: [PATCH 11/22] :white_check_mark: Add integration/doctests for new method --- redcap/conftest.py | 2 ++ tests/integration/conftest.py | 2 ++ tests/integration/test_simple_project.py | 6 ++++++ 3 files changed, 10 insertions(+) diff --git a/redcap/conftest.py b/redcap/conftest.py index 9381e3c..850af52 100644 --- a/redcap/conftest.py +++ b/redcap/conftest.py @@ -28,5 +28,7 @@ def add_doctest_objects(doctest_namespace): ) doctest_project = Project(redcapdemo_url(), doctest_token) doctest_project = grant_superuser_rights(doctest_project) + # Import attributes that aren't saved in an the xml file + doctest_project.create_folder("A Test Folder") doctest_namespace["proj"] = doctest_project doctest_namespace["TOKEN"] = doctest_token diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index b688403..8506185 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -92,6 +92,8 @@ def simple_project(redcapdemo_url_fixture, simple_project_token): """A simple REDCap project""" simple_proj = Project(redcapdemo_url_fixture, simple_project_token) simple_proj = grant_superuser_rights(simple_proj) + # Import attributes that aren't saved in the xml file + simple_proj.create_folder("test") return simple_proj diff --git a/tests/integration/test_simple_project.py b/tests/integration/test_simple_project.py index 2703992..85cbb99 100644 --- a/tests/integration/test_simple_project.py +++ b/tests/integration/test_simple_project.py @@ -305,3 +305,9 @@ def test_create_folder(simple_project): folder_name = "New Folder" new_folder = simple_project.create_folder(name=folder_name) assert new_folder[0]["folder_id"] > 0 + + +@pytest.mark.integration +def test_export_file_directory(simple_project): + directory = simple_project.export_file_directory() + assert len(directory) > 0 From e11846fe54947d93e0bf705039e41d742b0733fe Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Tue, 18 Mar 2025 14:59:37 -0400 Subject: [PATCH 12/22] :name_badge: Rename method --- redcap/conftest.py | 2 +- redcap/methods/file_repository.py | 8 ++++---- tests/integration/conftest.py | 2 +- tests/integration/test_simple_project.py | 8 ++++---- tests/unit/test_long_project.py | 4 +++- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/redcap/conftest.py b/redcap/conftest.py index 850af52..0a19483 100644 --- a/redcap/conftest.py +++ b/redcap/conftest.py @@ -29,6 +29,6 @@ def add_doctest_objects(doctest_namespace): doctest_project = Project(redcapdemo_url(), doctest_token) doctest_project = grant_superuser_rights(doctest_project) # Import attributes that aren't saved in an the xml file - doctest_project.create_folder("A Test Folder") + doctest_project.create_folder_in_repository("A Test Folder") doctest_namespace["proj"] = doctest_project doctest_namespace["TOKEN"] = doctest_token diff --git a/redcap/methods/file_repository.py b/redcap/methods/file_repository.py index ba80469..b7941db 100644 --- a/redcap/methods/file_repository.py +++ b/redcap/methods/file_repository.py @@ -11,7 +11,7 @@ class FileRepository(Base): """Responsible for all API methods under 'File Repository' in the API Playground""" - def create_folder( + def create_folder_in_repository( self, name: str, folder_id: Optional[int] = None, @@ -48,7 +48,7 @@ def create_folder( data changes, and the creation or deletion of users Examples: - >>> proj.create_folder(name="New Folder") + >>> proj.create_folder_in_repository(name="New Folder") [{'folder_id': ...}] """ payload: Dict[str, Any] = self._initialize_payload( @@ -78,7 +78,7 @@ def create_folder( format_type=format_type, ) - def export_file_directory( + def export_file_repository( self, folder_id: Optional[int] = None, format_type: Literal["json", "csv", "xml"] = "json", @@ -105,7 +105,7 @@ def export_file_directory( data changes, and the creation or deletion of users Examples: - >>> proj.export_file_directory() + >>> proj.export_file_repository() [{'folder_id': ..., 'name': 'A Test Folder'}, ...] """ payload: Dict[str, Any] = self._initialize_payload( diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 8506185..f15c03f 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -93,7 +93,7 @@ def simple_project(redcapdemo_url_fixture, simple_project_token): simple_proj = Project(redcapdemo_url_fixture, simple_project_token) simple_proj = grant_superuser_rights(simple_proj) # Import attributes that aren't saved in the xml file - simple_proj.create_folder("test") + simple_proj.create_folder_in_repository("test") return simple_proj diff --git a/tests/integration/test_simple_project.py b/tests/integration/test_simple_project.py index 85cbb99..52d67a3 100644 --- a/tests/integration/test_simple_project.py +++ b/tests/integration/test_simple_project.py @@ -301,13 +301,13 @@ def test_export_instrument_event_mapping(simple_project): @pytest.mark.integration -def test_create_folder(simple_project): +def test_create_folder_in_repository(simple_project): folder_name = "New Folder" - new_folder = simple_project.create_folder(name=folder_name) + new_folder = simple_project.create_folder_in_repository(name=folder_name) assert new_folder[0]["folder_id"] > 0 @pytest.mark.integration -def test_export_file_directory(simple_project): - directory = simple_project.export_file_directory() +def test_export_file_repository(simple_project): + directory = simple_project.export_file_repository() assert len(directory) > 0 diff --git a/tests/unit/test_long_project.py b/tests/unit/test_long_project.py index 6998716..9322fdb 100644 --- a/tests/unit/test_long_project.py +++ b/tests/unit/test_long_project.py @@ -257,5 +257,7 @@ def test_events_delete(long_project): def test_file_repo_folder_create(long_project): - response = long_project.create_folder(name="test", folder_id=1, dag_id=2, role_id=3) + response = long_project.create_folder_in_repository( + name="test", folder_id=1, dag_id=2, role_id=3 + ) assert response[0]["folder_id"] From 6ce4ec303225d99c63008226aa98b67ea0c7af98 Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Tue, 18 Mar 2025 15:15:36 -0400 Subject: [PATCH 13/22] :sparkles: Add import method for file repo --- redcap/methods/file_repository.py | 48 ++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/redcap/methods/file_repository.py b/redcap/methods/file_repository.py index b7941db..32d4477 100644 --- a/redcap/methods/file_repository.py +++ b/redcap/methods/file_repository.py @@ -3,9 +3,10 @@ from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Union, cast from redcap.methods.base import Base, Json +from redcap.request import EmptyJson, FileUpload if TYPE_CHECKING: - import pandas as pd + from io import TextIOWrapper class FileRepository(Base): @@ -127,3 +128,48 @@ def export_file_repository( content="fileRepository", format_type=format_type, ) + + def import_file_into_repository( + self, + file_name: str, + file_object: "TextIOWrapper", + folder_id: Optional[int] = None, + ) -> EmptyJson: + """ + Import the contents of a file represented by file_object into + the file repository + + Args: + file_name: File name visible in REDCap UI + file_object: File object as returned by `open` + folder_id: + The folder_id of a specific folder in the File Repository where + you wish to store the file. If none is provided, the file will + be stored in the top-level directory of the File Repository. + + Returns: + Empty JSON object + + Examples: + >>> import tempfile + >>> tmp_file = tempfile.TemporaryFile() + >>> proj.import_file_into_repository( + ... file_name="myupload.txt", + ... file_object=tmp_file, + ... ) + [{}] + """ + payload: Dict[str, Any] = self._initialize_payload(content="fileRepository") + payload["action"] = "import" + + if folder_id: + payload["folder_id"] = folder_id + + file_upload_dict: FileUpload = {"file": (file_name, file_object)} + + return cast( + EmptyJson, + self._call_api( + payload=payload, return_type="empty_json", file=file_upload_dict + ), + ) From ab9df3ec8455444f7605827cedf6e82f7d81f0ac Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Wed, 19 Mar 2025 12:04:07 -0400 Subject: [PATCH 14/22] :white_check_mark: Add integration test for file repo import --- tests/integration/conftest.py | 25 +++++++++++++++++++++--- tests/integration/test_simple_project.py | 16 +++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index f15c03f..64b6123 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,9 +1,10 @@ """Test fixtures for integration tests only""" # pylint: disable=redefined-outer-name -from datetime import datetime import os +import tempfile +from datetime import datetime from pathlib import Path from typing import cast @@ -87,13 +88,31 @@ def grant_superuser_rights(proj: Project) -> Project: return proj +def add_files_to_repository(proj: Project) -> Project: + """Given a project, fill out it's file repository + For some reason, this doesn't carry over in the XML file so + it has to be done after project creation + """ + new_folder = proj.create_folder_in_repository("test").pop() + + tmp_file = tempfile.TemporaryFile() + proj.import_file_into_repository(file_name="test.txt", file_object=tmp_file) + proj.import_file_into_repository( + file_name="test_in_folder.txt", + file_object=tmp_file, + folder_id=new_folder["folder_id"], + ) + + return proj + + @pytest.fixture(scope="module") def simple_project(redcapdemo_url_fixture, simple_project_token): """A simple REDCap project""" simple_proj = Project(redcapdemo_url_fixture, simple_project_token) simple_proj = grant_superuser_rights(simple_proj) - # Import attributes that aren't saved in the xml file - simple_proj.create_folder_in_repository("test") + simple_proj = add_files_to_repository(simple_proj) + return simple_proj diff --git a/tests/integration/test_simple_project.py b/tests/integration/test_simple_project.py index 52d67a3..1301e9a 100644 --- a/tests/integration/test_simple_project.py +++ b/tests/integration/test_simple_project.py @@ -2,6 +2,8 @@ # pylint: disable=missing-function-docstring import os +import tempfile + from io import StringIO import pandas as pd @@ -311,3 +313,17 @@ def test_create_folder_in_repository(simple_project): def test_export_file_repository(simple_project): directory = simple_project.export_file_repository() assert len(directory) > 0 + + +@pytest.mark.integration +def test_import_file_repository(simple_project): + initial_len = len(simple_project.export_file_repository()) + + tmp_file = tempfile.TemporaryFile() + simple_project.import_file_into_repository( + file_name="new_upload.txt", file_object=tmp_file + ) + + new_len = len(simple_project.export_file_repository()) + + assert new_len > initial_len From 8aba7d45913c3f530531d98dee9c0947c2a9ccf9 Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Wed, 19 Mar 2025 15:33:25 -0400 Subject: [PATCH 15/22] :white_check_mark: Add unit tests for export list/import repo --- tests/unit/callback_utils.py | 21 ++++++++++++++++++--- tests/unit/test_long_project.py | 12 ++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/tests/unit/callback_utils.py b/tests/unit/callback_utils.py index 4ede922..daff444 100644 --- a/tests/unit/callback_utils.py +++ b/tests/unit/callback_utils.py @@ -312,14 +312,29 @@ def handle_long_project_file_request(**kwargs) -> Any: return (201, headers, json.dumps(resp)) +def handle_long_project_file_repo_create_folder(data) -> Any: + """Handle the create folder request""" + assert data["name"] + return [{"folder_id": 101}] + + +def handle_long_project_export_file_repo(data) -> Any: + """Handle the export file/folder request""" + assert data + return [{"folder_id": 101, "name": "test"}] + + def handle_long_project_file_repository_request(**kwargs) -> Any: """Handle file repository requests""" data = kwargs["data"] headers = kwargs["headers"] resp = {} - if "createFolder" in data.get("action", "other"): - assert data["name"] - resp = [{"folder_id": 101}] + if "createFolder" in data.get("action"): + resp = handle_long_project_file_repo_create_folder(data) + elif "list" in data.get("action"): + resp = handle_long_project_export_file_repo(data) + elif "import" in data.get("action"): + resp = [{}] return (201, headers, json.dumps(resp)) diff --git a/tests/unit/test_long_project.py b/tests/unit/test_long_project.py index 9322fdb..6834f67 100644 --- a/tests/unit/test_long_project.py +++ b/tests/unit/test_long_project.py @@ -3,6 +3,7 @@ # pylint: disable=missing-function-docstring # pylint: disable=redefined-outer-name import os +import tempfile import pandas as pd import pytest @@ -261,3 +262,14 @@ def test_file_repo_folder_create(long_project): name="test", folder_id=1, dag_id=2, role_id=3 ) assert response[0]["folder_id"] + + +def test_file_export_file_repo(long_project): + response = long_project.export_file_repository(folder_id=1) + assert is_json(response) + + +def test_import_file_into_file_repo(long_project): + tmp_file = tempfile.TemporaryFile() + resp = long_project.import_file_into_repository("test.txt", tmp_file, folder_id=1) + assert resp From b6970a5964a0b7871e9623653d313d3d5ffe4ae5 Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Thu, 20 Mar 2025 14:05:50 -0400 Subject: [PATCH 16/22] :gear: Configure files for tests --- redcap/conftest.py | 5 +++-- tests/integration/conftest.py | 8 +++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/redcap/conftest.py b/redcap/conftest.py index 0a19483..7b58ae6 100644 --- a/redcap/conftest.py +++ b/redcap/conftest.py @@ -10,6 +10,7 @@ from redcap.project import Project from tests.integration.conftest import ( + add_files_to_repository, create_project, grant_superuser_rights, redcapdemo_url, @@ -28,7 +29,7 @@ def add_doctest_objects(doctest_namespace): ) doctest_project = Project(redcapdemo_url(), doctest_token) doctest_project = grant_superuser_rights(doctest_project) - # Import attributes that aren't saved in an the xml file - doctest_project.create_folder_in_repository("A Test Folder") + doctest_project = add_files_to_repository(doctest_project) + doctest_namespace["proj"] = doctest_project doctest_namespace["TOKEN"] = doctest_token diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 64b6123..ed88288 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -95,7 +95,13 @@ def add_files_to_repository(proj: Project) -> Project: """ new_folder = proj.create_folder_in_repository("test").pop() - tmp_file = tempfile.TemporaryFile() + # pylint: disable=consider-using-with + # Can't figure out how to do this in a cleaner way + tmp_file = tempfile.NamedTemporaryFile() + # pylint: enable=consider-using-with + with open(tmp_file.name, mode="w", encoding="utf-8") as tmp: + tmp.write("hello") + proj.import_file_into_repository(file_name="test.txt", file_object=tmp_file) proj.import_file_into_repository( file_name="test_in_folder.txt", From 3c79a4c6b875a2e7feffd7813f9a02cbde52c8e4 Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Thu, 20 Mar 2025 14:06:54 -0400 Subject: [PATCH 17/22] :white_check_marK: Adjust typing --- redcap/methods/base.py | 2 +- redcap/methods/file_repository.py | 9 +++------ redcap/methods/files.py | 6 ++---- redcap/request.py | 5 ++--- 4 files changed, 8 insertions(+), 14 deletions(-) diff --git a/redcap/methods/base.py b/redcap/methods/base.py index a98ef59..87e197f 100644 --- a/redcap/methods/base.py +++ b/redcap/methods/base.py @@ -260,7 +260,7 @@ def _initialize_payload( format_type: Optional[Literal["json", "csv", "xml", "df"]] = None, return_format_type: Optional[Literal["json", "csv", "xml"]] = None, record_type: Literal["flat", "eav"] = "flat", - ) -> Dict[str, str]: + ) -> Dict[str, Any]: """Create the default dictionary for payloads This can be used as is for simple API requests or added to diff --git a/redcap/methods/file_repository.py b/redcap/methods/file_repository.py index 32d4477..82ecdbe 100644 --- a/redcap/methods/file_repository.py +++ b/redcap/methods/file_repository.py @@ -1,13 +1,10 @@ """REDCap API methods for Project file repository""" -from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Union, cast +from typing import TYPE_CHECKING, Any, Dict, IO, Literal, Optional, Union, cast -from redcap.methods.base import Base, Json +from redcap.methods.base import Base, FileMap, Json from redcap.request import EmptyJson, FileUpload -if TYPE_CHECKING: - from io import TextIOWrapper - class FileRepository(Base): """Responsible for all API methods under 'File Repository' in the API Playground""" @@ -132,7 +129,7 @@ def export_file_repository( def import_file_into_repository( self, file_name: str, - file_object: "TextIOWrapper", + file_object: IO, folder_id: Optional[int] = None, ) -> EmptyJson: """ diff --git a/redcap/methods/files.py b/redcap/methods/files.py index cfdee06..d5da6c2 100644 --- a/redcap/methods/files.py +++ b/redcap/methods/files.py @@ -1,12 +1,10 @@ """REDCap API methods for Project files""" -from typing import TYPE_CHECKING, Any, Dict, Optional, Union, cast +from typing import TYPE_CHECKING, Any, Dict, IO, Optional, Union, cast from redcap.methods.base import Base, FileMap from redcap.request import EmptyJson, FileUpload -if TYPE_CHECKING: - from io import TextIOWrapper class Files(Base): @@ -90,7 +88,7 @@ def import_file( record: str, field: str, file_name: str, - file_object: "TextIOWrapper", + file_object: IO, event: Optional[str] = None, repeat_instance: Optional[Union[int, str]] = None, ) -> EmptyJson: diff --git a/redcap/request.py b/redcap/request.py index 5e5474f..312e633 100644 --- a/redcap/request.py +++ b/redcap/request.py @@ -8,6 +8,7 @@ Any, Dict, List, + IO, Literal, Optional, Tuple, @@ -18,8 +19,6 @@ from requests import RequestException, Response, Session -if TYPE_CHECKING: - from io import TextIOWrapper Json = List[Dict[str, Any]] EmptyJson = List[dict] @@ -36,7 +35,7 @@ class FileUpload(TypedDict): """Typing for the file upload API""" - file: Tuple[str, "TextIOWrapper"] + file: Tuple[str, IO] _ContentConfig = namedtuple("_ContentConfig", ["return_empty_json", "return_bytes"]) From 7adbec668aecb6a1c771c036d877198d328a0544 Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Thu, 20 Mar 2025 14:07:24 -0400 Subject: [PATCH 18/22] :sparkles: Add export file method to file repo api --- redcap/methods/file_repository.py | 49 +++++++++++++++++++++++- tests/integration/test_simple_project.py | 10 +++++ tests/unit/callback_utils.py | 9 +++++ tests/unit/test_long_project.py | 8 +++- 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/redcap/methods/file_repository.py b/redcap/methods/file_repository.py index 82ecdbe..74e7165 100644 --- a/redcap/methods/file_repository.py +++ b/redcap/methods/file_repository.py @@ -104,7 +104,7 @@ def export_file_repository( Examples: >>> proj.export_file_repository() - [{'folder_id': ..., 'name': 'A Test Folder'}, ...] + [{'folder_id': ..., 'name': 'New Folder'}, ...] """ payload: Dict[str, Any] = self._initialize_payload( content="fileRepository", @@ -126,6 +126,53 @@ def export_file_repository( format_type=format_type, ) + def export_file_from_repository( + self, + doc_id: int, + return_format_type: Literal["json", "csv", "xml"] = "json", + ) -> FileMap: + """ + Export the contents of a file stored in the File Repository + + Args: + doc_id: The doc_id of the file in the File Repository + return_format_type: + Response format. By default, response will be json-decoded. + + Returns: + Content of the file and content-type dictionary + + Examples: + >>> file_dir = proj.export_file_repository() + >>> text_file = [file for file in file_dir if file["name"] == "test.txt"].pop() + >>> proj.export_file_from_repository(doc_id=text_file["doc_id"]) + (b'hello', {'name': 'test.txt', 'charset': 'UTF-8'}) + """ + payload = self._initialize_payload( + content="fileRepository", return_format_type=return_format_type + ) + # there's no format field in this call + payload["action"] = "export" + payload["doc_id"] = doc_id + + content, headers = cast( + FileMap, self._call_api(payload=payload, return_type="file_map") + ) + # REDCap adds some useful things in content-type + content_map = {} + if "content-type" in headers: + splat = [ + key_values.strip() for key_values in headers["content-type"].split(";") + ] + key_values = [ + (key_values.split("=")[0], key_values.split("=")[1].replace('"', "")) + for key_values in splat + if "=" in key_values + ] + content_map = dict(key_values) + + return content, content_map + def import_file_into_repository( self, file_name: str, diff --git a/tests/integration/test_simple_project.py b/tests/integration/test_simple_project.py index 1301e9a..909ea56 100644 --- a/tests/integration/test_simple_project.py +++ b/tests/integration/test_simple_project.py @@ -315,6 +315,16 @@ def test_export_file_repository(simple_project): assert len(directory) > 0 +@pytest.mark.integration +def test_export_file_from_repository(simple_project): + file_dir = simple_project.export_file_repository() + text_file = [file for file in file_dir if file["name"] == "test.txt"].pop() + file_contents, _ = simple_project.export_file_from_repository( + doc_id=text_file["doc_id"] + ) + assert isinstance(file_contents, bytes) + + @pytest.mark.integration def test_import_file_repository(simple_project): initial_len = len(simple_project.export_file_repository()) diff --git a/tests/unit/callback_utils.py b/tests/unit/callback_utils.py index daff444..4117974 100644 --- a/tests/unit/callback_utils.py +++ b/tests/unit/callback_utils.py @@ -324,6 +324,12 @@ def handle_long_project_export_file_repo(data) -> Any: return [{"folder_id": 101, "name": "test"}] +def handle_long_project_export_file_from_repo(data) -> Any: + """Handle the export file from repo request""" + assert data["doc_id"] + return {} + + def handle_long_project_file_repository_request(**kwargs) -> Any: """Handle file repository requests""" data = kwargs["data"] @@ -333,6 +339,9 @@ def handle_long_project_file_repository_request(**kwargs) -> Any: resp = handle_long_project_file_repo_create_folder(data) elif "list" in data.get("action"): resp = handle_long_project_export_file_repo(data) + elif "export" in data.get("action"): + resp = handle_long_project_export_file_from_repo(data) + headers["content-type"] = "text/plain;name=test.txt" elif "import" in data.get("action"): resp = [{}] diff --git a/tests/unit/test_long_project.py b/tests/unit/test_long_project.py index 6834f67..cc49ea3 100644 --- a/tests/unit/test_long_project.py +++ b/tests/unit/test_long_project.py @@ -264,11 +264,17 @@ def test_file_repo_folder_create(long_project): assert response[0]["folder_id"] -def test_file_export_file_repo(long_project): +def test_export_file_repo(long_project): response = long_project.export_file_repository(folder_id=1) assert is_json(response) +def test_export_file_from_repo(long_project): + resp, headers = long_project.export_file_from_repository(doc_id=1) + assert isinstance(resp, bytes) + assert headers["name"] == "test.txt" + + def test_import_file_into_file_repo(long_project): tmp_file = tempfile.TemporaryFile() resp = long_project.import_file_into_repository("test.txt", tmp_file, folder_id=1) From 0d952ebfa5e377e5d91c0814e544ef726132269d Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Tue, 15 Apr 2025 12:39:07 -0400 Subject: [PATCH 19/22] :bath: Clean up imports and returns --- redcap/methods/file_repository.py | 16 +++------------- redcap/methods/files.py | 3 +-- redcap/request.py | 1 - 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/redcap/methods/file_repository.py b/redcap/methods/file_repository.py index 74e7165..398ea38 100644 --- a/redcap/methods/file_repository.py +++ b/redcap/methods/file_repository.py @@ -1,6 +1,6 @@ """REDCap API methods for Project file repository""" -from typing import TYPE_CHECKING, Any, Dict, IO, Literal, Optional, Union, cast +from typing import Any, Dict, IO, Literal, Optional, Union, cast from redcap.methods.base import Base, FileMap, Json from redcap.request import EmptyJson, FileUpload @@ -68,13 +68,8 @@ def create_folder_in_repository( payload["role_id"] = role_id return_type = self._lookup_return_type(format_type, request_type="export") - response = cast(Union[Json, str], self._call_api(payload, return_type)) - return self._return_data( - response=response, - content="fileRepository", - format_type=format_type, - ) + return cast(Union[Json, str], self._call_api(payload, return_type)) def export_file_repository( self, @@ -118,13 +113,8 @@ def export_file_repository( payload["folder_id"] = folder_id return_type = self._lookup_return_type(format_type, request_type="export") - response = cast(Union[Json, str], self._call_api(payload, return_type)) - return self._return_data( - response=response, - content="fileRepository", - format_type=format_type, - ) + return cast(Union[Json, str], self._call_api(payload, return_type)) def export_file_from_repository( self, diff --git a/redcap/methods/files.py b/redcap/methods/files.py index d5da6c2..405e6f9 100644 --- a/redcap/methods/files.py +++ b/redcap/methods/files.py @@ -1,12 +1,11 @@ """REDCap API methods for Project files""" -from typing import TYPE_CHECKING, Any, Dict, IO, Optional, Union, cast +from typing import Any, Dict, IO, Optional, Union, cast from redcap.methods.base import Base, FileMap from redcap.request import EmptyJson, FileUpload - class Files(Base): """Responsible for all API methods under 'Files' in the API Playground""" diff --git a/redcap/request.py b/redcap/request.py index 312e633..4a58636 100644 --- a/redcap/request.py +++ b/redcap/request.py @@ -4,7 +4,6 @@ from collections import namedtuple from typing import ( - TYPE_CHECKING, Any, Dict, List, From fed34dcfb2ff688652670f05e07e8306edcf64c7 Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Tue, 15 Apr 2025 12:39:33 -0400 Subject: [PATCH 20/22] :sparkles: Add delete file from repo method --- redcap/methods/file_repository.py | 39 ++++++++++++++++++++++++ tests/integration/test_simple_project.py | 8 +++++ tests/unit/callback_utils.py | 2 +- tests/unit/test_long_project.py | 5 +++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/redcap/methods/file_repository.py b/redcap/methods/file_repository.py index 398ea38..1373c12 100644 --- a/redcap/methods/file_repository.py +++ b/redcap/methods/file_repository.py @@ -207,3 +207,42 @@ def import_file_into_repository( payload=payload, return_type="empty_json", file=file_upload_dict ), ) + + def delete_file_from_repository( + self, + doc_id: int, + return_format_type: Literal["json", "csv", "xml"] = "json", + ) -> EmptyJson: + # pylint: disable=line-too-long + """ + Delete a File from the File Repository + + Once deleted, the file will remain in the Recycle Bin folder for up to 30 days. + + Args: + doc_id: The doc_id of the file in the File Repository + return_format_type: + Response format. By default, response will be json-decoded. + + Returns: + Empty JSON object + + Examples: + >>> file_dir = proj.export_file_repository() + >>> test_folder = [folder for folder in file_dir if folder["name"] == "test"].pop() + >>> test_dir = proj.export_file_repository(folder_id=test_folder["folder_id"]) + >>> test_file = [file for file in test_dir if file["name"] == "test_in_folder.txt"].pop() + >>> proj.delete_file_from_repository(doc_id=test_file["doc_id"]) + [{}] + """ + # pylint: enable=line-too-long + payload = self._initialize_payload( + content="fileRepository", return_format_type=return_format_type + ) + # there's no format field in this call + payload["action"] = "delete" + payload["doc_id"] = doc_id + + return cast( + EmptyJson, self._call_api(payload=payload, return_type="empty_json") + ) diff --git a/tests/integration/test_simple_project.py b/tests/integration/test_simple_project.py index 909ea56..4c660a3 100644 --- a/tests/integration/test_simple_project.py +++ b/tests/integration/test_simple_project.py @@ -337,3 +337,11 @@ def test_import_file_repository(simple_project): new_len = len(simple_project.export_file_repository()) assert new_len > initial_len + + +@pytest.mark.integration +def test_delete_file_from_repository(simple_project): + file_dir = simple_project.export_file_repository() + text_file = [file for file in file_dir if file["name"] == "test.txt"].pop() + resp = simple_project.delete_file_from_repository(doc_id=text_file["doc_id"]) + assert resp == [{}] diff --git a/tests/unit/callback_utils.py b/tests/unit/callback_utils.py index 4117974..8ab167e 100644 --- a/tests/unit/callback_utils.py +++ b/tests/unit/callback_utils.py @@ -342,7 +342,7 @@ def handle_long_project_file_repository_request(**kwargs) -> Any: elif "export" in data.get("action"): resp = handle_long_project_export_file_from_repo(data) headers["content-type"] = "text/plain;name=test.txt" - elif "import" in data.get("action"): + elif data.get("action") in ["import", "delete"]: resp = [{}] return (201, headers, json.dumps(resp)) diff --git a/tests/unit/test_long_project.py b/tests/unit/test_long_project.py index cc49ea3..9c4a147 100644 --- a/tests/unit/test_long_project.py +++ b/tests/unit/test_long_project.py @@ -279,3 +279,8 @@ def test_import_file_into_file_repo(long_project): tmp_file = tempfile.TemporaryFile() resp = long_project.import_file_into_repository("test.txt", tmp_file, folder_id=1) assert resp + + +def test_delete_file_from_file_repo(long_project): + resp = long_project.delete_file_from_repository(doc_id=1) + assert resp From 158d9046d6b38a974cc5cd6f282addba520e3e3e Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Tue, 15 Apr 2025 12:45:53 -0400 Subject: [PATCH 21/22] :memo: Add File Repository to docs --- README.md | 3 +++ docs/api_reference/file_repository.md | 5 +++++ mkdocs.yml | 1 + 3 files changed, 9 insertions(+) create mode 100644 docs/api_reference/file_repository.md diff --git a/README.md b/README.md index 5f565a3..aad8cf2 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Currently, these API calls are available: * Instruments * Instrument-event mapping * File +* File Repository * Logging * Metadata * Project Info @@ -68,6 +69,7 @@ Currently, these API calls are available: * Data Access Groups * Events * File +* File Repository * Instrument-event mapping * Metadata * Records @@ -83,6 +85,7 @@ Currently, these API calls are available: * Data Access Groups * Events * File +* File Repository * Records * Users * User Roles diff --git a/docs/api_reference/file_repository.md b/docs/api_reference/file_repository.md new file mode 100644 index 0000000..b4e2b51 --- /dev/null +++ b/docs/api_reference/file_repository.md @@ -0,0 +1,5 @@ +# File Repository + +::: redcap.methods.file_repository + selection: + inherited_members: true diff --git a/mkdocs.yml b/mkdocs.yml index f956601..0fdf4b5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,6 +14,7 @@ nav: - Data Access Groups: api_reference/data_access_groups.md - Events: api_reference/events.md - Field Names: api_reference/field_names.md + - File Repository: api_reference/file_repository.md - Files: api_reference/files.md - Instruments: api_reference/instruments.md - Logging: api_reference/logging.md From cdcafb1af163750495d8415a23445c29a90b7f25 Mon Sep 17 00:00:00 2001 From: Paul Wildenhain Date: Tue, 15 Apr 2025 13:36:58 -0400 Subject: [PATCH 22/22] :bug: Fix tempfile creation for windows --- tests/integration/conftest.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index ed88288..e9b82cf 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -95,19 +95,16 @@ def add_files_to_repository(proj: Project) -> Project: """ new_folder = proj.create_folder_in_repository("test").pop() - # pylint: disable=consider-using-with - # Can't figure out how to do this in a cleaner way - tmp_file = tempfile.NamedTemporaryFile() - # pylint: enable=consider-using-with - with open(tmp_file.name, mode="w", encoding="utf-8") as tmp: - tmp.write("hello") - - proj.import_file_into_repository(file_name="test.txt", file_object=tmp_file) - proj.import_file_into_repository( - file_name="test_in_folder.txt", - file_object=tmp_file, - folder_id=new_folder["folder_id"], - ) + with tempfile.NamedTemporaryFile(mode="w+t") as tmp_file: + tmp_file.write("hello") + tmp_file.seek(0) + + proj.import_file_into_repository(file_name="test.txt", file_object=tmp_file) + proj.import_file_into_repository( + file_name="test_in_folder.txt", + file_object=tmp_file, + folder_id=new_folder["folder_id"], + ) return proj