From f03330e85e123153e5283481df97fa9c5c74df72 Mon Sep 17 00:00:00 2001 From: PaoloLeonard Date: Mon, 16 Jun 2025 22:17:56 +0200 Subject: [PATCH 1/7] added documentation about a gdrive and adding new provder --- docs/tool/providers/custom_provider.md | 117 +++++++++++++++++++++++++ docs/tool/providers/google_drive.md | 68 ++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 docs/tool/providers/custom_provider.md create mode 100644 docs/tool/providers/google_drive.md diff --git a/docs/tool/providers/custom_provider.md b/docs/tool/providers/custom_provider.md new file mode 100644 index 0000000..15e9b23 --- /dev/null +++ b/docs/tool/providers/custom_provider.md @@ -0,0 +1,117 @@ +# How to Add a New Provider + +This guide explains how to integrate a new storage provider (e.g., DropBox, OneDrive) into DocBinder-OSS. The process involves creating configuration and client classes, registering the provider, and ensuring compatibility with the system’s models and interfaces. + +--- + +## 1. Create a Service Configuration Class + +Each provider must define a configuration class that inherits from [`ServiceConfig`](src/docbinder_oss/services/base_class.py): + +```python +# filepath: src/docbinder_oss/services/my_provider/my_provider_service_config.py +from docbinder_oss.services.base_class import ServiceConfig + +class MyProviderServiceConfig(ServiceConfig): + type: str = "my_provider" + name: str + api_key: str + # Add any other provider-specific fields here +``` + +- `type` must be unique and match the provider’s identifier. +- `name` is a user-defined label for this provider instance. + +--- + +## 2. Implement the Storage Client + +Create a client class that inherits from [`BaseStorageClient`](src/docbinder_oss/services/base_class.py) and implements all abstract methods: + +```python +# filepath: src/docbinder_oss/services/my_provider/my_provider_client.py +from docbinder_oss.services.base_class import BaseStorageClient +from .my_provider_service_config import MyProviderServiceConfig + +class MyProviderClient(BaseStorageClient): + def __init__(self, config: MyProviderServiceConfig): + self.config = config + # Initialize SDK/client here + + def test_connection(self) -> bool: + # Implement connection test + pass + + def list_files(self, folder_id: Optional[str] = None) -> List[File]: + # Implement file listing + pass + + def get_file_metadata(self, item_id: str) -> File: + # Implement metadata retrieval + pass + + def get_permissions(self, item_id: str) -> List[Permission]: + # Implement permissions retrieval + pass +``` + +- Use the shared models [`File`](src/docbinder_oss/core/schemas.py), [`Permission`](src/docbinder_oss/core/schemas.py), etc., for return types. + +--- + +## 3. Register the Provider + +Add an `__init__.py` in your provider’s folder with a `register()` function: + +```python +# filepath: src/docbinder_oss/services/my_provider/__init__.py +from .my_provider_client import MyProviderClient +from .my_provider_service_config import MyProviderServiceConfig + +def register(): + return { + "display_name": "my_provider", + "config_class": MyProviderServiceConfig, + "client_class": MyProviderClient, + } +``` + +--- + +## 4. Ensure Discovery + +The system will automatically discover your provider if it’s in the `src/docbinder_oss/services/` directory and contains a `register()` function in `__init__.py`. + +--- + +## 5. Update the Config File + +Add your provider’s configuration to `~/.config/docbinder/config.yaml`: + +```yaml +providers: + - type: my_provider + name: my_instance + api_key: + # Add other required fields +``` + +--- + +## 6. Test Your Provider + +- Run the application and ensure your provider appears and works as expected. +- The config loader will validate your config using your `ServiceConfig` subclass. + +--- + +## Reference + +- [src/docbinder_oss/services/base_class.py](src/docbinder_oss/services/base_class.py) +- [src/docbinder_oss/core/schemas.py](src/docbinder_oss/core/schemas.py) +- [src/docbinder_oss/services/google_drive/](src/docbinder_oss/services/google_drive/) (example implementation) +- [src/docbinder_oss/services/__init__.py](src/docbinder_oss/services/__init__.py) + +--- + +**Tip:** Use the Google Drive as a template for your implementation. Make sure to follow the abstract method signatures and use the shared models for compatibility. \ No newline at end of file diff --git a/docs/tool/providers/google_drive.md b/docs/tool/providers/google_drive.md new file mode 100644 index 0000000..31c5e77 --- /dev/null +++ b/docs/tool/providers/google_drive.md @@ -0,0 +1,68 @@ +# Google Drive Configuration Setup + +This guide will help you configure Google Drive as a provider for DocBinder. + +## Prerequisites + +- A Google account +- Access to [Google Cloud Console](https://console.cloud.google.com/) +- DocBinder installed + +## Step 1: Create a Google Cloud Project + +1. Go to the [Google Cloud Console](https://console.cloud.google.com/). +2. Click on **Select a project** and then **New Project**. +3. Enter a project name and click **Create**. + +## Step 2: Enable Google Drive API + +1. In your project dashboard, navigate to **APIs & Services > Library**. +2. Search for **Google Drive API**. +3. Click **Enable**. + +## Step 3: Create OAuth 2.0 Credentials + +1. Go to **APIs & Services > Credentials**. +2. Click **+ CREATE CREDENTIALS** and select **OAuth client ID**. +3. Configure the consent screen if prompted. +4. Choose **Desktop app** or **Web application** as the application type. +5. Enter a name and click **Create**. +6. Download the `credentials.json` file. + +## Step 4: Configure DocBinder + +1. Place your downloaded credentials file somewhere accessible (e.g., ~/gcp_credentials.json). +2. The application will generate a token file (e.g., ~/gcp_token.json) after the first authentication. + +## Step 5: Edit the Config File + +Create the config file, and add a provider entry for Google Drive: +```python +providers: + - type: google_drive + name: my_gdrive + gcp_credentials_json: ./gcp_credentials.json + gcp_token_json: ./gcp_token.json +``` + +* type: Must be google_drive. +* name: A unique name for this provider. +* gcp_credentials_json: Absolute path to your Google Cloud credentials file. +* gcp_token_json: Absolute path where the token will be stored/generated. + +## Step 5: Authenticate and Test + +1. Run DocBinder with the Google Drive provider enabled. +2. On first run, follow the authentication prompt to grant access. +3. Verify that DocBinder can access your Google Drive files. + +## Troubleshooting + +- Ensure your credentials file is in the correct location. +- Check that the Google Drive API is enabled for your project. +- Review the [Google API Console](https://console.developers.google.com/) for error messages. + +## References + +- [Google Drive API Documentation](https://developers.google.com/drive) +- [DocBinder Documentation](../README.md) \ No newline at end of file From 635bff7eeb3a1756f296875965517216c8fca032 Mon Sep 17 00:00:00 2001 From: PaoloLeonard Date: Mon, 16 Jun 2025 22:20:19 +0200 Subject: [PATCH 2/7] added correct type for the example --- docs/tool/providers/google_drive.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tool/providers/google_drive.md b/docs/tool/providers/google_drive.md index 31c5e77..83551a4 100644 --- a/docs/tool/providers/google_drive.md +++ b/docs/tool/providers/google_drive.md @@ -37,7 +37,7 @@ This guide will help you configure Google Drive as a provider for DocBinder. ## Step 5: Edit the Config File Create the config file, and add a provider entry for Google Drive: -```python +```yaml providers: - type: google_drive name: my_gdrive From fa88476d75743cf5805afcb974b8641dc7403bb1 Mon Sep 17 00:00:00 2001 From: PaoloLeonard Date: Mon, 16 Jun 2025 22:30:10 +0200 Subject: [PATCH 3/7] Correct the ordering --- docs/tool/providers/custom_provider.md | 1 + docs/tool/providers/google_drive.md | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/tool/providers/custom_provider.md b/docs/tool/providers/custom_provider.md index 15e9b23..21ad8ef 100644 --- a/docs/tool/providers/custom_provider.md +++ b/docs/tool/providers/custom_provider.md @@ -30,6 +30,7 @@ Create a client class that inherits from [`BaseStorageClient`](src/docbinder_oss ```python # filepath: src/docbinder_oss/services/my_provider/my_provider_client.py +from typing import Optional, List from docbinder_oss.services.base_class import BaseStorageClient from .my_provider_service_config import MyProviderServiceConfig diff --git a/docs/tool/providers/google_drive.md b/docs/tool/providers/google_drive.md index 83551a4..a390973 100644 --- a/docs/tool/providers/google_drive.md +++ b/docs/tool/providers/google_drive.md @@ -47,10 +47,10 @@ providers: * type: Must be google_drive. * name: A unique name for this provider. -* gcp_credentials_json: Absolute path to your Google Cloud credentials file. -* gcp_token_json: Absolute path where the token will be stored/generated. +* gcp_credentials_json: Absolute/relative path to your Google Cloud credentials file. +* gcp_token_json: Absolute/relative path where the token will be stored/generated. -## Step 5: Authenticate and Test +## Step 6: Authenticate and Test 1. Run DocBinder with the Google Drive provider enabled. 2. On first run, follow the authentication prompt to grant access. From 055182f5da9507b21efedcd632226acd0ab5189b Mon Sep 17 00:00:00 2001 From: PaoloLeonard Date: Sat, 21 Jun 2025 10:56:01 +0200 Subject: [PATCH 4/7] fix import --- docs/tool/providers/custom_provider.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/tool/providers/custom_provider.md b/docs/tool/providers/custom_provider.md index 21ad8ef..2e62975 100644 --- a/docs/tool/providers/custom_provider.md +++ b/docs/tool/providers/custom_provider.md @@ -32,6 +32,7 @@ Create a client class that inherits from [`BaseStorageClient`](src/docbinder_oss # filepath: src/docbinder_oss/services/my_provider/my_provider_client.py from typing import Optional, List from docbinder_oss.services.base_class import BaseStorageClient +from docbinder_oss.core.schema import File, Permission from .my_provider_service_config import MyProviderServiceConfig class MyProviderClient(BaseStorageClient): From ee7ef342cae4d557157d4a6693ef383f76a9d239 Mon Sep 17 00:00:00 2001 From: PaoloLeonard Date: Sat, 21 Jun 2025 12:03:06 +0200 Subject: [PATCH 5/7] changing the order of the parameters --- docs/tool/providers/custom_provider.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tool/providers/custom_provider.md b/docs/tool/providers/custom_provider.md index 2e62975..bad4644 100644 --- a/docs/tool/providers/custom_provider.md +++ b/docs/tool/providers/custom_provider.md @@ -15,8 +15,8 @@ from docbinder_oss.services.base_class import ServiceConfig class MyProviderServiceConfig(ServiceConfig): type: str = "my_provider" name: str - api_key: str # Add any other provider-specific fields here + api_key: str ``` - `type` must be unique and match the provider’s identifier. @@ -94,8 +94,8 @@ Add your provider’s configuration to `~/.config/docbinder/config.yaml`: providers: - type: my_provider name: my_instance - api_key: # Add other required fields + api_key: ``` --- From 2c5718fa881cd17a07b4f09159c5d8017374913f Mon Sep 17 00:00:00 2001 From: PaoloLeonard Date: Tue, 24 Jun 2025 19:55:51 +0200 Subject: [PATCH 6/7] added nice writers and printing --- src/docbinder_oss/cli/search.py | 16 ++---- src/docbinder_oss/core/schemas.py | 14 ++--- src/docbinder_oss/helpers/writer.py | 84 +++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 19 deletions(-) create mode 100644 src/docbinder_oss/helpers/writer.py diff --git a/src/docbinder_oss/cli/search.py b/src/docbinder_oss/cli/search.py index 673aa08..036e812 100644 --- a/src/docbinder_oss/cli/search.py +++ b/src/docbinder_oss/cli/search.py @@ -10,6 +10,7 @@ from docbinder_oss.providers import create_provider_instance from docbinder_oss.helpers.config import Config from docbinder_oss.providers.base_class import BaseProvider +from docbinder_oss.helpers.writer import MultiFormatWriter app = typer.Typer() @@ -75,19 +76,8 @@ def search( max_size=max_size, ) - if not export_format: - typer.echo(current_files) - return - - elif export_format.lower() == "csv": - __write_csv(current_files, "search_results.csv") - typer.echo("Results written to search_results.csv") - elif export_format.lower() == "json": - __write_json(current_files, "search_results.json", flat=True) # or flat=False for grouped - typer.echo("Results written to search_results.json") - else: - typer.echo(f"Unsupported export format: {export_format}") - raise typer.Exit(code=1) + MultiFormatWriter.write(current_files, export_format) + return def filter_files( diff --git a/src/docbinder_oss/core/schemas.py b/src/docbinder_oss/core/schemas.py index e11307b..1d8b72b 100644 --- a/src/docbinder_oss/core/schemas.py +++ b/src/docbinder_oss/core/schemas.py @@ -41,10 +41,12 @@ class FileCapabilities(BaseModel): class File(BaseModel): """Represents a file or folder""" - id: str - name: str - mime_type: str - kind: Optional[str] + id: str = Field(repr=True, description="Unique identifier for the file or folder.") + name: str = Field( + repr=True, description="Name of the file or folder. May not be unique." + ) + mime_type: str = Field(repr=True, description="MIME type of the file or folder.") + kind: Optional[str] = Field(repr=True, description="Kind of the item, e.g., 'drive#file'.") is_folder: bool = Field(False, description="True if the item is a folder, False otherwise.") @@ -52,9 +54,9 @@ class File(BaseModel): icon_link: Optional[HttpUrl] created_time: Optional[datetime] - modified_time: Optional[datetime] + modified_time: Optional[datetime] = Field(repr=True, description="Last modified time of the file or folder.") - owners: Optional[List[User]] + owners: Optional[List[User]] = Field(repr=True, description="List of owners of the file or folder.") last_modifying_user: Optional[User] size: Optional[str] = Field(description="Size in bytes, as a string. Only populated for files.") diff --git a/src/docbinder_oss/helpers/writer.py b/src/docbinder_oss/helpers/writer.py new file mode 100644 index 0000000..0363bea --- /dev/null +++ b/src/docbinder_oss/helpers/writer.py @@ -0,0 +1,84 @@ +import csv +import json +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, Dict, List, Union +from rich import print +from rich.panel import Panel + + +class Writer(ABC): + """Abstract base writer class.""" + + @abstractmethod + def write(self, data: Any, file_path: Union[None, str, Path]) -> None: + """Write data to file.""" + pass + + +class MultiFormatWriter: + """Factory writer that automatically detects format from file extension.""" + + _writers = { + '.csv': 'CSVWriter', + '.json': 'JSONWriter', + '.txt': 'TextWriter', + } + + @classmethod + def write(cls, data: Any, file_path: Union[None, str, Path]) -> None: + """Write data to file, format determined by extension.""" + if file_path is None: + # If no file path is provided, write to console + ConsoleWriter().write(data) + return + path = Path(file_path) + extension = path.suffix.lower() + + if extension not in cls._writers: + raise ValueError(f"Unsupported format: {extension}") + + writer_class = globals()[cls._writers[extension]] + writer = writer_class() + writer.write(data, file_path) + + +class CSVWriter(Writer): + def write(self, data: List[Dict], file_path: Union[str, Path]) -> None: + if not data: + return + + with open(file_path, 'w', newline='', encoding='utf-8') as f: + writer = csv.DictWriter(f, fieldnames=data[0].keys()) + writer.writeheader() + writer.writerows(data) + + +class JSONWriter(Writer): + def write(self, data: Any, file_path: Union[str, Path]) -> None: + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False, default=str) + + +class ConsoleWriter(Writer): + def write(self, data: Dict) -> None: + from rich.table import Table + + table = Table(title="Files and Folders") + table.add_column("Provider", justify="right", style="cyan", no_wrap=True) + table.add_column("Id", style="magenta") + table.add_column("Name", style="magenta") + table.add_column("Kind", style="magenta") + for provider, items in data.items(): + for item in items: + table.add_row(provider, item.id, item.name, item.kind) + print(table) + + +class TextWriter(Writer): + def write(self, data: Any, file_path: Union[str, Path]) -> None: + with open(file_path, 'w', encoding='utf-8') as f: + if isinstance(data, (list, dict)): + f.write(json.dumps(data, indent=2, default=str)) + else: + f.write(str(data)) \ No newline at end of file From ec570516142db873dc8955149995e00340fb1e64 Mon Sep 17 00:00:00 2001 From: PaoloLeonard Date: Tue, 24 Jun 2025 20:45:56 +0200 Subject: [PATCH 7/7] =?UTF-8?q?corrected=20tests=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/docbinder_oss/cli/search.py | 23 ------ src/docbinder_oss/core/schemas.py | 7 -- src/docbinder_oss/helpers/writer.py | 36 +++++---- tests/helpers/test_writer.py | 80 +++++++++++++++++++ .../google_drive/__init__.py | 0 .../google_drive/conftest.py | 20 ++--- .../google_drive/test_google_drive_buckets.py | 4 +- .../google_drive/test_google_drive_files.py | 6 +- .../test_google_drive_permissions.py | 4 +- 9 files changed, 119 insertions(+), 61 deletions(-) create mode 100644 tests/helpers/test_writer.py rename tests/{services => providers}/google_drive/__init__.py (100%) rename tests/{services => providers}/google_drive/conftest.py (63%) rename tests/{services => providers}/google_drive/test_google_drive_buckets.py (90%) rename tests/{services => providers}/google_drive/test_google_drive_files.py (95%) rename tests/{services => providers}/google_drive/test_google_drive_permissions.py (88%) diff --git a/src/docbinder_oss/cli/search.py b/src/docbinder_oss/cli/search.py index 036e812..b6ab969 100644 --- a/src/docbinder_oss/cli/search.py +++ b/src/docbinder_oss/cli/search.py @@ -192,26 +192,3 @@ def __write_csv(files_by_provider, filename): if isinstance(parents, list): file_dict["parents"] = ";".join(str(p) for p in parents) writer.writerow({fn: file_dict.get(fn, "") for fn in fieldnames}) - - -def __write_json(files_by_provider, filename, flat=False): - with open(filename, "w") as jsonfile: - if flat: - all_files = [] - for provider, files in files_by_provider.items(): - for file in files: - file_dict = ( - file.model_dump() if hasattr(file, "model_dump") else file.__dict__.copy() - ) - file_dict["provider"] = provider - all_files.append(file_dict) - json.dump(all_files, jsonfile, default=str, indent=2) - else: - grouped = { - provider: [ - file.model_dump() if hasattr(file, "model_dump") else file.__dict__.copy() - for file in files - ] - for provider, files in files_by_provider.items() - } - json.dump(grouped, jsonfile, default=str, indent=2) diff --git a/src/docbinder_oss/core/schemas.py b/src/docbinder_oss/core/schemas.py index 1d8b72b..354a61a 100644 --- a/src/docbinder_oss/core/schemas.py +++ b/src/docbinder_oss/core/schemas.py @@ -62,17 +62,10 @@ class File(BaseModel): size: Optional[str] = Field(description="Size in bytes, as a string. Only populated for files.") parents: Optional[List[str]] = Field(description="Parent folder IDs, if applicable.") - capabilities: Optional[FileCapabilities] = None - shared: Optional[bool] starred: Optional[bool] trashed: Optional[bool] - # Add full_path as an optional field for export/CLI assignment - full_path: Optional[str] = Field( - default=None, description="Full path of the file/folder, computed at runtime." - ) - def __init__(self, **data: Any): # Coerce parents to a list of strings or None if "parents" in data: diff --git a/src/docbinder_oss/helpers/writer.py b/src/docbinder_oss/helpers/writer.py index 0363bea..eddf4d5 100644 --- a/src/docbinder_oss/helpers/writer.py +++ b/src/docbinder_oss/helpers/writer.py @@ -3,8 +3,13 @@ from abc import ABC, abstractmethod from pathlib import Path from typing import Any, Dict, List, Union +from pydantic import BaseModel from rich import print -from rich.panel import Panel + +import logging + + +logger = logging.getLogger(__name__) class Writer(ABC): @@ -22,7 +27,6 @@ class MultiFormatWriter: _writers = { '.csv': 'CSVWriter', '.json': 'JSONWriter', - '.txt': 'TextWriter', } @classmethod @@ -44,18 +48,31 @@ def write(cls, data: Any, file_path: Union[None, str, Path]) -> None: class CSVWriter(Writer): + def get_fieldnames(self, data: Dict[str, List[BaseModel]]) -> List[str]: + fieldnames = next(iter(data.values()))[0].model_fields_set + return ["provider", *fieldnames] + def write(self, data: List[Dict], file_path: Union[str, Path]) -> None: if not data: + logger.warning("No data to write to CSV.") return with open(file_path, 'w', newline='', encoding='utf-8') as f: - writer = csv.DictWriter(f, fieldnames=data[0].keys()) + writer = csv.DictWriter(f, fieldnames=self.get_fieldnames(data)) writer.writeheader() - writer.writerows(data) + for provider, items in data.items(): + for item in items: + item_dict = item.model_dump() if isinstance(item, BaseModel) else item + item_dict['provider'] = provider + writer.writerow(item_dict) class JSONWriter(Writer): - def write(self, data: Any, file_path: Union[str, Path]) -> None: + def write(self, data: Dict[str, List[BaseModel]], file_path: Union[str, Path]) -> None: + data = { + provider: [item.model_dump() for item in items] + for provider, items in data.items() + } with open(file_path, 'w', encoding='utf-8') as f: json.dump(data, f, indent=2, ensure_ascii=False, default=str) @@ -73,12 +90,3 @@ def write(self, data: Dict) -> None: for item in items: table.add_row(provider, item.id, item.name, item.kind) print(table) - - -class TextWriter(Writer): - def write(self, data: Any, file_path: Union[str, Path]) -> None: - with open(file_path, 'w', encoding='utf-8') as f: - if isinstance(data, (list, dict)): - f.write(json.dumps(data, indent=2, default=str)) - else: - f.write(str(data)) \ No newline at end of file diff --git a/tests/helpers/test_writer.py b/tests/helpers/test_writer.py new file mode 100644 index 0000000..d3cf8ce --- /dev/null +++ b/tests/helpers/test_writer.py @@ -0,0 +1,80 @@ +import json +import csv +import pytest +from pydantic import BaseModel + +from docbinder_oss.helpers.writer import ( + MultiFormatWriter, + CSVWriter, + JSONWriter, +) + +class DummyModel(BaseModel): + id: str + name: str + kind: str + +@pytest.fixture +def sample_data(): + return { + "provider1": [ + DummyModel(id="1", name="FileA", kind="file"), + DummyModel(id="2", name="FolderB", kind="folder"), + ], + "provider2": [ + DummyModel(id="3", name="FileC", kind="file"), + ], + } + +def test_csv_writer(tmp_path, sample_data): + file_path = tmp_path / "output.csv" + writer = CSVWriter() + writer.write(sample_data, file_path) + assert file_path.exists() + with open(file_path, newline='', encoding='utf-8') as f: + reader = csv.DictReader(f) + rows = list(reader) + assert len(rows) == 3 + assert set(rows[0].keys()) == {"provider", "id", "name", "kind"} + assert rows[0]["provider"] == "provider1" + +def test_json_writer(tmp_path, sample_data): + file_path = tmp_path / "output.json" + writer = JSONWriter() + writer.write(sample_data, file_path) + assert file_path.exists() + with open(file_path, encoding='utf-8') as f: + data = json.load(f) + assert "provider1" in data + assert isinstance(data["provider1"], list) + assert data["provider1"][0]["id"] == "1" + + +def test_multiformat_writer_csv(tmp_path, sample_data): + file_path = tmp_path / "test.csv" + MultiFormatWriter.write(sample_data, file_path) + assert file_path.exists() + with open(file_path, newline='', encoding='utf-8') as f: + reader = csv.DictReader(f) + rows = list(reader) + assert len(rows) == 3 + +def test_multiformat_writer_json(tmp_path, sample_data): + file_path = tmp_path / "test.json" + MultiFormatWriter.write(sample_data, file_path) + assert file_path.exists() + with open(file_path, encoding='utf-8') as f: + data = json.load(f) + assert "provider2" in data + +def test_multiformat_writer_unsupported(tmp_path, sample_data): + file_path = tmp_path / "test.unsupported" + with pytest.raises(ValueError): + MultiFormatWriter.write(sample_data, file_path) + +def test_csv_writer_empty_data(tmp_path, caplog): + file_path = tmp_path / "empty.csv" + writer = CSVWriter() + with caplog.at_level("WARNING"): + writer.write({}, file_path) + assert "No data to write to CSV." in caplog.text diff --git a/tests/services/google_drive/__init__.py b/tests/providers/google_drive/__init__.py similarity index 100% rename from tests/services/google_drive/__init__.py rename to tests/providers/google_drive/__init__.py diff --git a/tests/services/google_drive/conftest.py b/tests/providers/google_drive/conftest.py similarity index 63% rename from tests/services/google_drive/conftest.py rename to tests/providers/google_drive/conftest.py index 8f3fe03..b248aac 100644 --- a/tests/services/google_drive/conftest.py +++ b/tests/providers/google_drive/conftest.py @@ -11,7 +11,7 @@ @pytest.fixture -def mock_gdrive_service(): +def mock_gdrive_provider(): """ This is the core of our testing strategy. We use 'patch' to replace the `build` function from the googleapiclient library. @@ -19,24 +19,24 @@ def mock_gdrive_service(): Whenever `GoogleDriveClient` calls `build('drive', 'v3', ...)`, it will receive our mock object instead of making a real network call. """ - with patch("docbinder_oss.services.google_drive.google_drive_client.build") as mock_build: - # Create a mock for the service object that `build` would return - mock_service = MagicMock() - # Configure the `build` function to return our mock service - mock_build.return_value = mock_service - yield mock_service + with patch("docbinder_oss.providers.google_drive.google_drive_client.build") as mock_build: + # Create a mock for the provider object that `build` would return + mock_provider = MagicMock() + # Configure the `build` function to return our mock provider + mock_build.return_value = mock_provider + yield mock_provider @pytest.fixture -def gdrive_client(mock_gdrive_service): +def gdrive_client(mock_gdrive_provider): """ Creates an instance of our GoogleDriveClient. It will be initialized with a fake config and will use - the mock_gdrive_service fixture internally. + the mock_gdrive_provider fixture internally. """ # Patch _get_credentials to avoid real auth with patch( - "docbinder_oss.services.google_drive.google_drive_client.GoogleDriveClient._get_credentials", + "docbinder_oss.providers.google_drive.google_drive_client.GoogleDriveClient._get_credentials", return_value=MagicMock(), ): config = GoogleDriveServiceConfig( diff --git a/tests/services/google_drive/test_google_drive_buckets.py b/tests/providers/google_drive/test_google_drive_buckets.py similarity index 90% rename from tests/services/google_drive/test_google_drive_buckets.py rename to tests/providers/google_drive/test_google_drive_buckets.py index 44e3bd5..a4a91c3 100644 --- a/tests/services/google_drive/test_google_drive_buckets.py +++ b/tests/providers/google_drive/test_google_drive_buckets.py @@ -3,7 +3,7 @@ from docbinder_oss.core.schemas import Bucket -def test_list_buckets(mock_gdrive_service, gdrive_client): +def test_list_buckets(mock_gdrive_provider, gdrive_client): fake_api_response = { "drives": [ { @@ -21,7 +21,7 @@ def test_list_buckets(mock_gdrive_service, gdrive_client): } ] } - mock_gdrive_service.drives.return_value.list.return_value.execute.return_value = ( + mock_gdrive_provider.drives.return_value.list.return_value.execute.return_value = ( fake_api_response ) diff --git a/tests/services/google_drive/test_google_drive_files.py b/tests/providers/google_drive/test_google_drive_files.py similarity index 95% rename from tests/services/google_drive/test_google_drive_files.py rename to tests/providers/google_drive/test_google_drive_files.py index 4ed40b2..432af3a 100644 --- a/tests/services/google_drive/test_google_drive_files.py +++ b/tests/providers/google_drive/test_google_drive_files.py @@ -53,7 +53,7 @@ def list_all_files(self): return list_all_files(self) monkeypatch.setattr( - "docbinder_oss.services.create_provider_instance", lambda cfg: DummyClient() + "docbinder_oss.providers.create_provider_instance", lambda cfg: DummyClient() ) orig_cwd = os.getcwd() os.chdir(tmp_path) @@ -61,7 +61,7 @@ def list_all_files(self): os.chdir(orig_cwd) -def test_list_files(mock_gdrive_service, gdrive_client): +def test_list_files(mock_gdrive_provider, gdrive_client): fake_api_response = { "files": [ { @@ -97,7 +97,7 @@ def test_list_files(mock_gdrive_service, gdrive_client): ] } - mock_gdrive_service.files.return_value.list.return_value.execute.return_value = ( + mock_gdrive_provider.files.return_value.list.return_value.execute.return_value = ( fake_api_response ) diff --git a/tests/services/google_drive/test_google_drive_permissions.py b/tests/providers/google_drive/test_google_drive_permissions.py similarity index 88% rename from tests/services/google_drive/test_google_drive_permissions.py rename to tests/providers/google_drive/test_google_drive_permissions.py index ddc0b8c..e4b14f6 100644 --- a/tests/services/google_drive/test_google_drive_permissions.py +++ b/tests/providers/google_drive/test_google_drive_permissions.py @@ -1,7 +1,7 @@ from docbinder_oss.core.schemas import Permission, User -def test_get_permissions(mock_gdrive_service, gdrive_client): +def test_get_permissions(mock_gdrive_provider, gdrive_client): fake_api_response = { "permissions": [ { @@ -18,7 +18,7 @@ def test_get_permissions(mock_gdrive_service, gdrive_client): } ] } - mock_gdrive_service.permissions.return_value.list.return_value.execute.return_value = ( + mock_gdrive_provider.permissions.return_value.list.return_value.execute.return_value = ( fake_api_response )