Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
db7e87d
:pencil2: Fix docstring typo
pwildenhain Feb 10, 2025
fdb196a
:construction: Add method for creating folders
pwildenhain Mar 5, 2025
4c76b6e
:alien: Update redcapdemo site
pwildenhain Mar 6, 2025
ca31555
:building_construction: Connect FileRepository to Project
pwildenhain Mar 6, 2025
57844a2
:bug: Fix create folder tests
pwildenhain Mar 6, 2025
9ba13c4
:alien: Modify test to reflect API change
pwildenhain Mar 7, 2025
c67ff9a
:recycle: Refactor untouched code
pwildenhain Mar 11, 2025
7c5c4eb
:bath: Lint
pwildenhain Mar 11, 2025
5903d63
:white_check_mark: Add unit tests for create folder
pwildenhain Mar 11, 2025
ca2f153
:sparkles: Add export_file_directory
pwildenhain Mar 18, 2025
2646be6
:white_check_mark: Add integration/doctests for new method
pwildenhain Mar 18, 2025
e11846f
:name_badge: Rename method
pwildenhain Mar 18, 2025
6ce4ec3
:sparkles: Add import method for file repo
pwildenhain Mar 18, 2025
ab9df3e
:white_check_mark: Add integration test for file repo import
pwildenhain Mar 19, 2025
8aba7d4
:white_check_mark: Add unit tests for export list/import repo
pwildenhain Mar 19, 2025
b6970a5
:gear: Configure files for tests
pwildenhain Mar 20, 2025
3c79a4c
:white_check_marK: Adjust typing
pwildenhain Mar 20, 2025
7adbec6
:sparkles: Add export file method to file repo api
pwildenhain Mar 20, 2025
0d952eb
:bath: Clean up imports and returns
pwildenhain Apr 15, 2025
fed34dc
:sparkles: Add delete file from repo method
pwildenhain Apr 15, 2025
158d904
:memo: Add File Repository to docs
pwildenhain Apr 15, 2025
cdcafb1
:bug:
pwildenhain Apr 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Currently, these API calls are available:
* Instruments
* Instrument-event mapping
* File
* File Repository
* Logging
* Metadata
* Project Info
Expand All @@ -68,6 +69,7 @@ Currently, these API calls are available:
* Data Access Groups
* Events
* File
* File Repository
* Instrument-event mapping
* Metadata
* Records
Expand All @@ -83,6 +85,7 @@ Currently, these API calls are available:
* Data Access Groups
* Events
* File
* File Repository
* Records
* Users
* User Roles
Expand Down
5 changes: 5 additions & 0 deletions docs/api_reference/file_repository.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# File Repository

::: redcap.methods.file_repository
selection:
inherited_members: true
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions redcap/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,26 @@

from redcap.project import Project
from tests.integration.conftest import (
add_files_to_repository,
create_project,
grant_superuser_rights,
redcapdemo_url,
SUPER_TOKEN,
)


@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_project = add_files_to_repository(doctest_project)

doctest_namespace["proj"] = doctest_project
doctest_namespace["TOKEN"] = doctest_token
1 change: 1 addition & 0 deletions redcap/methods/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion redcap/methods/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -339,6 +339,7 @@ def _return_data(
"dag",
"event",
"exportFieldNames",
"fileRepository",
"formEventMapping",
"instrument",
"log",
Expand Down
248 changes: 248 additions & 0 deletions redcap/methods/file_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
"""REDCap API methods for Project file repository"""

from typing import Any, Dict, IO, Literal, Optional, Union, cast

from redcap.methods.base import Base, FileMap, Json
from redcap.request import EmptyJson, FileUpload


class FileRepository(Base):
"""Responsible for all API methods under 'File Repository' in the API Playground"""

def create_folder_in_repository(
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: Literal["json", "csv", "xml"] = "json",
):
"""
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_in_repository(name="New Folder")
[{'folder_id': ...}]
"""
payload: Dict[str, Any] = self._initialize_payload(
content="fileRepository",
format_type=format_type,
return_format_type=return_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

return_type = self._lookup_return_type(format_type, request_type="export")

return cast(Union[Json, str], self._call_api(payload, return_type))

def export_file_repository(
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_repository()
[{'folder_id': ..., 'name': 'New 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")

return cast(Union[Json, str], self._call_api(payload, return_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,
file_object: IO,
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
),
)

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")
)
7 changes: 2 additions & 5 deletions redcap/methods/files.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
"""REDCap API methods for Project files"""

from typing import TYPE_CHECKING, Any, Dict, 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

if TYPE_CHECKING:
from io import TextIOWrapper


class Files(Base):
"""Responsible for all API methods under 'Files' in the API Playground"""
Expand Down Expand Up @@ -90,7 +87,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:
Expand Down
2 changes: 1 addition & 1 deletion redcap/methods/logging.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 2 additions & 1 deletion redcap/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -50,7 +51,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']
Expand Down
Loading