From fd981c8ff6ddecc54f3237ca8017d7f8eb04386e Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Tue, 4 Nov 2025 16:45:14 +0200 Subject: [PATCH 01/25] Ignore pycharm files --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 23d12dc..68b9b51 100644 --- a/.gitignore +++ b/.gitignore @@ -175,7 +175,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ # Abstra # Abstra is an AI-powered process automation framework. From 1e9b9cc23afa51ae7b32cddaa0af37b8112e401f Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Tue, 4 Nov 2025 16:46:30 +0200 Subject: [PATCH 02/25] Deprecate old code --- src/datacollective/client.py | 352 ---------------------------------- src/datacollective/dataset.py | 105 ---------- 2 files changed, 457 deletions(-) delete mode 100644 src/datacollective/client.py delete mode 100644 src/datacollective/dataset.py diff --git a/src/datacollective/client.py b/src/datacollective/client.py deleted file mode 100644 index 28554c4..0000000 --- a/src/datacollective/client.py +++ /dev/null @@ -1,352 +0,0 @@ -import os -import shutil -import sys -import tarfile -import time -from pathlib import Path -from typing import Any, Optional, cast - -import requests -from dotenv import load_dotenv - -from .dataset import Dataset - - -class ProgressBar: - """A custom progress bar with a fox emoji that moves across the bar""" - - def __init__(self, total_size: int, bar_length: int = 50): - self.total_size = total_size - self.downloaded = 0 - self.bar_length = bar_length - self.start_time = time.time() - self.last_update_time = 0.0 - self.update_interval = 0.1 # Update every 100ms max - - def update(self, chunk_size: int) -> None: - """Update the progress bar with new downloaded data""" - self.downloaded += chunk_size - - # Only update display if enough time has passed - current_time = time.time() - if current_time - self.last_update_time >= self.update_interval: - self._display() - self.last_update_time = current_time - - def _display(self) -> None: - """Display the current progress bar""" - if self.total_size <= 0: - # If we don't know the total size, show a spinning fox - spinner = ["๐ŸฆŠ", "๐ŸฆŠ", "๐ŸฆŠ", "๐ŸฆŠ"] - spin_char = spinner[int(time.time() * 2) % len(spinner)] - sys.stdout.write( - f"\r{spin_char} Downloading... {self._format_bytes(self.downloaded)}" - ) - sys.stdout.flush() - return - - # Calculate percentage and bar position - percentage = min(100.0, (self.downloaded / self.total_size) * 100) - filled_length = int(self.bar_length * self.downloaded // self.total_size) - - # Create the progress bar - always show fox, even at 0% - bar = "โ–ˆ" * filled_length + "โ–‘" * (self.bar_length - filled_length) - - # Position the fox emoji - always visible at position 0 or current progress - if filled_length == 0: - # Fox at the start when no progress yet - bar = "๐ŸฆŠ" + bar[1:] - else: - # Fox at the leading edge of progress - fox_position = min(filled_length, self.bar_length - 1) - bar = bar[:fox_position] + "๐ŸฆŠ" + bar[fox_position + 1 :] - - # Calculate speed and ETA - elapsed_time = time.time() - self.start_time - if elapsed_time > 0 and self.downloaded > 0: - speed = self.downloaded / elapsed_time - eta = (self.total_size - self.downloaded) / speed if speed > 0 else 0 - speed_str = f"{self._format_bytes(speed)}/s" - eta_str = f"ETA: {self._format_time(eta)}" - else: - speed_str = "0 B/s" - eta_str = "ETA: --:--" - - # Display the progress bar - sys.stdout.write( - f"\r{bar} {percentage:.1f}% " - f"({self._format_bytes(self.downloaded)}/{self._format_bytes(self.total_size)}) " - f"{speed_str} {eta_str}" - ) - sys.stdout.flush() - - def finish(self) -> None: - """Complete the progress bar and move to next line""" - if self.total_size > 0: - # Show completed bar with fox at the end - bar = "โ–ˆ" * (self.bar_length - 1) + "๐ŸฆŠ" - elapsed_time = time.time() - self.start_time - avg_speed = self.downloaded / elapsed_time if elapsed_time > 0 else 0 - sys.stdout.write( - f"\r{bar} 100.0% " - f"({self._format_bytes(self.downloaded)}/{self._format_bytes(self.total_size)}) " - f"Average: {self._format_bytes(avg_speed)}/s " - f"Total time: {self._format_time(elapsed_time)}\n" - ) - else: - elapsed_time = time.time() - self.start_time - avg_speed = self.downloaded / elapsed_time if elapsed_time > 0 else 0 - sys.stdout.write( - f"\n๐ŸฆŠ Download complete! {self._format_bytes(self.downloaded)} " - f"in {self._format_time(elapsed_time)} " - f"(avg: {self._format_bytes(avg_speed)}/s)\n" - ) - sys.stdout.flush() - - @staticmethod - def _format_bytes(bytes_val: float) -> str: - """Format bytes into human readable format""" - for unit in ["B", "KB", "MB", "GB", "TB"]: - if bytes_val < 1024.0: - return f"{bytes_val:.1f} {unit}" - bytes_val /= 1024.0 - return f"{bytes_val:.1f} PB" - - @staticmethod - def _format_time(seconds: float) -> str: - """Format seconds into MM:SS format""" - if seconds < 0: - return "--:--" - mins, secs = divmod(int(seconds), 60) - return f"{mins:02d}:{secs:02d}" - - -class DataCollective: - - def __init__( - self, - api_key: Optional[str] = None, - environment: str = "production", - download_path: Optional[str] = None, - **kwargs: Any, - ) -> None: - """ - Initialize the DataCollective client object - """ - - env = environment or os.getenv("ENVIRONMENT", "development") - env_file = f".env.{env}" if env != "production" else ".env" - - if os.path.exists(env_file): - load_dotenv( - dotenv_path=env_file - ) # load in environmental specific .env file - else: - load_dotenv() # load in default .env file - - # set up API URL - self.api_url = ( - os.getenv("MDC_API_URL") - or "https://datacollective.mozillafoundation.org/api" - ) - if not self.api_url.endswith("/"): - self.api_url += "/" # add trailing slash if it isn't already included - - # set up API Key - self.api_key = api_key or os.getenv("MDC_API_KEY") - - if not self.api_key: - raise ValueError( - "API key missing. Please provide one when creating this object with the api_key parameter or provide it in your .env file as MDC_API_KEY" - ) - - # set up download path - download_path_env = download_path or os.getenv( - "MDC_DOWNLOAD_PATH", "~/.mozdata/datasets" - ) - # Expand user path (handle ~) - self.download_path = os.path.expanduser(download_path_env) # type: ignore - - def _ensure_download_directory(self, download_path: str) -> None: - """ - Ensure the download directory exists and is writable. - Raises an error if the directory cannot be created or is not writable. - """ - try: - # Create the directory if it doesn't exist - Path(download_path).mkdir(parents=True, exist_ok=True) - - # Check if the directory is writable - if not os.access(download_path, os.W_OK): - raise PermissionError(f"Directory {download_path} is not writable") - - except PermissionError as e: - raise PermissionError( - f"Cannot create or write to directory {download_path}: {e}" - ) from e - except Exception as e: - raise OSError(f"Failed to create directory {download_path}: {e}") from e - - def get_dataset( - self, - dataset: str, - download_path: Optional[str] = None, - show_progress: bool = True, - ) -> Optional[str]: - """ - Download a dataset from the DataCollective API. - - Args: - dataset (str): The name/ID of the dataset to download - download_path (str, optional): Override the default download path for this download - show_progress (bool): Whether to show the progress bar (default: True) - - Returns: - str: The full path to the downloaded file, or None if download failed - """ - - # Determine the download path for this download - if download_path is not None: - # Expand user path (handle ~) - final_download_path = os.path.expanduser(download_path) - else: - final_download_path = self.download_path # type: ignore - - # Ensure the download directory exists and is writable - self._ensure_download_directory(final_download_path) - - # create a download session - download_session_url = self.api_url + "datasets/" + dataset + "/download" - headers = {"Authorization": "Bearer " + self.api_key} # type: ignore - - print(f"Requesting dataset: {dataset}") - try: - r = requests.post(download_session_url, headers=headers) - r.raise_for_status() - # parse response once - response_data = r.json() - except requests.exceptions.HTTPError as e: - if e.response.status_code == 429: # rate limit exceeded - print("Rate limit exceeded") - return None - print(f"HTTP Error: {e}") - return None - except requests.exceptions.RequestException as e: - print(f"Request Error: {e}") - return None - - if "error" in response_data: - response_error = response_data["error"] - if response_error == "Rate limit exceeded": - print("Rate limit exceeded") - return None - else: - print(f"API Error: {response_error}") - return None - - if "downloadUrl" not in response_data or "filename" not in response_data: - print(f"Unexpected response format: {response_data}") - - dataset_file_url = response_data["downloadUrl"] - dataset_filename = response_data["filename"] - - # download dataset file - try: - headers = {"Authorization": "Bearer " + self.api_key} # type: ignore - r = requests.get(dataset_file_url, stream=True, headers=headers) - r.raise_for_status() - except requests.exceptions.HTTPError as e: - print(f"HTTP Error Downloading File: {e}") - return None - except requests.exceptions.RequestException as e: - print(f"Request Error Downloading File: {e}") - return None - - # Create the full file path - full_file_path = os.path.join(final_download_path, dataset_filename) - - # Get the total file size for the progress bar - total_size = int(r.headers.get("content-length", 0)) - - if show_progress: - print(f"Downloading dataset: {dataset_filename}") - progress_bar = ProgressBar(total_size) - # Show initial progress bar with fox at the start - progress_bar._display() - else: - print(f"Downloading dataset: {dataset_filename}") - - # Download with progress tracking - with open(full_file_path, "wb") as f: - for chunk in r.iter_content(chunk_size=65536): # Increased chunk size - if chunk: - f.write(chunk) - if show_progress: - progress_bar.update(len(chunk)) - - if show_progress: - progress_bar.finish() - - print(f"Dataset downloaded to: {full_file_path}") - return full_file_path - - def load_dataset(self, dataset: str) -> Dataset: - - filepath = self.get_dataset(dataset) - if not filepath: - raise Exception("Downloading dataset failed") - - extract_path = self._extract_dataset(filepath) - return Dataset(extract_path) - - def _extract_dataset(self, filepath: str) -> str: - - archive_suffix = ".tar.gz" - if filepath.endswith(archive_suffix): - extract_path = filepath[: -len(archive_suffix)] - else: - raise Exception( - f"Downloaded archive {filepath} does not end with {archive_suffix}" - ) - - if os.path.exists(extract_path): - print(f"Deleting old extract {extract_path}") - shutil.rmtree(extract_path) - - print(f"Extracting {filepath} to {extract_path}") - with tarfile.open(filepath, "r:gz") as tar: - tar.extractall(path=extract_path) - print(f"Extracted {filepath} to {extract_path}") - return extract_path - - def get_dataset_details(self, dataset_id: str) -> dict[str, Any]: - """ - Retrieve details of a specific dataset. - - Args: - dataset_id: The dataset ID (as shown in MDC platform). - - Returns: - A dict with dataset details as returned by the API. - - Raises: - ValueError: If dataset_id is empty. - FileNotFoundError: If the dataset does not exist (404). - PermissionError: If access is denied (403). - requests.HTTPError: For other non-2xx responses. - """ - if not dataset_id or not dataset_id.strip(): - raise ValueError("dataset_id is required") - - dataset_details_url = self.api_url + "datasets/" + dataset_id - headers = {"Authorization": "Bearer " + self.api_key} # type: ignore - - resp = requests.get(dataset_details_url, headers=headers) - if resp.status_code == 404: - raise FileNotFoundError("Dataset not found") - if resp.status_code == 403: - raise PermissionError( - "Access denied. Private dataset requires organization membership" - ) - resp.raise_for_status() - return cast(dict[str, Any], resp.json()) diff --git a/src/datacollective/dataset.py b/src/datacollective/dataset.py deleted file mode 100644 index 10a4b98..0000000 --- a/src/datacollective/dataset.py +++ /dev/null @@ -1,105 +0,0 @@ -import os - -import pandas as pd - -SCRIPTED_SPEECH_SPLITS = [ - "dev", - "train", - "test", - "validated", - "invalidated", - "reported", - "other", -] - - -class Dataset: - """ - Represents a dataset. Should be the jumping off point to access its data, metadata, anything that comes from it. - A dataset is backed by a directory, that contains all of its data. - """ - - def __init__(self, directory: str): - self.directory = directory - self.corpus_filepath = None - - @property - def splits(self) -> list[str]: - """ - A list of splits available for the dataset - """ - return [str(x) for x in self._data["split"].dropna().unique().tolist()] - - @property - def _data(self) -> pd.DataFrame: - """ - A single opinion of how a dataset's data should be presented - A table of all splits in a dataset, can be differentiated via the split column - """ - - if "/mcv-scripted-" in self.directory: - return self._get_scripted_speech_data() - elif "/mcv-spontaneous-" in self.directory: - return self._get_spontaneous_speech_data() - else: - raise Exception( - f"Dataset directory {self.directory} cannot be identified as MCV scripted or spontaneous" - ) - - def _get_scripted_speech_data(self) -> pd.DataFrame: - """ - A crude method of getting all of the data for a scripted speech dataset - Transforms it into the canonical representation of several splits of data - In the future, we will aim for a more robust solution - """ - split_files: dict[str, str] = {} - for root, _, files in os.walk(self.directory): - for file in files: - if not file.endswith(".tsv"): - continue - - # Store the corpus directory for reference - self.corpus_filepath = root # type: ignore - full_path = os.path.join(root, file) - data_file_name = file[:-4] - if data_file_name not in SCRIPTED_SPEECH_SPLITS: - continue - - split_files[data_file_name] = full_path - - dfs = [] - for split, file in split_files.items(): - df = pd.read_csv(file, sep="\t", header="infer") - df["split"] = split - dfs.append(df) - return pd.concat(dfs, ignore_index=True) - - def _get_spontaneous_speech_data(self) -> pd.DataFrame: - """ - A crude method of getting all of the data for a spontaneous speech dataset - Transforms it into the canonical representation of several splits of data - In the future, we will aim for a more robust solution - """ - - for root, _, files in os.walk(self.directory): - for file in files: - if not file.startswith("ss-corpus-"): - continue - - if not file.endswith(".tsv"): - continue - - # Store the corpus directory for reference - self.corpus_filepath = root # type: ignore - full_path = os.path.join(root, file) - return pd.read_csv(full_path, sep="\t", header="infer") - - raise Exception("Could nof find dataset file in directory") - - # This may look redundant today, but this is intentionally designed to present an API which is agnostic to its own insides. - # The inside might be anything, you call this to know you've got pandas - def to_pandas(self) -> pd.DataFrame: - """ - Provides the dataset in a pandas format. - """ - return self._data From d37d855079b93e11aa7314db44bc2d5b9c340d5e Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Tue, 4 Nov 2025 16:46:47 +0200 Subject: [PATCH 03/25] Add progress bar to its own file --- src/datacollective/progress_bar.py | 111 +++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 src/datacollective/progress_bar.py diff --git a/src/datacollective/progress_bar.py b/src/datacollective/progress_bar.py new file mode 100644 index 0000000..1a52516 --- /dev/null +++ b/src/datacollective/progress_bar.py @@ -0,0 +1,111 @@ +import sys +import time + +class ProgressBar: + """A custom progress bar with a fox emoji that moves across the bar""" + + def __init__(self, total_size: int, bar_length: int = 50): + self.total_size = total_size + self.downloaded = 0 + self.bar_length = bar_length + self.start_time = time.time() + self.last_update_time = 0.0 + self.update_interval = 0.1 # Update every 100ms max + + def update(self, chunk_size: int) -> None: + """Update the progress bar with new downloaded data""" + self.downloaded += chunk_size + + # Only update display if enough time has passed + current_time = time.time() + if current_time - self.last_update_time >= self.update_interval: + self._display() + self.last_update_time = current_time + + def _display(self) -> None: + """Display the current progress bar""" + if self.total_size <= 0: + # If we don't know the total size, show a spinning fox + spinner = ["๐ŸฆŠ", "๐ŸฆŠ", "๐ŸฆŠ", "๐ŸฆŠ"] + spin_char = spinner[int(time.time() * 2) % len(spinner)] + sys.stdout.write( + f"\r{spin_char} Downloading... {self._format_bytes(self.downloaded)}" + ) + sys.stdout.flush() + return + + # Calculate percentage and bar position + percentage = min(100.0, (self.downloaded / self.total_size) * 100) + filled_length = int(self.bar_length * self.downloaded // self.total_size) + + # Create the progress bar - always show fox, even at 0% + bar = "โ–ˆ" * filled_length + "โ–‘" * (self.bar_length - filled_length) + + # Position the fox emoji - always visible at position 0 or current progress + if filled_length == 0: + # Fox at the start when no progress yet + bar = "๐ŸฆŠ" + bar[1:] + else: + # Fox at the leading edge of progress + fox_position = min(filled_length, self.bar_length - 1) + bar = bar[:fox_position] + "๐ŸฆŠ" + bar[fox_position + 1 :] + + # Calculate speed and ETA + elapsed_time = time.time() - self.start_time + if elapsed_time > 0 and self.downloaded > 0: + speed = self.downloaded / elapsed_time + eta = (self.total_size - self.downloaded) / speed if speed > 0 else 0 + speed_str = f"{self._format_bytes(speed)}/s" + eta_str = f"ETA: {self._format_time(eta)}" + else: + speed_str = "0 B/s" + eta_str = "ETA: --:--" + + # Display the progress bar + sys.stdout.write( + f"\r{bar} {percentage:.1f}% " + f"({self._format_bytes(self.downloaded)}/{self._format_bytes(self.total_size)}) " + f"{speed_str} {eta_str}" + ) + sys.stdout.flush() + + def finish(self) -> None: + """Complete the progress bar and move to next line""" + if self.total_size > 0: + # Show completed bar with fox at the end + bar = "โ–ˆ" * (self.bar_length - 1) + "๐ŸฆŠ" + elapsed_time = time.time() - self.start_time + avg_speed = self.downloaded / elapsed_time if elapsed_time > 0 else 0 + sys.stdout.write( + f"\r{bar} 100.0% " + f"({self._format_bytes(self.downloaded)}/{self._format_bytes(self.total_size)}) " + f"Average: {self._format_bytes(avg_speed)}/s " + f"Total time: {self._format_time(elapsed_time)}\n" + ) + else: + elapsed_time = time.time() - self.start_time + avg_speed = self.downloaded / elapsed_time if elapsed_time > 0 else 0 + sys.stdout.write( + f"\n๐ŸฆŠ Download complete! {self._format_bytes(self.downloaded)} " + f"in {self._format_time(elapsed_time)} " + f"(avg: {self._format_bytes(avg_speed)}/s)\n" + ) + sys.stdout.flush() + + @staticmethod + def _format_bytes(bytes_val: float) -> str: + """Format bytes into human readable format""" + for unit in ["B", "KB", "MB", "GB", "TB"]: + if bytes_val < 1024.0: + return f"{bytes_val:.1f} {unit}" + bytes_val /= 1024.0 + return f"{bytes_val:.1f} PB" + + @staticmethod + def _format_time(seconds: float) -> str: + """Format seconds into MM:SS format""" + if seconds < 0: + return "--:--" + mins, secs = divmod(int(seconds), 60) + return f"{mins:02d}:{secs:02d}" + From 14fb6591dc8bb57fd8644a3102c5d5e2f5cc5e78 Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Tue, 4 Nov 2025 16:47:23 +0200 Subject: [PATCH 04/25] Add init implementation of SDK functions --- src/datacollective/datasets.py | 249 +++++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 src/datacollective/datasets.py diff --git a/src/datacollective/datasets.py b/src/datacollective/datasets.py new file mode 100644 index 0000000..4ea7d14 --- /dev/null +++ b/src/datacollective/datasets.py @@ -0,0 +1,249 @@ +import os +import sys +import tarfile +import zipfile +from pathlib import Path +from typing import Any +import pandas as pd +import requests + +from datacollective.api import _get_api_url, _auth_headers, HTTP_TIMEOUT, ENV_DOWNLOAD_PATH +from datacollective.common_voice import _load_scripted, _load_spontaneous + + +def get_dataset_details(dataset_id: str) -> dict[str, Any]: + """ + Return dataset details from the MDC API as a dictionary. + Args: + dataset_id: The dataset ID (as shown in MDC platform). + Returns: + A dict with dataset details as returned by the API. + Raises: + ValueError: If dataset_id is empty. + FileNotFoundError: If the dataset does not exist (404). + PermissionError: If access is denied (403). + RuntimeError: If rate limit is exceeded (429). + requests.HTTPError: For other non-2xx responses. + """ + if not dataset_id or not dataset_id.strip(): + raise ValueError("`dataset_id` must be a non-empty string") + + url = f"{_get_api_url()}/datasets/{dataset_id}" + resp = requests.get(url, headers=_auth_headers(), timeout=HTTP_TIMEOUT) + + if resp.status_code == 404: + raise FileNotFoundError("Dataset not found") + if resp.status_code == 403: + raise PermissionError( + "Access denied. Private dataset requires organization membership" + ) + if resp.status_code == 429: + raise RuntimeError("Rate limit exceeded") + + resp.raise_for_status() + return resp.json() + + +def save_dataset_to_disk( + dataset_id: str, + download_directory: str, + show_progress: bool = True, + overwrite_existing: bool = False, +) -> Path: + """ + Download the dataset archive to a local directory and return the archive path. + Skips download if the target file already exists (unless `overwrite_existing=True`). + Args: + dataset_id: The dataset ID (as shown in MDC platform). + download_directory: Directory where to save the downloaded dataset. + If None or empty, falls back to env MDC_DOWNLOAD_PATH or default. + show_progress: Whether to show a progress bar during download. + overwrite_existing: Whether to overwrite existing files. + Returns: + Path to the downloaded dataset archive. + Raises: + ValueError: If dataset_id is empty. + FileNotFoundError: If the dataset does not exist (404). + PermissionError: If access is denied (403) or download directory is not writable. + RuntimeError: If rate limit is exceeded (429) or unexpected response format. + requests.HTTPError: For other non-2xx responses. + """ + if not dataset_id or not dataset_id.strip(): + raise ValueError("`dataset_id` must be a non-empty string") + + base_dir = _resolve_download_dir(download_directory) + + # Create a download session to get `downloadUrl` and `filename` + session_url = f"{_get_api_url()}/datasets/{dataset_id}/download" + sess_resp = requests.post( + session_url, headers=_auth_headers(), timeout=HTTP_TIMEOUT + ) + if sess_resp.status_code == 404: + raise FileNotFoundError("Dataset not found") + if sess_resp.status_code == 403: + raise PermissionError( + "Access denied. Private dataset requires organization membership" + ) + if sess_resp.status_code == 429: + raise RuntimeError("Rate limit exceeded") + sess_resp.raise_for_status() + + payload = sess_resp.json() + download_url = payload.get("downloadUrl") + filename = payload.get("filename") + if not download_url or not filename: + raise RuntimeError(f"Unexpected response format: {payload}") + + target_path = base_dir / filename + if target_path.exists() and not overwrite_existing: + print(f"File already exists. Skipping download: `{str(target_path)}`") + return target_path + + # Stream download to a temporary file for atomicity + tmp_path = target_path.with_suffix(target_path.suffix + ".part") + + with requests.get( + download_url, headers=_auth_headers(), stream=True, timeout=HTTP_TIMEOUT + ) as r: + if r.status_code == 429: + raise RuntimeError("Rate limit exceeded") + r.raise_for_status() + total = int(r.headers.get("content-length", "0")) or None + bytes_read = 0 + + with open(tmp_path, "wb") as f: + for chunk in r.iter_content(chunk_size=1 << 16): + if not chunk: + continue + f.write(chunk) + bytes_read += len(chunk) + if show_progress: + _print_progress(bytes_read, total) + + if show_progress: + sys.stdout.write("\n") + + tmp_path.replace(target_path) + print(f"Saved dataset to `{str(target_path)}`") + return target_path + + +def load_dataset( + dataset_id: str, download_directory: str, show_progress: bool = True +) -> pd.DataFrame: + """ + Download (if needed), extract, and load the dataset into a pandas DataFrame. + Uses dataset `details['name']` to decide scripted vs spontaneous Common Voice parsing. + Args: + dataset_id: The dataset ID (as shown in MDC platform). + download_directory: Directory where to save the downloaded dataset. + If None or empty, falls back to env MDC_DOWNLOAD_PATH or default. + show_progress: Whether to show a progress bar during download. + Returns: + A pandas DataFrame with the loaded dataset. + Raises: + ValueError: If dataset_id is empty. + FileNotFoundError: If the dataset does not exist (404). + PermissionError: If access is denied (403) or download directory is not writable. + RuntimeError: If rate limit is exceeded (429) or unexpected response format. + requests.HTTPError: For other non-2xx responses. + """ + archive_path = save_dataset_to_disk( + dataset_id=dataset_id, + download_directory=download_directory, + show_progress=show_progress, + overwrite_existing=False, + ) + base_dir = _resolve_download_dir(download_directory) + extract_dir = _extract_archive(archive_path, base_dir) + + details = get_dataset_details(dataset_id) + dataset_name = str(details.get("name", "")).lower() + + # TODO: we need a better to support multiple dataset types in the future + if "scripted" in dataset_name: + return _load_scripted(extract_dir) + if "spontaneous" in dataset_name: + return _load_spontaneous(extract_dir) + + # Fallback: infer by file layout + try: + return _load_scripted(extract_dir) + except Exception: + return _load_spontaneous(extract_dir) + + + +def _resolve_download_dir(download_directory: str | None) -> Path: + """ + Resolve and ensure the download directory exists and is writable. + + Args: + download_directory (str | None): User-specified download directory. + If None or empty, falls back to env MDC_DOWNLOAD_PATH or default. + + Returns: + The resolved Path object for the download directory. + """ + if download_directory and download_directory.strip(): + base = download_directory + else: + base = os.getenv(ENV_DOWNLOAD_PATH, "~/.mozdata/datasets") + p = Path(os.path.expanduser(base)) + p.mkdir(parents=True, exist_ok=True) + if not os.access(p, os.W_OK): + raise PermissionError(f"Directory `{str(p)}` is not writable") + return p + + +def _strip_archive_suffix(path: Path) -> Path: + """ + Strip known archive suffixes from the filename. + Args: + path: Path to the archive file. + Returns: + Path with the archive suffix removed. + """ + name = path.name + if name.endswith(".tar.gz"): + return path.with_name(name[: -len(".tar.gz")]) + if name.endswith(".tgz"): + return path.with_name(name[: -len(".tgz")]) + if name.endswith(".zip"): + return path.with_name(name[: -len(".zip")]) + # Unknown; drop one suffix if present + return path.with_suffix("") + + +def _extract_archive(archive_path: Path, dest_dir: Path) -> Path: + """ + Extract the given archive (.tar.gz, .tgz, .zip) into `dest_dir`. + Args: + archive_path: Path to the archive file. + dest_dir: Directory where to extract the contents. + Returns: + Path to the extracted root directory. + Raises: + ValueError: If the archive type is unsupported. + """ + extract_root = _strip_archive_suffix(archive_path) + # Extract into a dedicated directory under `dest_dir` using stripped name + target = dest_dir / extract_root.name + if target.exists(): + # Keep it simple and ensure fresh state + import shutil + + shutil.rmtree(target) + target.mkdir(parents=True, exist_ok=True) + + if archive_path.suffix == ".zip": + with zipfile.ZipFile(archive_path, "r") as zf: + zf.extractall(target) + elif archive_path.name.endswith(".tar.gz") or archive_path.suffix == ".tgz": + with tarfile.open(archive_path, "r:gz") as tf: + tf.extractall(target) + else: + raise ValueError( + f"Unsupported archive type for `{archive_path.name}`. Expected .tar.gz, .tgz, or .zip." + ) + return target \ No newline at end of file From 8a633db93b98dc0c93f98699253df969c6fe4974 Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Tue, 4 Nov 2025 17:15:37 +0200 Subject: [PATCH 05/25] Update python version --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index baf9892..a99bcc0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ repos: rev: 23.12.1 hooks: - id: black - language_version: python3.9 + language_version: python3.12 - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.1.8 From 3e7f31b6a7e098965bf6df4e8bac93b53eb912d6 Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Tue, 4 Nov 2025 17:16:11 +0200 Subject: [PATCH 06/25] Add API utils --- src/datacollective/api_utils.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/datacollective/api_utils.py diff --git a/src/datacollective/api_utils.py b/src/datacollective/api_utils.py new file mode 100644 index 0000000..8ac1cff --- /dev/null +++ b/src/datacollective/api_utils.py @@ -0,0 +1,26 @@ +import os + +DEFAULT_API_URL = "https://datacollective.mozillafoundation.org/api" +ENV_API_KEY = "MDC_API_KEY" +ENV_API_URL = "MDC_API_URL" +ENV_DOWNLOAD_PATH = "MDC_DOWNLOAD_PATH" +HTTP_TIMEOUT = (10, 60) # (connect, read) + +RATE_LIMIT_ERROR = "Rate limit exceeded. Please try again later." + + +def _get_api_url() -> str: + return os.getenv(ENV_API_URL, DEFAULT_API_URL).rstrip("/") + + +def _get_api_key() -> str: + key = os.getenv(ENV_API_KEY) + if not key: + raise ValueError( + f"Missing API key. Set env {ENV_API_KEY} to your MDC API token." + ) + return key + + +def _auth_headers() -> dict[str, str]: + return {"Authorization": f"Bearer {_get_api_key()}"} From 6562093e872ee4e07e41b29e2137912cd1d7993b Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Tue, 4 Nov 2025 17:20:29 +0200 Subject: [PATCH 07/25] Use the fox progress bar --- src/datacollective/common_voice.py | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/datacollective/common_voice.py diff --git a/src/datacollective/common_voice.py b/src/datacollective/common_voice.py new file mode 100644 index 0000000..17262d1 --- /dev/null +++ b/src/datacollective/common_voice.py @@ -0,0 +1,41 @@ +from pathlib import Path + +import pandas as pd + +SCRIPTED_SPEECH_SPLITS = [ + "dev", + "train", + "test", + "validated", + "invalidated", + "reported", + "other", +] + +def _load_scripted(root_dir: Path) -> pd.DataFrame: + split_files: dict[str, Path] = {} + for path in root_dir.rglob("*.tsv"): + split_name = path.stem + if split_name in SCRIPTED_SPEECH_SPLITS: + split_files[split_name] = path + + if not split_files: + raise RuntimeError( + f"No scripted split files found under `{str(root_dir)}`" + ) + + frames = [] + for split, file_path in sorted(split_files.items()): + df = pd.read_csv(file_path, sep="\t", header="infer") + df["split"] = split + frames.append(df) + return pd.concat(frames, ignore_index=True) + + +def _load_spontaneous(root_dir: Path) -> pd.DataFrame: + for path in root_dir.rglob("*.tsv"): + if path.name.startswith("ss-corpus-"): + return pd.read_csv(path, sep="\t", header="infer") + raise RuntimeError( + f"No spontaneous corpus file (`ss-corpus-*.tsv`) found under `{str(root_dir)}`" + ) From a251bb30af9d453776dcf28d0fb1457a5c35fde1 Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Tue, 4 Nov 2025 17:21:54 +0200 Subject: [PATCH 08/25] Revert python version --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a99bcc0..baf9892 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ repos: rev: 23.12.1 hooks: - id: black - language_version: python3.12 + language_version: python3.9 - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.1.8 From f233eec7a9424c82b94da36c88b91713bca99208 Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Tue, 4 Nov 2025 17:25:17 +0200 Subject: [PATCH 09/25] Use the fox progress bar --- src/datacollective/datasets.py | 44 ++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/src/datacollective/datasets.py b/src/datacollective/datasets.py index 4ea7d14..5877175 100644 --- a/src/datacollective/datasets.py +++ b/src/datacollective/datasets.py @@ -1,14 +1,23 @@ +from __future__ import annotations + import os -import sys import tarfile import zipfile from pathlib import Path from typing import Any + import pandas as pd import requests -from datacollective.api import _get_api_url, _auth_headers, HTTP_TIMEOUT, ENV_DOWNLOAD_PATH +from datacollective.api_utils import ( + ENV_DOWNLOAD_PATH, + HTTP_TIMEOUT, + RATE_LIMIT_ERROR, + _auth_headers, + _get_api_url, +) from datacollective.common_voice import _load_scripted, _load_spontaneous +from datacollective.progress_bar import ProgressBar def get_dataset_details(dataset_id: str) -> dict[str, Any]: @@ -38,10 +47,10 @@ def get_dataset_details(dataset_id: str) -> dict[str, Any]: "Access denied. Private dataset requires organization membership" ) if resp.status_code == 429: - raise RuntimeError("Rate limit exceeded") + raise RuntimeError(RATE_LIMIT_ERROR) resp.raise_for_status() - return resp.json() + return resp.json() # type: ignore def save_dataset_to_disk( @@ -85,7 +94,7 @@ def save_dataset_to_disk( "Access denied. Private dataset requires organization membership" ) if sess_resp.status_code == 429: - raise RuntimeError("Rate limit exceeded") + raise RuntimeError(RATE_LIMIT_ERROR) sess_resp.raise_for_status() payload = sess_resp.json() @@ -97,7 +106,7 @@ def save_dataset_to_disk( target_path = base_dir / filename if target_path.exists() and not overwrite_existing: print(f"File already exists. Skipping download: `{str(target_path)}`") - return target_path + return Path(target_path) # Stream download to a temporary file for atomicity tmp_path = target_path.with_suffix(target_path.suffix + ".part") @@ -106,26 +115,32 @@ def save_dataset_to_disk( download_url, headers=_auth_headers(), stream=True, timeout=HTTP_TIMEOUT ) as r: if r.status_code == 429: - raise RuntimeError("Rate limit exceeded") + raise RuntimeError(RATE_LIMIT_ERROR) r.raise_for_status() - total = int(r.headers.get("content-length", "0")) or None - bytes_read = 0 + total = int(r.headers.get("content-length", "0")) + + if show_progress: + print(f"Downloading dataset: {filename}") + progress_bar = ProgressBar(total) + # Show initial progress bar with fox at the start + progress_bar._display() + else: + print(f"Downloading dataset: {filename}") with open(tmp_path, "wb") as f: for chunk in r.iter_content(chunk_size=1 << 16): if not chunk: continue f.write(chunk) - bytes_read += len(chunk) if show_progress: - _print_progress(bytes_read, total) + progress_bar.update(len(chunk)) if show_progress: - sys.stdout.write("\n") + progress_bar.finish() tmp_path.replace(target_path) print(f"Saved dataset to `{str(target_path)}`") - return target_path + return Path(target_path) def load_dataset( @@ -173,7 +188,6 @@ def load_dataset( return _load_spontaneous(extract_dir) - def _resolve_download_dir(download_directory: str | None) -> Path: """ Resolve and ensure the download directory exists and is writable. @@ -246,4 +260,4 @@ def _extract_archive(archive_path: Path, dest_dir: Path) -> Path: raise ValueError( f"Unsupported archive type for `{archive_path.name}`. Expected .tar.gz, .tgz, or .zip." ) - return target \ No newline at end of file + return target From e5b819d7bdc9da2685b39d181121839f8cd45e33 Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Tue, 4 Nov 2025 18:11:49 +0200 Subject: [PATCH 10/25] Common api_request function --- src/datacollective/api_utils.py | 41 ++++++++++++++++++++++++++++++ src/datacollective/datasets.py | 44 ++++++++------------------------- 2 files changed, 51 insertions(+), 34 deletions(-) diff --git a/src/datacollective/api_utils.py b/src/datacollective/api_utils.py index 8ac1cff..47a91a7 100644 --- a/src/datacollective/api_utils.py +++ b/src/datacollective/api_utils.py @@ -1,4 +1,9 @@ +from __future__ import annotations + import os +from typing import Any + +import requests DEFAULT_API_URL = "https://datacollective.mozillafoundation.org/api" ENV_API_KEY = "MDC_API_KEY" @@ -9,6 +14,42 @@ RATE_LIMIT_ERROR = "Rate limit exceeded. Please try again later." +def api_request( + method: str, + url: str, + *, + headers: dict[str, str] | None = None, + timeout: tuple[int, int] | None = None, + raise_known_errors: bool = True, + **kwargs: Any, +) -> requests.Response: + """ + Send an HTTP request with default MDC auth headers and timeout, and + normalize common error codes (403/404/429) to exceptions. + """ + merged_headers = {**_auth_headers(), **(headers or {})} + resp = requests.request( + method=method.upper(), + url=url, + headers=merged_headers, + timeout=HTTP_TIMEOUT if timeout is None else timeout, + **kwargs, + ) + + if raise_known_errors: + if resp.status_code == 404: + raise FileNotFoundError("Dataset not found") + if resp.status_code == 403: + raise PermissionError( + "Access denied. Private dataset requires organization membership" + ) + if resp.status_code == 429: + raise RuntimeError(RATE_LIMIT_ERROR) + resp.raise_for_status() + + return resp + + def _get_api_url() -> str: return os.getenv(ENV_API_URL, DEFAULT_API_URL).rstrip("/") diff --git a/src/datacollective/datasets.py b/src/datacollective/datasets.py index 5877175..1ae601c 100644 --- a/src/datacollective/datasets.py +++ b/src/datacollective/datasets.py @@ -7,14 +7,12 @@ from typing import Any import pandas as pd -import requests from datacollective.api_utils import ( ENV_DOWNLOAD_PATH, HTTP_TIMEOUT, - RATE_LIMIT_ERROR, - _auth_headers, _get_api_url, + api_request, ) from datacollective.common_voice import _load_scripted, _load_spontaneous from datacollective.progress_bar import ProgressBar @@ -38,19 +36,8 @@ def get_dataset_details(dataset_id: str) -> dict[str, Any]: raise ValueError("`dataset_id` must be a non-empty string") url = f"{_get_api_url()}/datasets/{dataset_id}" - resp = requests.get(url, headers=_auth_headers(), timeout=HTTP_TIMEOUT) - - if resp.status_code == 404: - raise FileNotFoundError("Dataset not found") - if resp.status_code == 403: - raise PermissionError( - "Access denied. Private dataset requires organization membership" - ) - if resp.status_code == 429: - raise RuntimeError(RATE_LIMIT_ERROR) - - resp.raise_for_status() - return resp.json() # type: ignore + resp = api_request("GET", url) + return dict(resp.json()) def save_dataset_to_disk( @@ -84,20 +71,9 @@ def save_dataset_to_disk( # Create a download session to get `downloadUrl` and `filename` session_url = f"{_get_api_url()}/datasets/{dataset_id}/download" - sess_resp = requests.post( - session_url, headers=_auth_headers(), timeout=HTTP_TIMEOUT - ) - if sess_resp.status_code == 404: - raise FileNotFoundError("Dataset not found") - if sess_resp.status_code == 403: - raise PermissionError( - "Access denied. Private dataset requires organization membership" - ) - if sess_resp.status_code == 429: - raise RuntimeError(RATE_LIMIT_ERROR) - sess_resp.raise_for_status() + resp = api_request("POST", session_url) + payload: dict[str, Any] = dict(resp.json()) - payload = sess_resp.json() download_url = payload.get("downloadUrl") filename = payload.get("filename") if not download_url or not filename: @@ -111,12 +87,12 @@ def save_dataset_to_disk( # Stream download to a temporary file for atomicity tmp_path = target_path.with_suffix(target_path.suffix + ".part") - with requests.get( - download_url, headers=_auth_headers(), stream=True, timeout=HTTP_TIMEOUT + with api_request( + "GET", + download_url, + stream=True, + timeout=HTTP_TIMEOUT, ) as r: - if r.status_code == 429: - raise RuntimeError(RATE_LIMIT_ERROR) - r.raise_for_status() total = int(r.headers.get("content-length", "0")) if show_progress: From 538a57beed1f2d211d29862eb8b46bc6cb979288 Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Tue, 4 Nov 2025 18:20:33 +0200 Subject: [PATCH 11/25] Add dataset-specific loading logic --- .../dataset_loading_scripts/registry.py | 20 +++++++++++++++++++ src/datacollective/datasets.py | 18 +++++------------ 2 files changed, 25 insertions(+), 13 deletions(-) create mode 100644 src/datacollective/dataset_loading_scripts/registry.py diff --git a/src/datacollective/dataset_loading_scripts/registry.py b/src/datacollective/dataset_loading_scripts/registry.py new file mode 100644 index 0000000..7cab8fa --- /dev/null +++ b/src/datacollective/dataset_loading_scripts/registry.py @@ -0,0 +1,20 @@ +from pathlib import Path + +import pandas as pd +from datacollective.dataset_loading_scripts.common_voice import ( + _load_scripted, + _load_spontaneous, +) + + +def load_dataset_from_name_as_dataframe( + dataset_name: str, extract_dir: Path +) -> pd.DataFrame: + if "scripted" in dataset_name: + return _load_scripted(extract_dir) + if "spontaneous" in dataset_name: + return _load_spontaneous(extract_dir) + + raise ValueError( + f"Dataset name `{dataset_name}` currently not supported for loading as DataFrame." + ) diff --git a/src/datacollective/datasets.py b/src/datacollective/datasets.py index 1ae601c..6256798 100644 --- a/src/datacollective/datasets.py +++ b/src/datacollective/datasets.py @@ -14,7 +14,9 @@ _get_api_url, api_request, ) -from datacollective.common_voice import _load_scripted, _load_spontaneous +from datacollective.dataset_loading_scripts.registry import ( + load_dataset_from_name_as_dataframe, +) from datacollective.progress_bar import ProgressBar @@ -124,7 +126,7 @@ def load_dataset( ) -> pd.DataFrame: """ Download (if needed), extract, and load the dataset into a pandas DataFrame. - Uses dataset `details['name']` to decide scripted vs spontaneous Common Voice parsing. + Uses dataset `details['name']` to check in registry.py for dataset-specific loading logic. Args: dataset_id: The dataset ID (as shown in MDC platform). download_directory: Directory where to save the downloaded dataset. @@ -151,17 +153,7 @@ def load_dataset( details = get_dataset_details(dataset_id) dataset_name = str(details.get("name", "")).lower() - # TODO: we need a better to support multiple dataset types in the future - if "scripted" in dataset_name: - return _load_scripted(extract_dir) - if "spontaneous" in dataset_name: - return _load_spontaneous(extract_dir) - - # Fallback: infer by file layout - try: - return _load_scripted(extract_dir) - except Exception: - return _load_spontaneous(extract_dir) + return load_dataset_from_name_as_dataframe(dataset_name, extract_dir) def _resolve_download_dir(download_directory: str | None) -> Path: From 3ae527aa2292cfaecd275f91ca9269c9ef850a37 Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Tue, 4 Nov 2025 18:56:49 +0200 Subject: [PATCH 12/25] Add dataset-specific loading logic --- .../dataset_loading_scripts/README.md | 3 + .../common_voice.py | 5 +- tests/conftest.py | 37 --- tests/test_client.py | 258 ------------------ tests/test_dataset.py | 89 ------ 5 files changed, 5 insertions(+), 387 deletions(-) create mode 100644 src/datacollective/dataset_loading_scripts/README.md rename src/datacollective/{ => dataset_loading_scripts}/common_voice.py (90%) delete mode 100644 tests/conftest.py delete mode 100644 tests/test_client.py delete mode 100644 tests/test_dataset.py diff --git a/src/datacollective/dataset_loading_scripts/README.md b/src/datacollective/dataset_loading_scripts/README.md new file mode 100644 index 0000000..a8dacad --- /dev/null +++ b/src/datacollective/dataset_loading_scripts/README.md @@ -0,0 +1,3 @@ +`load_dataset()` requires a certain dataset-specific logic in order to parse the data correctly from the downloaded files into a Pandas DataFrame. + +This directory contains dataset loading scripts for different datasets hosted on Mozilla Data Collective to enable the `load_dataset()` functionality. \ No newline at end of file diff --git a/src/datacollective/common_voice.py b/src/datacollective/dataset_loading_scripts/common_voice.py similarity index 90% rename from src/datacollective/common_voice.py rename to src/datacollective/dataset_loading_scripts/common_voice.py index 17262d1..86bad93 100644 --- a/src/datacollective/common_voice.py +++ b/src/datacollective/dataset_loading_scripts/common_voice.py @@ -12,6 +12,7 @@ "other", ] + def _load_scripted(root_dir: Path) -> pd.DataFrame: split_files: dict[str, Path] = {} for path in root_dir.rglob("*.tsv"): @@ -20,9 +21,7 @@ def _load_scripted(root_dir: Path) -> pd.DataFrame: split_files[split_name] = path if not split_files: - raise RuntimeError( - f"No scripted split files found under `{str(root_dir)}`" - ) + raise RuntimeError(f"No scripted split files found under `{str(root_dir)}`") frames = [] for split, file_path in sorted(split_files.items()): diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 8ae4dd1..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,37 +0,0 @@ -import os - -import pytest - - -@pytest.fixture(autouse=True) -def clean_environment(): - """Automatically clean environment variables before each test.""" - # Store original environment - original_env = os.environ.copy() - - # Clear MDC-related variables - for key in ["MDC_API_KEY", "MDC_API_URL", "ENVIRONMENT"]: - os.environ.pop(key, None) - - yield - - # Restore original environment - os.environ.clear() - os.environ.update(original_env) - - -@pytest.fixture -def mock_env_file(tmp_path): - """Create a temporary directory with mock .env files.""" - # Create temporary .env files - env_file = tmp_path / ".env" - env_file.write_text( - "MDC_API_KEY=prod-test-key\nMDC_API_URL=https://prod.test.url\n" - ) - - dev_env_file = tmp_path / ".env.development" - dev_env_file.write_text( - "MDC_API_KEY=dev-test-key\nMDC_API_URL=https://dev.test.url\n" - ) - - return tmp_path diff --git a/tests/test_client.py b/tests/test_client.py deleted file mode 100644 index 7525d32..0000000 --- a/tests/test_client.py +++ /dev/null @@ -1,258 +0,0 @@ -import os -import tempfile -from unittest.mock import patch - -import pytest -import requests - -from datacollective import DataCollective - - -class TestDataCollective: - """Test suite for DataCollective client.""" - - def test_init_with_api_key_parameter(self): - """Test initialization with API key passed as parameter.""" - client = DataCollective(api_key="test-api-key-123") - assert client.api_key == "test-api-key-123" - assert client.api_url == "https://datacollective.mozillafoundation.org/api/" - - def test_init_with_env_variable(self): - """Test initialization with API key from environment variable.""" - with patch.dict(os.environ, {"MDC_API_KEY": "env-api-key-456"}): - client = DataCollective() - assert client.api_key == "env-api-key-456" - - def test_init_missing_api_key_raises_error(self): - """Test that missing API key raises ValueError.""" - with patch.dict(os.environ, {}, clear=True): - with patch( - "datacollective.client.load_dotenv" - ): # Mock to prevent loading from file - with pytest.raises(ValueError) as exc_info: - DataCollective() - assert "API key missing" in str(exc_info.value) - - def test_custom_api_url_from_env(self): - """Test custom API URL from environment variable.""" - with patch.dict( - os.environ, - {"MDC_API_KEY": "test-key", "MDC_API_URL": "https://custom.api.url"}, - ): - client = DataCollective() - assert client.api_url == "https://custom.api.url/" - - def test_default_api_url_when_env_not_set(self): - """Test default API URL is used when env variable not set.""" - with patch.dict(os.environ, {"MDC_API_KEY": "test-key"}): - # Ensure MDC_API_URL is not set - os.environ.pop("MDC_API_URL", None) - client = DataCollective() - assert client.api_url == "https://datacollective.mozillafoundation.org/api/" - - def test_environment_parameter_loads_correct_env_file(self): - """Test that different environment parameter loads correct .env file.""" - # Create temporary .env files - with tempfile.TemporaryDirectory() as tmpdir: - # Create .env.development file - dev_env_file = os.path.join(tmpdir, ".env.development") - with open(dev_env_file, "w") as f: - f.write("MDC_API_KEY=dev-key-789\n") - f.write("MDC_API_URL=https://dev.api.url\n") - - # Change to temp directory and create client - original_cwd = os.getcwd() - try: - os.chdir(tmpdir) - client = DataCollective(environment="development") - assert client.api_key == "dev-key-789" - assert client.api_url == "https://dev.api.url/" - finally: - os.chdir(original_cwd) - - def test_production_environment_loads_default_env(self): - """Test that production environment loads default .env file.""" - with tempfile.TemporaryDirectory() as tmpdir: - # Create default .env file - env_file = os.path.join(tmpdir, ".env") - with open(env_file, "w") as f: - f.write("MDC_API_KEY=prod-key-999\n") - - original_cwd = os.getcwd() - try: - os.chdir(tmpdir) - client = DataCollective(environment="production") - assert client.api_key == "prod-key-999" - finally: - os.chdir(original_cwd) - - def test_parameter_overrides_env_variable(self): - """Test that parameter takes precedence over environment variable.""" - with patch.dict(os.environ, {"MDC_API_KEY": "env-key"}): - client = DataCollective(api_key="param-key") - assert client.api_key == "param-key" - - @patch("datacollective.client.requests.post") - def test_get_dataset_handles_http_error(self, mock_post): - """Test that get_dataset handles HTTP errors properly.""" - # Mock a 403 Forbidden response - mock_response = mock_post.return_value - mock_response.status_code = 403 - - # Create a proper HTTPError with response - http_error = requests.exceptions.HTTPError("403 Client Error: Forbidden") - http_error.response = mock_response - mock_response.raise_for_status.side_effect = http_error - - client = DataCollective(api_key="test-key") - result = client.get_dataset("test-dataset") - - assert result is None - mock_post.assert_called_once() - - -class TestDataCollectiveWithMocking: - """Tests using mocking for isolation.""" - - @patch("datacollective.client.load_dotenv") - def test_load_dotenv_called_for_development(self, mock_load_dotenv): - """Test that load_dotenv is called with correct path for development.""" - with patch.dict(os.environ, {"MDC_API_KEY": "test-key"}): - with patch("os.path.exists", return_value=True): - DataCollective(environment="development") - mock_load_dotenv.assert_called_once_with(dotenv_path=".env.development") - - @patch("datacollective.client.load_dotenv") - def test_load_dotenv_fallback_when_env_file_missing(self, mock_load_dotenv): - """Test that load_dotenv falls back to default when env file doesn't exist.""" - with patch.dict(os.environ, {"MDC_API_KEY": "test-key"}): - with patch("os.path.exists", return_value=False): - DataCollective(environment="staging") - mock_load_dotenv.assert_called_once_with() - - -# Fixtures for shared test data -@pytest.fixture -def api_key(): - """Fixture providing a test API key.""" - return "test-api-key-fixture" - - -@pytest.fixture -def client(api_key): - """Fixture providing a DataCollective client.""" - return DataCollective(api_key=api_key) - - -class TestDataCollectiveWithFixtures: - """Tests using fixtures for common setup.""" - - def test_client_fixture_has_api_key(self, client, api_key): - """Test that fixture-provided client has correct API key.""" - assert client.api_key == api_key - - def test_client_fixture_has_default_url(self, client): - """Test that fixture-provided client has default URL.""" - assert client.api_url == "https://datacollective.mozillafoundation.org/api/" - - -class TestGetDatasetDetails: - """Tests for get_dataset_details.""" - - @patch("datacollective.client.requests.get") - def test_get_dataset_details_success(self, mock_get, api_key): - mock_resp = mock_get.return_value - mock_resp.status_code = 200 - mock_resp.json.return_value = {"id": "abc123", "name": "Example Dataset"} - - client = DataCollective(api_key=api_key) - result = client.get_dataset_details("abc123") - - assert result == {"id": "abc123", "name": "Example Dataset"} - - # Verify URL and Authorization header - called_url = mock_get.call_args[0][0] - called_headers = mock_get.call_args[1]["headers"] - assert called_url == client.api_url + "datasets/abc123" - assert called_headers["Authorization"] == f"Bearer {api_key}" - - @pytest.mark.parametrize("bad_id", ["", " ", " "]) - @patch("datacollective.client.requests.get") - def test_get_dataset_details_empty_id_raises(self, mock_get, bad_id): - client = DataCollective(api_key="test-key") - with pytest.raises(ValueError, match="dataset_id is required"): - client.get_dataset_details(bad_id) - mock_get.assert_not_called() - - @patch("datacollective.client.requests.get") - def test_get_dataset_details_404_raises_file_not_found(self, mock_get): - mock_resp = mock_get.return_value - mock_resp.status_code = 404 - - client = DataCollective(api_key="test-key") - with pytest.raises(FileNotFoundError, match="Dataset not found"): - client.get_dataset_details("missing-dataset") - - @patch("datacollective.client.requests.get") - def test_get_dataset_details_403_raises_permission_error(self, mock_get): - mock_resp = mock_get.return_value - mock_resp.status_code = 403 - - client = DataCollective(api_key="test-key") - with pytest.raises( - PermissionError, - match=r"Access denied\. Private dataset requires organization membership", - ): - client.get_dataset_details("private-dataset") - - @patch("datacollective.client.requests.get") - def test_get_dataset_details_other_http_error_propagates(self, mock_get): - mock_resp = mock_get.return_value - mock_resp.status_code = 500 - http_err = requests.exceptions.HTTPError( - "500 Server Error: Internal Server Error" - ) - mock_resp.raise_for_status.side_effect = http_err - - client = DataCollective(api_key="test-key") - with pytest.raises(requests.exceptions.HTTPError): - client.get_dataset_details("abc123") - - -def test_get_dataset_details_live_roundtrip(): - from dotenv import load_dotenv - - load_dotenv() - api_key = os.getenv("MDC_API_KEY") - dataset_id = ( - "cmflnuzw414x7bnapn6iycjnv" # Common Voice Scripted Speech 23.0 - Bengali - ) - - client = DataCollective(api_key=api_key) - details = client.get_dataset_details(dataset_id) - - assert isinstance(details, dict) - assert details.get("id") == dataset_id - assert isinstance(details.get("slug"), str) and details["slug"] - assert isinstance(details.get("name"), str) and details["name"] - assert isinstance(details.get("locale"), str) and details["locale"] - visibility = details.get("visibility") - if visibility is not None: - assert isinstance(visibility, str) - assert visibility in ("public", "private", "restricted") - assert isinstance(details.get("sizeBytes"), str) - assert isinstance(details.get("createdAt"), str) and details["createdAt"].endswith( - "Z" - ) - updated_at = details.get("updatedAt") - if updated_at is not None: - assert isinstance(updated_at, str) - assert updated_at.endswith("Z") - org = details.get("organization") - assert isinstance(org, dict) - assert isinstance(org.get("name"), str) and org["name"] - assert isinstance(org.get("slug"), str) and org["slug"] - expected_dataset_url = ( - client.api_url.replace("/api/", "/") + "datasets/" + dataset_id - ) - assert details.get("datasetUrl") == expected_dataset_url diff --git a/tests/test_dataset.py b/tests/test_dataset.py deleted file mode 100644 index e33222b..0000000 --- a/tests/test_dataset.py +++ /dev/null @@ -1,89 +0,0 @@ -import pandas as pd -import pytest - -from datacollective.dataset import SCRIPTED_SPEECH_SPLITS, Dataset - - -@pytest.fixture -def scripted_dataset_dir(tmp_path): - """Create a fake MCV scripted dataset directory with valid .tsv split files.""" - base_dir = tmp_path / "mcv-scripted-en" - base_dir.mkdir() - for split in ["train", "test", "validated"]: - df = pd.DataFrame({"text": [f"{split}_1", f"{split}_2"], "speaker": [1, 2]}) - file_path = base_dir / f"{split}.tsv" - df.to_csv(file_path, sep="\t", index=False) - return base_dir - - -@pytest.fixture -def spontaneous_dataset_dir(tmp_path): - """Create a fake MCV spontaneous dataset directory with one ss-corpus file.""" - base_dir = tmp_path / "mcv-spontaneous-en" - base_dir.mkdir() - df = pd.DataFrame({"utterance": ["hello", "world"], "speaker": [1, 2]}) - (base_dir / "ss-corpus-data.tsv").write_text(df.to_csv(sep="\t", index=False)) - return base_dir - - -def test_scripted_dataset_loads_correctly(scripted_dataset_dir): - ds = Dataset(str(scripted_dataset_dir)) - df = ds.to_pandas() - - # Should contain concatenated data from all splits - assert set(df["split"].unique()) == {"train", "test", "validated"} - assert all(col in df.columns for col in ["text", "speaker", "split"]) - assert len(df) == 6 # 3 splits ร— 2 rows each - - -def test_scripted_splits_property(scripted_dataset_dir): - ds = Dataset(str(scripted_dataset_dir)) - splits = ds.splits - assert sorted(splits) == ["test", "train", "validated"] - - -def test_spontaneous_dataset_loads_correctly(spontaneous_dataset_dir): - ds = Dataset(str(spontaneous_dataset_dir)) - df = ds.to_pandas() - - assert set(df.columns) == {"utterance", "speaker"} - assert len(df) == 2 - assert df.iloc[0]["utterance"] == "hello" - - -def test_spontaneous_dataset_missing_file_raises(tmp_path): - base_dir = tmp_path / "mcv-spontaneous-en" - base_dir.mkdir() - - ds = Dataset(str(base_dir)) - with pytest.raises(Exception, match="Could nof find dataset file in directory"): - ds.to_pandas() - - -def test_invalid_dataset_dir_raises(tmp_path): - base_dir = tmp_path / "some-random-dataset" - base_dir.mkdir() - ds = Dataset(str(base_dir)) - - with pytest.raises( - Exception, match="cannot be identified as MCV scripted or spontaneous" - ): - ds.to_pandas() - - -def test_get_scripted_speech_splits_filters_only_valid_names(tmp_path): - base_dir = tmp_path / "mcv-scripted-en" - base_dir.mkdir() - # valid and invalid split names - valid_file = base_dir / "train.tsv" - invalid_file = base_dir / "random.tsv" - - pd.DataFrame({"x": [1]}).to_csv(valid_file, sep="\t", index=False) - pd.DataFrame({"x": [1]}).to_csv(invalid_file, sep="\t", index=False) - - ds = Dataset(str(base_dir)) - df = ds._get_scripted_speech_data() - - assert "split" in df.columns - assert all(df["split"].isin(SCRIPTED_SPEECH_SPLITS)) - assert "random" not in df["split"].unique() From 7e77d7e4f9d6b85052f2b8a78b251a1053168a7b Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Tue, 18 Nov 2025 17:01:11 +0200 Subject: [PATCH 13/25] Add mkdocs --- .github/workflows/docs.yml | 47 ++++ docs/api.md | 9 + docs/index.md | 203 ++++++++++++++++++ docs/mdc_ccc.png | Bin 0 -> 52732 bytes docs/mdc_logo.png | Bin 0 -> 116587 bytes docs/mdc_logo_white.png | Bin 0 -> 119483 bytes mkdocs.yml | 48 +++++ pyproject.toml | 7 + src/datacollective/__init__.py | 8 +- .../dataset_loading_scripts/__init__.py | 0 10 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/api.md create mode 100644 docs/index.md create mode 100644 docs/mdc_ccc.png create mode 100644 docs/mdc_logo.png create mode 100644 docs/mdc_logo_white.png create mode 100644 mkdocs.yml create mode 100644 src/datacollective/dataset_loading_scripts/__init__.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..99c7642 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,47 @@ +name: Documentation + +on: + push: + branches: [main] + paths: + - mkdocs.yml + - 'docs/**' + - 'src/**' + pull_request: + paths: + - mkdocs.yml + - 'docs/**' + - 'src/**' + workflow_dispatch: + +jobs: + docs: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - name: Check out the repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' + cache: "pip" + - name: Configure git + run: | + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + + - name: Install requirements + run: pip install -e '.[docs]' + + - name: Build docs + if: github.event_name == 'pull_request' + run: mkdocs build -s + + - name: Publish docs + if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + run: mkdocs gh-deploy \ No newline at end of file diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..e0a0c4d --- /dev/null +++ b/docs/api.md @@ -0,0 +1,9 @@ +# API Reference + +::: datacollective.datasets + +::: datacollective.api_utils + +::: datacollective.dataset_loading_scripts.registry + +::: datacollective.dataset_loading_scripts.common_voice diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..8ff74a9 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,203 @@ +# Mozilla Data Collective Python SDK Library + +Welcome to the documentation for the `datacollective` Python client for the +[Mozilla Data Collective](https://datacollective.mozillafoundation.org/) REST API. + +This library helps you: + +- Authenticate with the Mozilla Data Collective. +- Download datasets to local storage. +- Load supported datasets into AI-friendly formats, such as pandas DataFrames. + +## Installation + +Install from PyPI: + +```bash +pip install datacollective +``` + +You can also use uv or other Python tooling as desired, as long as the package datacollective is installed in your environment. + + +## Getting an API Key + +To use the Mozilla Data Collective API, you need an API key: + +1. Sign in to the Mozilla Data Collective dashboard. +2. Create or retrieve an API key from your account/settings page. +3. Keep your key secret and do not commit it to version control. + +## Configuration + +The client reads configuration from environment variables and `.env` files. + +### Environment variables + +Required: + +- `MDC_API_KEY` - Your Mozilla Data Collective API key. + +Optional: + +- `MDC_API_URL` - API endpoint (defaults to the production URL). +- `MDC_DOWNLOAD_PATH` - Local directory where datasets will be downloaded + (defaults to `~/.mozdata/datasets`). + +Example using environment variables directly: + +```bash +export MDC_API_KEY=your-api-key-here +export MDC_API_URL=https://datacollective.mozillafoundation.org/api +export MDC_DOWNLOAD_PATH=~/.mozdata/datasets +``` + +### `.env` file + +The client will automatically load configuration from a `.env` file in your +project root or present working directory. + +Create a file named `.env`: + +```bash +# MDC API Configuration +MDC_API_KEY=your-api-key-here +MDC_API_URL=https://datacollective.mozillafoundation.org/api +MDC_DOWNLOAD_PATH=~/.mozdata/datasets +``` + +> **Security note:** do not commit `.env` files to version control, as they +> contain secrets. + +## Basic Usage + +### Download a dataset + +Use `save_dataset_to_disk` to download a dataset to the configured download path: + +```python +from datacollective import save_dataset_to_disk + +dataset = save_dataset_to_disk("your-dataset-id") + +# Depending on the implementation, `dataset` may contain metadata +# about the downloaded files or a higher-level dataset object. +``` + +The files will be stored under `MDC_DOWNLOAD_PATH` (default `~/.mozdata/datasets`). + +## Loading and Querying Datasets + +> **Note:** in-memory dataset loading is currently supported only for certain datasets. + +You can load supported datasets into memory and convert them to a `pandas` +`DataFrame` for analysis: + +```python +from datacollective import load_dataset + +dataset = load_dataset("your-dataset-id") + +# Convert to pandas +df = dataset.to_pandas() + +# Inspect available splits (e.g., train, dev, test) +print(dataset.splits) +``` + +Once loaded into a `DataFrame`, you can use standard `pandas` operations +to filter, aggregate, and analyze the data. + +## Get dataset details + +You can retrieve info from the datasheet of a dataset without downloading it: + +```python +from datacollective import get_dataset_info + +info = get_dataset_info("your-dataset-id") +print(info) +``` + +## Release Workflow + +This repository uses GitHub Actions and branch-specific workflows for +publishing releases. + +### Branches + +- `main` \- primary development branch; merging to `main` triggers + automated version bumping. +- `test-pypi` \- deploying releases to TestPyPI. +- `pypi` \- deploying releases to the production PyPI index. + +### Automated steps + +1. **Prepare release on `main`** + + When a pull request is merged into `main`, a workflow runs: + + ```bash + uv run python scripts/dev.py prepare-release + ``` + + This command: + - Runs the full check suite. + - Bumps the version. + - Pushes the updated commit and tag back to `main`. + +2. **Deploy to TestPyPI** + + Merging the updated `main` into `test-pypi` runs: + + ```bash + uv run python scripts/dev.py publish-test + ``` + + This builds and publishes the new version to TestPyPI. + +3. **Deploy to PyPI** + + After validating the package from TestPyPI, merge the same `main` + commit into `pypi` to run: + + ```bash + uv run python scripts/dev.py publish + ``` + + This publishes the package to the production PyPI index. + +### Recommended local workflow + +Before opening release-related pull requests: + +1. Run the full checks without modifying files: + + ```bash + uv run python scripts/dev.py all + ``` + +2. Optionally preview the version bump locally: + + ```bash + uv run python scripts/dev.py prepare-release + ``` + + The GitHub Actions workflow runs the same command when the PR + is merged into `main`. + +3. After the automated version bump lands on `main`, open PRs from: + - `main` to `test-pypi` to deploy to TestPyPI. + - `main` to `pypi` to deploy to PyPI (once validated). + +## API Reference + +For a detailed API reference, see: + +- `datacollective.datasets` +- `datacollective.api_utils` +- `datacollective.dataset_loading_scripts.registry` +- `datacollective.dataset_loading_scripts.common_voice` + +The `docs/api.md` file is configured to be processed by the API +documentation plugin for MkDocs. diff --git a/docs/mdc_ccc.png b/docs/mdc_ccc.png new file mode 100644 index 0000000000000000000000000000000000000000..839e4a8682b3297cbacbb051676fc824a9d15f76 GIT binary patch literal 52732 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

{(Nu0|K)OB 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 literal 0 HcmV?d00001 diff --git a/docs/mdc_logo.png b/docs/mdc_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..3fd90d8fc4ab26d44c4745dcdd996be777e9b865 GIT binary patch literal 116587 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 + + + + + Project logo + +

+ +
+ +[![Published](https://github.com/Mozilla-Data-Collective/datacollective-python/actions/workflows/publish.yml/badge.svg)](https://github.com/Mozilla-Data-Collective/datacollective-python/actions/workflows/publish.yml/) +[![Docs](https://github.com/Mozilla-Data-Collective/datacollective-python/actions/workflows/docs.yml/badge.svg)](https://github.com/Mozilla-Data-Collective/datacollective-python/actions/workflows/docs.yml/) +[![Tests](https://github.com/Mozilla-Data-Collective/datacollective-python/actions/workflows/tests.yml/badge.svg)](https://github.com/Mozilla-Data-Collective/datacollective-python/actions/workflows/tests.yml/) + +
+ # Mozilla Data Collective Python API Library Python library for interfacing with the [Mozilla Data Collective](https://datacollective.mozillafoundation.org/) REST API. ## Installation -Install the package using pip: - ```bash pip install datacollective ``` @@ -14,122 +31,20 @@ pip install datacollective 1. **Get your API key** from the Mozilla Data Collective dashboard -2. **Set up your environment**: - -If you have cloned the repository, you can run the following command: - - ```bash - # Copy the example environment file - cp .env.example .env - ``` - -Otherwise, copy and paste the following into a file called `.env` in your present working directory. - -```bash -MDC_API_KEY= # change to your MDC API Key -MDC_API_URL=https://datacollective.mozillafoundation.org/api # change to MDC API URL endpoint -MDC_DOWNLOAD_PATH=~/.mozdata/datasets # change to where you want to download datasets -``` - -3. **Configure your API key** by editing `.env`: - ```bash - # Required: Your MDC API key - MDC_API_KEY=your-api-key-here - - # Optional: Download path for datasets (defaults to ~/.mozdata/datasets) - MDC_DOWNLOAD_PATH=~/.mozdata/datasets - ``` - -4. **Start using the library**: - ```python - from datacollective import DataCollective - - # Initialize the client - client = DataCollective() - - # Download a dataset - client.get_dataset('mdc-dataset-id') - ``` - -## Configuration - -The client loads configuration from environment variables or `.env` files: - -- `MDC_API_KEY` - Your Mozilla Data Collective API key (required) -- `MDC_API_URL` - API endpoint (defaults to production) -- `MDC_DOWNLOAD_PATH` - Where to download datasets (defaults to `~/.mozdata/datasets`) - -### Environment Files - -Create a `.env` file in your project root: - -```bash -# MDC API Configuration -MDC_API_KEY=your-api-key-here -MDC_API_URL=https://datacollective.mozillafoundation.org/api -MDC_DOWNLOAD_PATH=~/.mozdata/datasets -``` - -**Note:** Never commit `.env` files to version control as they contain sensitive information. - -## Basic Usage - -```python -from datacollective import DataCollective +2. **Set the API key in your environment variable**: -# Initialize client (loads from .env automatically) -client = DataCollective() - -# Verify your configuration -print(f"API URL: {client.api_url}") -print(f"Download path: {client.download_path}") - -# Download a dataset -dataset = client.get_dataset('your-dataset-id') ``` - -## Load and query datasets - -**note:** today, this feature only works with Mozilla Common Voice datasets +export MDC_API_KEY=your-api-key-here ``` -from datacollective import DataCollective - -client = DataCollective() -dataset = client.load_dataset("") # Load dasaset into memory -df = dataset.to_pandas() # Convert to pandas for queryable form -dataset.splits # A list of all splits available in the dataset +3.**Use the client**: ``` +from datacollective import load_dataset - -## Multiple Environments - -You can use different environment configurations: - -```python -# Production environment (default, uses .env) -client = DataCollective() - -# Development environment (uses .env.development) -client = DataCollective(environment='development') - -# Staging environment (uses .env.staging) -client = DataCollective(environment='staging') +dataset = load_dataset("your-dataset-id") ``` -## Release Workflow - -The repository uses branch-specific GitHub Actions for releases: - -- When a pull request is merged into `main`, the workflow runs `uv run python scripts/dev.py prepare-release`, which executes the full check suite, bumps the version, and pushes the commit and tag back to `main`. -- Merging the updated `main` into `test-pypi` deploys that version to TestPyPI via `uv run python scripts/dev.py publish-test`. -- After validating on TestPyPI, merging the same `main` commit into `pypi` runs `uv run python scripts/dev.py publish` to ship to the production index. - -Recommended local prep before opening release pull requests: - -1. Run `uv run python scripts/dev.py all` to make sure checks pass without modifying files. -2. Optionally run `uv run python scripts/dev.py prepare-release` locally if you want to preview the version bump; the workflow performs the same operation when the PR merges. -3. Once the automated bump lands on `main`, open PRs from `main` into `test-pypi` and then `pypi` to trigger the deploy pipelines. +## For more details, visit [our docs](https://Mozilla-Data-Collective.github.io/datacollective-python/) ## License diff --git a/src/datacollective/dataset_loading_scripts/common_voice.py b/src/datacollective/dataset_loading_scripts/common_voice.py index 86bad93..f82b74a 100644 --- a/src/datacollective/dataset_loading_scripts/common_voice.py +++ b/src/datacollective/dataset_loading_scripts/common_voice.py @@ -14,6 +14,11 @@ def _load_scripted(root_dir: Path) -> pd.DataFrame: + """ + Load Common Voice spontaneous speech datasets from the given root directory. + The function searches for TSV files corresponding to predefined scripted speech splits, + reads them into DataFrames, adds a 'split' column, and concatenates them into a single DataFrame. + """ split_files: dict[str, Path] = {} for path in root_dir.rglob("*.tsv"): split_name = path.stem @@ -32,6 +37,11 @@ def _load_scripted(root_dir: Path) -> pd.DataFrame: def _load_spontaneous(root_dir: Path) -> pd.DataFrame: + """ + Load Common Voice spontaneous speech datasets from the given root directory. + The function searches for a TSV file with a name starting with 'ss-corpus-', + reads it into a DataFrame, and returns it. + """ for path in root_dir.rglob("*.tsv"): if path.name.startswith("ss-corpus-"): return pd.read_csv(path, sep="\t", header="infer") diff --git a/src/datacollective/dataset_loading_scripts/registry.py b/src/datacollective/dataset_loading_scripts/registry.py index 7cab8fa..8637c7d 100644 --- a/src/datacollective/dataset_loading_scripts/registry.py +++ b/src/datacollective/dataset_loading_scripts/registry.py @@ -1,6 +1,7 @@ from pathlib import Path import pandas as pd + from datacollective.dataset_loading_scripts.common_voice import ( _load_scripted, _load_spontaneous, @@ -10,6 +11,20 @@ def load_dataset_from_name_as_dataframe( dataset_name: str, extract_dir: Path ) -> pd.DataFrame: + """ + In order to enable loading MDC datasets as Pandas DataFrames, this function + routes the loading process to the appropriate dataset-specific loader based on + the dataset name. Each dataset loader is implemented in its own module under + `datacollective.dataset_loading_scripts`. + + Args: + dataset_name (str): The name of the dataset (lowercased). + extract_dir (Path): The directory where the dataset has been extracted. + Returns: + A pandas DataFrame containing the loaded dataset. + Raises: + ValueError: If the dataset name is not supported for loading. + """ if "scripted" in dataset_name: return _load_scripted(extract_dir) if "spontaneous" in dataset_name: From 457b5ebbc996886b5564f9f89c437122ea80f38c Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Tue, 18 Nov 2025 17:02:36 +0200 Subject: [PATCH 15/25] Add GH action for tests --- .github/workflows/publish.yml | 2 +- .github/workflows/tests.yml | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9591bf9..293909c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,4 @@ -name: Publish Packages +name: Publish on: pull_request: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..a5a2b03 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,34 @@ +name: Tests + +on: + push: + branches: [main] + paths: + - 'src/**' + - 'tests/**' + pull_request: + paths: + - 'src/**' + - 'tests/**' + workflow_dispatch: + +jobs: + run-tests: + timeout-minutes: 30 + runs-on: ubuntu-latest + + steps: + - name: Check out the repository + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' + cache: "pip" + + - name: Install test dependencies + run: pip install -e '.[tests]' + + - name: Run Tests + run: pytest -v tests From 111bcb3d9f2fa3387f56d20f3937ce912808844c Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Tue, 18 Nov 2025 17:05:04 +0200 Subject: [PATCH 16/25] Update dep gh action --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a5a2b03..be2ca27 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,7 +28,7 @@ jobs: cache: "pip" - name: Install test dependencies - run: pip install -e '.[tests]' + run: pip install -e '.[dev]' - name: Run Tests run: pytest -v tests From 7c9db17ef4e56434b019ae8829702a66b2fa0252 Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Thu, 20 Nov 2025 14:21:43 +0200 Subject: [PATCH 17/25] Add release workflow to separate page --- docs/index.md | 85 ++++--------------------------------------------- docs/release.md | 70 ++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 3 files changed, 78 insertions(+), 78 deletions(-) create mode 100644 docs/release.md diff --git a/docs/index.md b/docs/index.md index 8ff74a9..6f5c6f1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -119,85 +119,14 @@ info = get_dataset_info("your-dataset-id") print(info) ``` -## Release Workflow - -This repository uses GitHub Actions and branch-specific workflows for -publishing releases. - -### Branches - -- `main` \- primary development branch; merging to `main` triggers - automated version bumping. -- `test-pypi` \- deploying releases to TestPyPI. -- `pypi` \- deploying releases to the production PyPI index. - -### Automated steps - -1. **Prepare release on `main`** - - When a pull request is merged into `main`, a workflow runs: - - ```bash - uv run python scripts/dev.py prepare-release - ``` - - This command: - - Runs the full check suite. - - Bumps the version. - - Pushes the updated commit and tag back to `main`. - -2. **Deploy to TestPyPI** - - Merging the updated `main` into `test-pypi` runs: - - ```bash - uv run python scripts/dev.py publish-test - ``` - - This builds and publishes the new version to TestPyPI. - -3. **Deploy to PyPI** - - After validating the package from TestPyPI, merge the same `main` - commit into `pypi` to run: - - ```bash - uv run python scripts/dev.py publish - ``` - - This publishes the package to the production PyPI index. - -### Recommended local workflow - -Before opening release-related pull requests: - -1. Run the full checks without modifying files: - - ```bash - uv run python scripts/dev.py all - ``` - -2. Optionally preview the version bump locally: - - ```bash - uv run python scripts/dev.py prepare-release - ``` - - The GitHub Actions workflow runs the same command when the PR - is merged into `main`. - -3. After the automated version bump lands on `main`, open PRs from: - - `main` to `test-pypi` to deploy to TestPyPI. - - `main` to `pypi` to deploy to PyPI (once validated). - ## API Reference -For a detailed API reference, see: +For a detailed API reference, see the [API Reference](api.md) section of the documentation. + +## Release Workflow -- `datacollective.datasets` -- `datacollective.api_utils` -- `datacollective.dataset_loading_scripts.registry` -- `datacollective.dataset_loading_scripts.common_voice` +> [!NOTE] +> This section is intended for maintainers of the `datacollective` library. -The `docs/api.md` file is configured to be processed by the API -documentation plugin for MkDocs. +Check out the [Release Workflow](release.md) document for details on how to +publish new versions of the library to PyPI using GitHub Actions. \ No newline at end of file diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..d9e0c2b --- /dev/null +++ b/docs/release.md @@ -0,0 +1,70 @@ +## Release Workflow + +This repository uses GitHub Actions and branch-specific workflows for +publishing releases. + +### Branches + +- `main` \- primary development branch; merging to `main` triggers + automated version bumping. +- `test-pypi` \- deploying releases to TestPyPI. +- `pypi` \- deploying releases to the production PyPI index. + +### Automated steps + +1. **Prepare release on `main`** + + When a pull request is merged into `main`, a workflow runs: + + ```bash + uv run python scripts/dev.py prepare-release + ``` + + This command: + - Runs the full check suite. + - Bumps the version. + - Pushes the updated commit and tag back to `main`. + +2. **Deploy to TestPyPI** + + Merging the updated `main` into `test-pypi` runs: + + ```bash + uv run python scripts/dev.py publish-test + ``` + + This builds and publishes the new version to TestPyPI. + +3. **Deploy to PyPI** + + After validating the package from TestPyPI, merge the same `main` + commit into `pypi` to run: + + ```bash + uv run python scripts/dev.py publish + ``` + + This publishes the package to the production PyPI index. + +### Recommended local workflow + +Before opening release-related pull requests: + +1. Run the full checks without modifying files: + + ```bash + uv run python scripts/dev.py all + ``` + +2. Optionally preview the version bump locally: + + ```bash + uv run python scripts/dev.py prepare-release + ``` + + The GitHub Actions workflow runs the same command when the PR + is merged into `main`. + +3. After the automated version bump lands on `main`, open PRs from: + - `main` to `test-pypi` to deploy to TestPyPI. + - `main` to `pypi` to deploy to PyPI (once validated). diff --git a/mkdocs.yml b/mkdocs.yml index 6bcb73c..084af95 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,6 +5,7 @@ repo_name: datacollective-python nav: - Home: index.md - API Reference: api.md + - Release Workflow: release.md theme: icon: From c7d5cfb243541b49fe84a2b73dc2b6a3f5dea5f3 Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Mon, 24 Nov 2025 13:32:08 +0200 Subject: [PATCH 18/25] Default value for download_directory is None --- src/datacollective/datasets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/datacollective/datasets.py b/src/datacollective/datasets.py index 6256798..0d95773 100644 --- a/src/datacollective/datasets.py +++ b/src/datacollective/datasets.py @@ -44,7 +44,7 @@ def get_dataset_details(dataset_id: str) -> dict[str, Any]: def save_dataset_to_disk( dataset_id: str, - download_directory: str, + download_directory: str | None = None, show_progress: bool = True, overwrite_existing: bool = False, ) -> Path: @@ -122,7 +122,7 @@ def save_dataset_to_disk( def load_dataset( - dataset_id: str, download_directory: str, show_progress: bool = True + dataset_id: str, download_directory: str | None = None, show_progress: bool = True ) -> pd.DataFrame: """ Download (if needed), extract, and load the dataset into a pandas DataFrame. From 7ad7784e04d262c3dd7d3b54ebad6c8f090d6ce8 Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Mon, 24 Nov 2025 13:48:46 +0200 Subject: [PATCH 19/25] Bump versions in pre-commit --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index baf9892..bf9074c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,19 +1,19 @@ repos: - repo: https://github.com/psf/black - rev: 23.12.1 + rev: 25.11.0 hooks: - id: black language_version: python3.9 - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.8 + rev: v0.14.6 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.8.0 + rev: v1.18.2 hooks: - id: mypy additional_dependencies: [types-requests] From 666e20565258e453d402f6336d12aef3fb09fccf Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Mon, 24 Nov 2025 13:49:39 +0200 Subject: [PATCH 20/25] Remove comments from .env.example --- .env.example | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index dd80dc5..d50ecf2 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,3 @@ -MDC_API_KEY= # change to your MDC API Key -MDC_API_URL=https://datacollective.mozillafoundation.org/api # change to MDC API URL endpoint -MDC_DOWNLOAD_PATH=~/.mozdata/datasets # change to where you want to download datasets \ No newline at end of file +MDC_API_KEY= +MDC_API_URL=https://datacollective.mozillafoundation.org/api +MDC_DOWNLOAD_PATH=~/.mozdata/datasets \ No newline at end of file From 77daa8ac7c1c04a5c9589a3c38d74729a18b8ff4 Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Mon, 24 Nov 2025 13:49:48 +0200 Subject: [PATCH 21/25] Remove dev scripts --- scripts/dev.py | 296 ------------------------------------------------- 1 file changed, 296 deletions(-) delete mode 100755 scripts/dev.py diff --git a/scripts/dev.py b/scripts/dev.py deleted file mode 100755 index fc5217f..0000000 --- a/scripts/dev.py +++ /dev/null @@ -1,296 +0,0 @@ -#!/usr/bin/env python3 -""" -Development scripts for the datacollective package. -Run with: python scripts/dev.py -""" - -import subprocess -import sys -from pathlib import Path - - -def run_command(cmd: list[str]) -> int: - """Run a command and return its exit code.""" - print(f"Running: {' '.join(cmd)}") - result = subprocess.run(cmd) - return result.returncode - - -def format_code() -> int: - """Format code with Black.""" - print("๐ŸŽจ Formatting code with Black...") - return run_command(["uv", "run", "black", "src/", "tests/"]) - - -def format_check() -> int: - """Verify code formatting with Black without modifying files.""" - print("๐ŸŽจ Checking formatting with Black...") - return run_command(["uv", "run", "black", "--check", "src/", "tests/"]) - - -def lint_code() -> int: - """Lint code with Ruff.""" - print("๐Ÿ” Linting code with Ruff...") - return run_command(["uv", "run", "ruff", "check", "src/", "tests/"]) - - -def type_check() -> int: - """Type check with MyPy.""" - print("๐Ÿ”ฌ Type checking with MyPy...") - return run_command(["uv", "run", "mypy", "src/"]) - - -def fix_lint() -> int: - """Fix linting issues automatically.""" - print("๐Ÿ”ง Fixing linting issues...") - return run_command(["uv", "run", "ruff", "check", "--fix", "src/", "tests/"]) - - -def run_tests() -> int: - """Run tests with pytest.""" - print("๐Ÿงช Running tests...") - return run_command(["uv", "run", "pytest", "tests/"]) - - -def bump_version(part: str) -> int: - """Bump version using bump2version.""" - print(f"๐Ÿ“ฆ Bumping {part} version...") - return run_command(["uv", "run", "bump2version", part]) - - -def show_version() -> int: - """Show current version.""" - print("๐Ÿ“‹ Current version information:") - - # Read version from pyproject.toml - pyproject_path = Path("pyproject.toml") - if pyproject_path.exists(): - content = pyproject_path.read_text() - for line in content.split("\n"): - if line.strip().startswith("version = "): - version = line.split('"')[1] - print(f" pyproject.toml: {version}") - break - - # Read version from __init__.py - init_path = Path("src/datacollective/__init__.py") - if init_path.exists(): - content = init_path.read_text() - for line in content.split("\n"): - if "__version__" in line: - version = line.split('"')[1] - print(f" __init__.py: {version}") - break - - return 0 - - -def clean_build() -> int: - """Clean build artifacts.""" - print("๐Ÿงน Cleaning build artifacts...") - import shutil - import os - - # Remove dist directory - if os.path.exists("dist"): - shutil.rmtree("dist") - print(" Removed dist/ directory") - - # Remove build directory - if os.path.exists("build"): - shutil.rmtree("build") - print(" Removed build/ directory") - - # Remove __pycache__ directories - for root, dirs, files in os.walk("."): - for dir_name in dirs[:]: # Use slice to avoid modifying list while iterating - if dir_name == "__pycache__": - shutil.rmtree(os.path.join(root, dir_name)) - print(f" Removed {os.path.join(root, dir_name)}") - dirs.remove(dir_name) - - print("โœ… Cleanup complete!") - return 0 - - -def build_package() -> int: - """Build the package.""" - print("๐Ÿ“ฆ Building package...") - return run_command(["uv", "build"]) - - -def publish_package(index: str = "pypi") -> int: - """Publish package to PyPI or TestPyPI.""" - print(f"๐Ÿš€ Publishing to {index}...") - - # Clean first - if clean_build() != 0: - print("โŒ Clean failed") - return 1 - - # Build package - if build_package() != 0: - print("โŒ Build failed") - return 1 - - # Publish - publish_cmd = ["uv", "publish"] - if index == "testpypi": - publish_cmd.extend(["--publish-url", "https://test.pypi.org/legacy/"]) - return run_command(publish_cmd) - - -def publish_with_bump(index: str = "pypi", part: str = "patch") -> int: - """Bump version and publish package to PyPI or TestPyPI.""" - print(f"๐Ÿš€ Bumping {part} version and publishing to {index}...") - - # Show current version - print("๐Ÿ“‹ Current version:") - if show_version() != 0: - print("โŒ Failed to get current version") - return 1 - - # Run all checks before bumping so we don't consume versions on failure - print("๐Ÿ” Running pre-publish checks...") - if all_checks() != 0: - print("โŒ Pre-publish checks failed") - return 1 - - # Bump version - if bump_version(part) != 0: - print("โŒ Version bump failed") - return 1 - - # Show new version - print("๐Ÿ“‹ New version:") - if show_version() != 0: - print("โŒ Failed to get new version") - return 1 - - # Publish - return publish_package(index) - - -def prepare_release(part: str = "patch") -> int: - """Run checks and bump version without publishing.""" - print(f"๐Ÿš€ Preparing release by bumping {part} version...") - - print("๐Ÿ“‹ Current version:") - if show_version() != 0: - print("โŒ Failed to get current version") - return 1 - - print("๐Ÿ” Running pre-release checks...") - if all_checks() != 0: - print("โŒ Pre-release checks failed") - return 1 - - print("๐Ÿ“ฆ Bumping version...") - if bump_version(part) != 0: - print("โŒ Version bump failed") - return 1 - - print("๐Ÿ“‹ New version:") - if show_version() != 0: - print("โŒ Failed to get new version") - return 1 - - return 0 - - -def all_checks() -> int: - """Run all checks: format, lint, type check, and tests.""" - print("๐Ÿš€ Running all checks...") - - # Check formatting - if format_check() != 0: - print("โŒ Formatting check failed") - return 1 - - # Run linting - if lint_code() != 0: - print("โŒ Linting failed") - return 1 - - # Type check - if type_check() != 0: - print("โŒ Type checking failed") - return 1 - - # Run tests - if run_tests() != 0: - print("โŒ Tests failed") - return 1 - - print("โœ… All checks passed!") - return 0 - - -def main(): - """Main entry point.""" - if len(sys.argv) < 2: - print("Usage: python scripts/dev.py ") - print("Commands:") - print(" format - Format code with Black") - print(" lint - Lint code with Ruff") - print(" typecheck - Type check with MyPy") - print(" fix - Fix linting issues automatically") - print(" test - Run tests") - print(" all - Run all checks") - print(" clean - Clean build artifacts") - print(" build - Build package") - print(" publish - Clean, build, and publish to PyPI") - print(" publish-test - Clean, build, and publish to TestPyPI") - print(" publish-bump - Bump patch version and publish to PyPI") - print(" publish-bump-test - Bump patch version and publish to TestPyPI") - print(" prepare-release - Run checks and bump patch version without publishing") - print(" version - Show current version") - print(" bump-patch - Bump patch version (0.0.1 -> 0.0.2)") - print(" bump-minor - Bump minor version (0.0.1 -> 0.1.0)") - print(" bump-major - Bump major version (0.0.1 -> 1.0.0)") - return 1 - - command = sys.argv[1].lower() - - # Handle version bumping commands - if command.startswith("bump-"): - part = command.split("-")[1] - if part in ["patch", "minor", "major"]: - return bump_version(part) - else: - print(f"Unknown version part: {part}") - return 1 - - # Handle publish commands - if command == "publish": - return publish_package("pypi") - elif command == "publish-test": - return publish_package("testpypi") - elif command == "publish-bump": - return publish_with_bump("pypi", "patch") - elif command == "publish-bump-test": - return publish_with_bump("testpypi", "patch") - elif command == "prepare-release": - return prepare_release("patch") - - commands = { - "format": format_code, - "lint": lint_code, - "typecheck": type_check, - "fix": fix_lint, - "test": run_tests, - "all": all_checks, - "clean": clean_build, - "build": build_package, - "version": show_version, - } - - if command not in commands: - print(f"Unknown command: {command}") - return 1 - - return commands[command]() - - -if __name__ == "__main__": - sys.exit(main()) From 90a94d5d76b0cac2f03ddd0c50acb53e5162d247 Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Tue, 25 Nov 2025 17:22:10 +0200 Subject: [PATCH 22/25] Update release docs --- docs/release.md | 47 ++++++++++++++++++++--------------------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/docs/release.md b/docs/release.md index d9e0c2b..88db84b 100644 --- a/docs/release.md +++ b/docs/release.md @@ -5,47 +5,35 @@ publishing releases. ### Branches -- `main` \- primary development branch; merging to `main` triggers - automated version bumping. -- `test-pypi` \- deploying releases to TestPyPI. -- `pypi` \- deploying releases to the production PyPI index. +- `main` - primary development branch. When a pull request is merged into `main` the repository workflow: + - Runs the full check suite. + - Bumps the version. + - Opens a `release/vX.Y.Z` pull request back onto `main`. Auto-merge is enabled on that PR, so once required checks pass the version commit lands on `main` automatically. +- `test-pypi` - receives releases from `main` to deploy to TestPyPI. +- `pypi` - receives releases from `main` to deploy to the production PyPI index. ### Automated steps 1. **Prepare release on `main`** - When a pull request is merged into `main`, a workflow runs: - - ```bash - uv run python scripts/dev.py prepare-release - ``` - - This command: - - Runs the full check suite. - - Bumps the version. - - Pushes the updated commit and tag back to `main`. + When a pull request is merged into `main`, the release workflow runs the full checks, performs the version bump, and opens the `release/vX.Y.Z` pull request onto `main`. That PR is configured to auto-merge once required checks complete, so the version commit is applied to `main` without manual intervention. 2. **Deploy to TestPyPI** - Merging the updated `main` into `test-pypi` runs: + Merge the updated `main` into `test-pypi` to deploy that version to TestPyPI. The following command runs automatically in the workflow: ```bash uv run python scripts/dev.py publish-test ``` - This builds and publishes the new version to TestPyPI. - 3. **Deploy to PyPI** - After validating the package from TestPyPI, merge the same `main` - commit into `pypi` to run: + After validating the package on TestPyPI, merge `main` into `pypi` to deploy to production. The following command runs automatically in the workflow: ```bash uv run python scripts/dev.py publish ``` - This publishes the package to the production PyPI index. - ### Recommended local workflow Before opening release-related pull requests: @@ -56,15 +44,20 @@ Before opening release-related pull requests: uv run python scripts/dev.py all ``` -2. Optionally preview the version bump locally: +2. Optionally rehearse the version bump locally: ```bash uv run python scripts/dev.py prepare-release ``` - The GitHub Actions workflow runs the same command when the PR - is merged into `main`. + The repository workflow performs the same steps when `main` changes. + +3. Follow the branch merge order so TestPyPI receives the version before production: + + - `main` -> `test-pypi` + - `main` -> `pypi` + +### Required GitHub Actions secrets -3. After the automated version bump lands on `main`, open PRs from: - - `main` to `test-pypi` to deploy to TestPyPI. - - `main` to `pypi` to deploy to PyPI (once validated). +- `TEST_PYPI_API_TOKEN` - token for publishing to TestPyPI (username `__token__`). +- `PYPI_API_TOKEN` - token for publishing to PyPI (username `__token__`). From dc22a4df5a6bf9fafd0d98ff534ee6c2f7c3be7a Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Wed, 26 Nov 2025 15:26:46 +0200 Subject: [PATCH 23/25] Remove empty files --- scripts/dev.py | 0 src/datacollective/client.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100755 scripts/dev.py delete mode 100644 src/datacollective/client.py diff --git a/scripts/dev.py b/scripts/dev.py deleted file mode 100755 index e69de29..0000000 diff --git a/src/datacollective/client.py b/src/datacollective/client.py deleted file mode 100644 index e69de29..0000000 From 824fb03da131e9d4cec73cd6a682633f7770c15b Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Wed, 26 Nov 2025 15:36:16 +0200 Subject: [PATCH 24/25] Propagate overwrite_existing in load_dataset --- src/datacollective/datasets.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/datacollective/datasets.py b/src/datacollective/datasets.py index 0d95773..ea08155 100644 --- a/src/datacollective/datasets.py +++ b/src/datacollective/datasets.py @@ -122,7 +122,10 @@ def save_dataset_to_disk( def load_dataset( - dataset_id: str, download_directory: str | None = None, show_progress: bool = True + dataset_id: str, + download_directory: str | None = None, + show_progress: bool = True, + overwrite_existing: bool = False, ) -> pd.DataFrame: """ Download (if needed), extract, and load the dataset into a pandas DataFrame. @@ -132,6 +135,7 @@ def load_dataset( download_directory: Directory where to save the downloaded dataset. If None or empty, falls back to env MDC_DOWNLOAD_PATH or default. show_progress: Whether to show a progress bar during download. + overwrite_existing: Whether to overwrite existing files. Returns: A pandas DataFrame with the loaded dataset. Raises: @@ -145,7 +149,7 @@ def load_dataset( dataset_id=dataset_id, download_directory=download_directory, show_progress=show_progress, - overwrite_existing=False, + overwrite_existing=overwrite_existing, ) base_dir = _resolve_download_dir(download_directory) extract_dir = _extract_archive(archive_path, base_dir) From 068f4ceb58c09c7c1b210248af0698a3d32ccbfa Mon Sep 17 00:00:00 2001 From: Kostis-S-Z Date: Wed, 26 Nov 2025 15:42:43 +0200 Subject: [PATCH 25/25] Add more info in quick start section --- README.md | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8efb161..17805d2 100644 --- a/README.md +++ b/README.md @@ -29,15 +29,35 @@ pip install datacollective ## Quick Start -1. **Get your API key** from the Mozilla Data Collective dashboard +1. **Get your API key** from the Mozilla Data Collective [dashboard](https://datacollective.mozillafoundation.org/api-reference) -2. **Set the API key in your environment variable**: +2. **Set the API key in your environment variable (or create `.env` file add it there)**: ``` export MDC_API_KEY=your-api-key-here ``` -3.**Use the client**: +3. **Get your dataset ID from the last section of the dataset URL at the MDC website**. + +For example, in the URL `https://datacollective.mozillafoundation.org/datasets/cmflnuzw43exbql8uukllvnqg`, the dataset ID is `cmflnuzw43exbql8uukllvnqg`. + +4. **Save a dataset locally**: +``` +from datacollective import save_dataset_to_disk + +dataset = save_dataset_to_disk("your-dataset-id") +``` + +5. **Get information & metadata about a dataset**: + +``` +from datacollective import get_dataset_details + +details = get_dataset_details("your-dataset-id") +``` + +6. **Load the dataset into a pandas DataFrame _(Only Common Voice datasets are supported right now)_**: + ``` from datacollective import load_dataset