Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -158,6 +158,9 @@ print(response)

For predefined licenses, pass `licenseAbbreviation=License.<VALUE>` and leave `licenseUrl` and `license` unset. For custom licenses, pass a custom string to `license` and optionally include `licenseUrl` and `licenseAbbreviation`.

> [!TIP]
> To also attach an optional sample of your dataset, pass `sample_file_path="/path/to/dataset-sample.tar.gz"` to `create_submission_with_upload`, or upload it separately with `upload_sample_file(file_path=..., submission_id=...)`.

> [!TIP]
> To upload a new `.tar.gz` version to an already approved dataset, call `upload_dataset_file(file_path=..., submission_id=...)` directly. Find the submission under **Profile → Uploads**, open the approved dataset, and copy the value after `/profile/submissions/` in the URL. Note that this value is the submission ID, which is different from the public dataset ID.

Expand Down
3 changes: 3 additions & 0 deletions docs/demo_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@
response = create_submission_with_upload(
file_path="example_dataset.tar.gz",
submission=submission,
enable_logging=True,
# Optional: a small, representative excerpt of the dataset
# sample_file_path="example_dataset_sample.tar.gz",
)

print(response)
3 changes: 3 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ print(response)

For predefined licenses, pass `licenseAbbreviation=License.<VALUE>` and leave `licenseUrl` and `license` unset. For a custom license, pass a custom string to `license` and optionally include `licenseUrl` and `licenseAbbreviation`.

> [!TIP]
> To also attach an optional sample of your dataset, pass `sample_file_path="/path/to/dataset-sample.tar.gz"` to `create_submission_with_upload`, or upload it separately with `upload_sample_file(file_path=..., submission_id=...)`.

> [!TIP]
> To upload a new `.tar.gz` version to an already approved and published dataset, call `upload_dataset_file(file_path=..., submission_id=...)` directly. Get the submission ID from **Profile → Uploads** by opening the approved dataset and copying the value after `/profile/submissions/` in the URL. This submission ID is different from the dataset ID.

Expand Down
81 changes: 79 additions & 2 deletions docs/upload.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ The SDK provides a complete workflow for uploading datasets:
1. **Create a draft submission** - Initialize a new dataset submission
2. **Update submission metadata** - Add required metadata fields to the submission
3. **Upload the dataset file** - Upload your archive using resumable multipart uploads
4. **Submit for review** - Finalize the submission for review
4. **Upload a sample file** (optional) - Upload a small, representative excerpt of the dataset
5. **Submit for review** - Finalize the submission for review

Additionally, the functionality of uploading a dataset file can be used independently to upload a new archive version to an already approved and published dataset submission. Check the [Upload a New File Version to an Approved Dataset](#upload-a-new-file-version-to-an-approved-dataset) section for more details.

Expand Down Expand Up @@ -98,6 +99,18 @@ print(response)

For predefined licenses, pass `licenseAbbreviation=License.<VALUE>` and leave `licenseUrl` and `license` unset. For a custom license, pass a custom string to `license` and optionally include `licenseUrl` and `licenseAbbreviation`.

To also attach an **optional** sample file, pass `sample_file_path`:

```python
response = create_submission_with_upload(
file_path="/path/to/dataset.tar.gz",
submission=submission,
sample_file_path="/path/to/dataset-sample.tar.gz",
)
```

See [Uploading a Sample File](#uploading-a-sample-file) for details.

### Visibility

`visibility` controls who can access the dataset and must be one of the `Visibility` enum values:
Expand All @@ -108,6 +121,56 @@ For predefined licenses, pass `licenseAbbreviation=License.<VALUE>` and leave `l
| `Visibility.PRIVATE` | Everyone | Your organization & Approved requesters only |
| `Visibility.RESTRICTED` | Your organization | Your organization (via SDK) |

## Uploading a Sample File

A **sample file** is a small, representative excerpt of your dataset that reviewers and
potential users can inspect without downloading the full archive. It is **optional**: a
submission can be submitted for review without one, and uploading a sample never replaces
the dataset file itself.

Sample files are uploaded exactly like the dataset archive — resumable multipart upload of
a `.tar.gz` archive (`application/gzip`) — through the submission's sample endpoints.

Either pass `sample_file_path` to `create_submission_with_upload`:

```python
from datacollective import create_submission_with_upload

response = create_submission_with_upload(
file_path="/path/to/dataset.tar.gz",
submission=submission,
sample_file_path="/path/to/dataset-sample.tar.gz",
# Optional, defaults to `<filename>.mdc-sample-upload.json` next to the sample
sample_state_path="/custom/path/sample-upload-state.json",
)

print(response["submission"]["sampleFileReferenceId"])
```

Or upload it on its own with `upload_sample_file`, using the submission ID (this works for
draft submissions as well as already approved ones):

```python
from datacollective import upload_sample_file

upload_state = upload_sample_file(
file_path="/path/to/dataset-sample.tar.gz",
submission_id=submission_id,
)

print(f"Sample upload complete! File Upload ID: {upload_state.fileUploadId}")
```

`upload_sample_file` accepts the same arguments as `upload_dataset_file`
(`state_path`, `show_progress`, `enable_logging`, `part_size`) and is equally resumable.
Its state file uses a `.mdc-sample-upload.json` suffix, so a sample upload and a dataset
upload never overwrite each other's resume state.

> [!NOTE]
> When using `create_submission_with_upload`, the dataset archive is uploaded first and the
> sample file right after, before the submission is sent for review. A missing
> `sample_file_path` raises `FileNotFoundError` up front, before anything is uploaded.

## Upload a New File Version to an Approved Dataset

Use `upload_dataset_file` when the dataset already exists on the platform and is already in the **Published / Approved** state.
Expand Down Expand Up @@ -239,7 +302,20 @@ print(f"Upload complete! File Upload ID: {upload_state.fileUploadId}")
> [!TIP]
> You can also find your submission ID by going to your [Uploads](https://mozilladatacollective.com/profile/uploads) in your profile, click on the dataset submission of your choice, and the URL will contain the submission ID (e.g., `https://mozilladatacollective.com/submissions/cmmjpewijXXXXXXXXX`).

### Step 4: Submit for Review
### Step 4 (Optional): Upload a Sample File

```python
from datacollective import upload_sample_file

sample_state = upload_sample_file(
file_path="/path/to/your/dataset-sample.tar.gz",
submission_id=submission_id,
)

print(f"Sample upload complete! File Upload ID: {sample_state.fileUploadId}")
```

### Step 5: Submit for Review

```python
from datacollective import DatasetSubmission, submit_submission
Expand Down Expand Up @@ -373,4 +449,5 @@ For detailed API documentation, see the [API Reference](api.md) section.
- [`create_submission_draft`](api.md) - Create a draft submission
- [`update_submission`](api.md) - Update submission metadata
- [`upload_dataset_file`](api.md) - Upload a file to a submission
- [`upload_sample_file`](api.md) - Upload an optional sample file to a submission
- [`submit_submission`](api.md) - Submit a draft for review
3 changes: 2 additions & 1 deletion src/datacollective/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
submit_submission,
update_submission,
)
from datacollective.upload import upload_dataset_file
from datacollective.upload import upload_dataset_file, upload_sample_file

__all__ = [
"download_dataset",
Expand All @@ -35,6 +35,7 @@
"submit_submission",
"create_submission_with_upload",
"upload_dataset_file",
"upload_sample_file",
"DatasetDetails",
"DatasetSubmission",
"License",
Expand Down
4 changes: 4 additions & 0 deletions src/datacollective/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,10 @@ class DatasetSubmission(NonEmptyStrModel, Dataset):
None,
description="Identifier for the associated file upload, if any. Generated by the API when a file is uploaded.",
)
sampleFileReferenceId: str | None = Field(
None,
description="Identifier for the associated sample file, if any. Generated by the API when a sample file is uploaded.",
)
exclusivityOptOutAt: str | None = Field(
None, description="Timestamp when exclusivity opt-out was set, if applicable."
)
Expand Down
22 changes: 21 additions & 1 deletion src/datacollective/submissions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from pathlib import Path
from typing import Any

from datacollective.api_utils import _get_api_url, _send_api_request
Expand All @@ -17,7 +18,7 @@
_validate_final_submission_fields,
SUBMIT_FIELDS,
)
from datacollective.upload import upload_dataset_file
from datacollective.upload import upload_dataset_file, upload_sample_file
from datacollective.upload_utils import _resolve_upload_state, DEFAULT_PART_SIZE

logger = get_logger(__name__)
Expand Down Expand Up @@ -104,6 +105,8 @@ def create_submission_with_upload(
state_path: str | None = None,
enable_logging: bool = False,
part_size: int = DEFAULT_PART_SIZE,
sample_file_path: str | None = None,
sample_state_path: str | None = None,
) -> dict[str, Any]:
"""
Single point function to create a submission, update metadata, upload a file, and submit for review.
Expand All @@ -116,13 +119,20 @@ def create_submission_with_upload(
enable_logging: Whether to enable detailed logging during the process.
part_size: Multipart part size in bytes. Ignored when resuming an existing upload,
which keeps the part size recorded in its state file.
sample_file_path: Optional path to a sample archive to upload alongside the
dataset archive. A sample file is not required to submit a dataset.
sample_state_path: Optional path to persist the sample upload state.
"""
_enable_logging(enable_logging)

submission = _ensure_submission_model(submission)

_validate_final_submission_fields(submission, require_file_upload_id=False)

# Fail fast on a missing sample file, before uploading the dataset archive
if sample_file_path and not Path(sample_file_path).exists():
raise FileNotFoundError(f"Sample file not found: `{sample_file_path}`")

state_file, existing_upload_state = _resolve_upload_state(file_path, state_path)

if existing_upload_state:
Expand Down Expand Up @@ -158,6 +168,16 @@ def create_submission_with_upload(
part_size=part_size,
)

if sample_file_path:
logger.info("Uploading sample file...")
upload_sample_file(
file_path=sample_file_path,
submission_id=submission_id,
state_path=sample_state_path,
enable_logging=enable_logging,
part_size=part_size,
)

# The uploaded file is linked to the submission automatically when the
# multipart upload completes (the upload was started with `submissionId`),
# so `fileUploadId` is not sent on the metadata PATCH. We still record it on
Expand Down
86 changes: 83 additions & 3 deletions src/datacollective/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,76 @@ def upload_dataset_file(
part_size: Multipart part size in bytes. Ignored when resuming an
existing upload, which keeps the part size recorded in its state file.
"""
return _upload_file(
file_path=file_path,
submission_id=submission_id,
state_path=state_path,
show_progress=show_progress,
enable_logging=enable_logging,
part_size=part_size,
is_sample=False,
)


def upload_sample_file(
file_path: str,
submission_id: str,
state_path: str | None = None,
show_progress: bool = True,
enable_logging: bool = False,
part_size: int = DEFAULT_PART_SIZE,
) -> UploadState:
"""
Upload an **optional** sample file for a dataset submission.

A sample file is a small, representative excerpt of the dataset that
users can inspect without downloading the full archive. It is uploaded
exactly like the dataset archive (resumable multipart upload,
`application/gzip` MIME type) but through the submission's sample endpoints,
and it does not replace the dataset file.

Args:
file_path: Path to the sample archive on disk.
submission_id: Dataset submission ID (not the dataset ID).
state_path: Optional path to persist upload state. Defaults to
`<filename>.mdc-sample-upload.json` alongside the archive.
enable_logging: Whether to enable detailed logging during the upload.
show_progress: Whether to show a progress bar during upload.
part_size: Multipart part size in bytes. Ignored when resuming an
existing upload, which keeps the part size recorded in its state file.
"""
return _upload_file(
file_path=file_path,
submission_id=submission_id,
state_path=state_path,
show_progress=show_progress,
enable_logging=enable_logging,
part_size=part_size,
is_sample=True,
)


def _upload_file(
file_path: str,
submission_id: str,
state_path: str | None,
show_progress: bool,
enable_logging: bool,
part_size: int,
is_sample: bool,
) -> UploadState:
"""
Shared multipart upload function for the dataset archive and the sample file.

Args:
file_path: Path to the archive on disk.
submission_id: Dataset submission ID (not the dataset ID).
state_path: Optional path to persist upload state.
show_progress: Whether to show a progress bar during upload.
enable_logging: Whether to enable detailed logging during the upload.
part_size: Multipart part size in bytes.
is_sample: Whether to upload the file as the submission's sample file.
"""
path = Path(file_path)
_enable_logging(enable_logging)

Expand All @@ -65,14 +135,17 @@ def upload_dataset_file(

final_filename = path.name

state_file = Path(state_path) if state_path else _default_state_path(path)
state_file = (
Path(state_path) if state_path else _default_state_path(path, is_sample)
)

state = _load_or_create_state(
state_file=state_file,
submission_id=submission_id,
final_filename=final_filename,
file_size=file_size,
part_size=part_size,
is_sample=is_sample,
)

expected_parts = _expected_parts(state.fileSize, state.partSize)
Expand All @@ -83,7 +156,7 @@ def upload_dataset_file(
f"Resuming: {len(parts_by_number)}/{expected_parts} parts already uploaded."
)

logger.info(f"Uploading file: {final_filename}")
logger.info(f"Uploading: {final_filename}")

progress_bar = _init_progress_bar(
show_progress=show_progress,
Expand Down Expand Up @@ -122,7 +195,14 @@ def upload_dataset_file(

logger.info("Completing upload...")

_complete_upload(state.fileUploadId, state.uploadId, state.parts, state.checksum)
_complete_upload(
state.fileUploadId,
state.uploadId,
state.parts,
state.checksum,
state.submissionId,
state.isSample,
)

logger.info(f"Upload complete. File upload ID: {state.fileUploadId}")

Expand Down
Loading
Loading