Skip to content

Commit 01b33c9

Browse files
authored
✨ Add file repository API (#297)
1 parent 1f91197 commit 01b33c9

File tree

17 files changed

+435
-37
lines changed

17 files changed

+435
-37
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Currently, these API calls are available:
4848
* Instruments
4949
* Instrument-event mapping
5050
* File
51+
* File Repository
5152
* Logging
5253
* Metadata
5354
* Project Info
@@ -68,6 +69,7 @@ Currently, these API calls are available:
6869
* Data Access Groups
6970
* Events
7071
* File
72+
* File Repository
7173
* Instrument-event mapping
7274
* Metadata
7375
* Records
@@ -83,6 +85,7 @@ Currently, these API calls are available:
8385
* Data Access Groups
8486
* Events
8587
* File
88+
* File Repository
8689
* Records
8790
* Users
8891
* User Roles
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# File Repository
2+
3+
::: redcap.methods.file_repository
4+
selection:
5+
inherited_members: true

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ nav:
1414
- Data Access Groups: api_reference/data_access_groups.md
1515
- Events: api_reference/events.md
1616
- Field Names: api_reference/field_names.md
17+
- File Repository: api_reference/file_repository.md
1718
- Files: api_reference/files.md
1819
- Instruments: api_reference/instruments.md
1920
- Logging: api_reference/logging.md

pytest.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
doctest_optionflags = NORMALIZE_WHITESPACE ELLIPSIS FAIL_FAST REPORT_NDIFF
33
addopts = -rsxX -l --tb=short --strict --pylint --black --cov=redcap --cov-report=xml --mypy
44
markers =
5-
integration: test connects to redcapdemo.vanderbilt.edu server
5+
integration: test connects to redcapdemo.vumc.org server
66
# Keep current format for future version of pytest
77
junit_family=xunit1
88
# Ignore unimportant warnings

redcap/conftest.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,26 @@
1010

1111
from redcap.project import Project
1212
from tests.integration.conftest import (
13+
add_files_to_repository,
1314
create_project,
1415
grant_superuser_rights,
16+
redcapdemo_url,
1517
SUPER_TOKEN,
1618
)
1719

1820

1921
@pytest.fixture(scope="session", autouse=True)
2022
def add_doctest_objects(doctest_namespace):
2123
"""Add the doctest project instance to the doctest_namespace"""
22-
url = "https://redcapdemo.vanderbilt.edu/api/"
2324
doctest_project_xml = Path("tests/data/doctest_project.xml")
2425
doctest_token = create_project(
25-
url=url,
26+
url=redcapdemo_url(),
2627
super_token=SUPER_TOKEN,
2728
project_xml_path=doctest_project_xml,
2829
)
29-
doctest_project = Project(url, doctest_token)
30+
doctest_project = Project(redcapdemo_url(), doctest_token)
3031
doctest_project = grant_superuser_rights(doctest_project)
32+
doctest_project = add_files_to_repository(doctest_project)
33+
3134
doctest_namespace["proj"] = doctest_project
3235
doctest_namespace["TOKEN"] = doctest_token

redcap/methods/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import redcap.methods.data_access_groups
55
import redcap.methods.events
66
import redcap.methods.field_names
7+
import redcap.methods.file_repository
78
import redcap.methods.files
89
import redcap.methods.instruments
910
import redcap.methods.logging

redcap/methods/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ def _initialize_payload(
260260
format_type: Optional[Literal["json", "csv", "xml", "df"]] = None,
261261
return_format_type: Optional[Literal["json", "csv", "xml"]] = None,
262262
record_type: Literal["flat", "eav"] = "flat",
263-
) -> Dict[str, str]:
263+
) -> Dict[str, Any]:
264264
"""Create the default dictionary for payloads
265265
266266
This can be used as is for simple API requests or added to
@@ -339,6 +339,7 @@ def _return_data(
339339
"dag",
340340
"event",
341341
"exportFieldNames",
342+
"fileRepository",
342343
"formEventMapping",
343344
"instrument",
344345
"log",

redcap/methods/file_repository.py

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
"""REDCap API methods for Project file repository"""
2+
3+
from typing import Any, Dict, IO, Literal, Optional, Union, cast
4+
5+
from redcap.methods.base import Base, FileMap, Json
6+
from redcap.request import EmptyJson, FileUpload
7+
8+
9+
class FileRepository(Base):
10+
"""Responsible for all API methods under 'File Repository' in the API Playground"""
11+
12+
def create_folder_in_repository(
13+
self,
14+
name: str,
15+
folder_id: Optional[int] = None,
16+
dag_id: Optional[int] = None,
17+
role_id: Optional[int] = None,
18+
format_type: Literal["json", "csv", "xml"] = "json",
19+
return_format_type: Literal["json", "csv", "xml"] = "json",
20+
):
21+
"""
22+
Create a New Folder in the File Repository
23+
24+
Args:
25+
name:
26+
The desired name of the folder to be created (max length = 150 characters)
27+
folder_id:
28+
The folder_id of a specific folder in the File Repository for which you wish
29+
to create this sub-folder. If none is provided, the folder will be created in
30+
the top-level directory of the File Repository.
31+
dag_id:
32+
The dag_id of the DAG (Data Access Group) to which you wish to restrict
33+
access for this folder. If none is provided, the folder will accessible to
34+
users in all DAGs and users in no DAGs.
35+
role_id:
36+
The role_id of the User Role to which you wish to restrict access for this
37+
folder. If none is provided, the folder will accessible to users in all
38+
User Roles and users in no User Roles.
39+
format_type:
40+
Return the metadata in native objects, csv or xml.
41+
return_format_type:
42+
Response format. By default, response will be json-decoded.
43+
Returns:
44+
Union[str, List[Dict[str, Any]]]:
45+
List of all changes made to this project, including data exports,
46+
data changes, and the creation or deletion of users
47+
48+
Examples:
49+
>>> proj.create_folder_in_repository(name="New Folder")
50+
[{'folder_id': ...}]
51+
"""
52+
payload: Dict[str, Any] = self._initialize_payload(
53+
content="fileRepository",
54+
format_type=format_type,
55+
return_format_type=return_format_type,
56+
)
57+
58+
payload["action"] = "createFolder"
59+
payload["name"] = name
60+
61+
if folder_id:
62+
payload["folder_id"] = folder_id
63+
64+
if dag_id:
65+
payload["dag_id"] = dag_id
66+
67+
if role_id:
68+
payload["role_id"] = role_id
69+
70+
return_type = self._lookup_return_type(format_type, request_type="export")
71+
72+
return cast(Union[Json, str], self._call_api(payload, return_type))
73+
74+
def export_file_repository(
75+
self,
76+
folder_id: Optional[int] = None,
77+
format_type: Literal["json", "csv", "xml"] = "json",
78+
return_format_type: Literal["json", "csv", "xml"] = "json",
79+
):
80+
"""
81+
Export of list of files/folders in the File Repository
82+
83+
Only exports the top-level of files/folders. To see which files are contained
84+
within a folder, use the `folder_id` parameter
85+
86+
Args:
87+
folder_id:
88+
The folder_id of a specific folder in the File Repository for which you wish
89+
to search for files/folders. If none is provided, the search will be conducted
90+
in the top-level directory of the File Repository.
91+
format_type:
92+
Return the metadata in native objects, csv or xml.
93+
return_format_type:
94+
Response format. By default, response will be json-decoded.
95+
Returns:
96+
Union[str, List[Dict[str, Any]]]:
97+
List of all changes made to this project, including data exports,
98+
data changes, and the creation or deletion of users
99+
100+
Examples:
101+
>>> proj.export_file_repository()
102+
[{'folder_id': ..., 'name': 'New Folder'}, ...]
103+
"""
104+
payload: Dict[str, Any] = self._initialize_payload(
105+
content="fileRepository",
106+
format_type=format_type,
107+
return_format_type=return_format_type,
108+
)
109+
110+
payload["action"] = "list"
111+
112+
if folder_id:
113+
payload["folder_id"] = folder_id
114+
115+
return_type = self._lookup_return_type(format_type, request_type="export")
116+
117+
return cast(Union[Json, str], self._call_api(payload, return_type))
118+
119+
def export_file_from_repository(
120+
self,
121+
doc_id: int,
122+
return_format_type: Literal["json", "csv", "xml"] = "json",
123+
) -> FileMap:
124+
"""
125+
Export the contents of a file stored in the File Repository
126+
127+
Args:
128+
doc_id: The doc_id of the file in the File Repository
129+
return_format_type:
130+
Response format. By default, response will be json-decoded.
131+
132+
Returns:
133+
Content of the file and content-type dictionary
134+
135+
Examples:
136+
>>> file_dir = proj.export_file_repository()
137+
>>> text_file = [file for file in file_dir if file["name"] == "test.txt"].pop()
138+
>>> proj.export_file_from_repository(doc_id=text_file["doc_id"])
139+
(b'hello', {'name': 'test.txt', 'charset': 'UTF-8'})
140+
"""
141+
payload = self._initialize_payload(
142+
content="fileRepository", return_format_type=return_format_type
143+
)
144+
# there's no format field in this call
145+
payload["action"] = "export"
146+
payload["doc_id"] = doc_id
147+
148+
content, headers = cast(
149+
FileMap, self._call_api(payload=payload, return_type="file_map")
150+
)
151+
# REDCap adds some useful things in content-type
152+
content_map = {}
153+
if "content-type" in headers:
154+
splat = [
155+
key_values.strip() for key_values in headers["content-type"].split(";")
156+
]
157+
key_values = [
158+
(key_values.split("=")[0], key_values.split("=")[1].replace('"', ""))
159+
for key_values in splat
160+
if "=" in key_values
161+
]
162+
content_map = dict(key_values)
163+
164+
return content, content_map
165+
166+
def import_file_into_repository(
167+
self,
168+
file_name: str,
169+
file_object: IO,
170+
folder_id: Optional[int] = None,
171+
) -> EmptyJson:
172+
"""
173+
Import the contents of a file represented by file_object into
174+
the file repository
175+
176+
Args:
177+
file_name: File name visible in REDCap UI
178+
file_object: File object as returned by `open`
179+
folder_id:
180+
The folder_id of a specific folder in the File Repository where
181+
you wish to store the file. If none is provided, the file will
182+
be stored in the top-level directory of the File Repository.
183+
184+
Returns:
185+
Empty JSON object
186+
187+
Examples:
188+
>>> import tempfile
189+
>>> tmp_file = tempfile.TemporaryFile()
190+
>>> proj.import_file_into_repository(
191+
... file_name="myupload.txt",
192+
... file_object=tmp_file,
193+
... )
194+
[{}]
195+
"""
196+
payload: Dict[str, Any] = self._initialize_payload(content="fileRepository")
197+
payload["action"] = "import"
198+
199+
if folder_id:
200+
payload["folder_id"] = folder_id
201+
202+
file_upload_dict: FileUpload = {"file": (file_name, file_object)}
203+
204+
return cast(
205+
EmptyJson,
206+
self._call_api(
207+
payload=payload, return_type="empty_json", file=file_upload_dict
208+
),
209+
)
210+
211+
def delete_file_from_repository(
212+
self,
213+
doc_id: int,
214+
return_format_type: Literal["json", "csv", "xml"] = "json",
215+
) -> EmptyJson:
216+
# pylint: disable=line-too-long
217+
"""
218+
Delete a File from the File Repository
219+
220+
Once deleted, the file will remain in the Recycle Bin folder for up to 30 days.
221+
222+
Args:
223+
doc_id: The doc_id of the file in the File Repository
224+
return_format_type:
225+
Response format. By default, response will be json-decoded.
226+
227+
Returns:
228+
Empty JSON object
229+
230+
Examples:
231+
>>> file_dir = proj.export_file_repository()
232+
>>> test_folder = [folder for folder in file_dir if folder["name"] == "test"].pop()
233+
>>> test_dir = proj.export_file_repository(folder_id=test_folder["folder_id"])
234+
>>> test_file = [file for file in test_dir if file["name"] == "test_in_folder.txt"].pop()
235+
>>> proj.delete_file_from_repository(doc_id=test_file["doc_id"])
236+
[{}]
237+
"""
238+
# pylint: enable=line-too-long
239+
payload = self._initialize_payload(
240+
content="fileRepository", return_format_type=return_format_type
241+
)
242+
# there's no format field in this call
243+
payload["action"] = "delete"
244+
payload["doc_id"] = doc_id
245+
246+
return cast(
247+
EmptyJson, self._call_api(payload=payload, return_type="empty_json")
248+
)

redcap/methods/files.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
"""REDCap API methods for Project files"""
22

3-
from typing import TYPE_CHECKING, Any, Dict, Optional, Union, cast
3+
from typing import Any, Dict, IO, Optional, Union, cast
44

55
from redcap.methods.base import Base, FileMap
66
from redcap.request import EmptyJson, FileUpload
77

8-
if TYPE_CHECKING:
9-
from io import TextIOWrapper
10-
118

129
class Files(Base):
1310
"""Responsible for all API methods under 'Files' in the API Playground"""
@@ -90,7 +87,7 @@ def import_file(
9087
record: str,
9188
field: str,
9289
file_name: str,
93-
file_object: "TextIOWrapper",
90+
file_object: IO,
9491
event: Optional[str] = None,
9592
repeat_instance: Optional[Union[int, str]] = None,
9693
) -> EmptyJson:

redcap/methods/logging.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""REDCap API methods for Project field names"""
1+
"""REDCap API methods for Project logs"""
22

33
from datetime import datetime
44
from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Union, cast

0 commit comments

Comments
 (0)