diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..2284346 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,69 @@ +name: Deploy Documentation + +on: + push: + branches: + - main + - staging + - develop + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 # Fetch all history for mike versioning + + - name: Configure Git Credentials + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.x' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[docs]" + + - name: Deploy documentation (main branch) + if: github.ref == 'refs/heads/main' + run: | + mike deploy --push --update-aliases latest stable + mike set-default --push latest + + - name: Deploy documentation (staging branch) + if: github.ref == 'refs/heads/staging' + run: | + mike deploy --push staging + + - name: Deploy documentation (develop branch) + if: github.ref == 'refs/heads/develop' + run: | + mike deploy --push dev + + - name: Set default alias when missing (develop) + if: github.ref == 'refs/heads/develop' + run: | + DEFAULT_ALIAS=$(mike list --remote 2>/dev/null | awk '/^\*/ {print $2}') + if [ -z "$DEFAULT_ALIAS" ]; then + mike set-default --push dev + fi + + - name: Deploy documentation (version tag) + if: startsWith(github.ref, 'refs/tags/v') + run: | + VERSION=${GITHUB_REF#refs/tags/v} + mike deploy --push --update-aliases $VERSION stable + mike set-default --push $VERSION diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..61f266a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,43 @@ +name: Publish + +on: + push: + branches: + - main + - staging + workflow_dispatch: + +jobs: + build-and-publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: Build distributions + run: | + rm -rf dist + uv build + + - name: Publish to TestPyPI + if: github.ref == 'refs/heads/staging' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.TEST_PYPI_API_TOKEN }} + repository-url: https://test.pypi.org/legacy/ + + - name: Publish to PyPI + if: github.ref == 'refs/heads/main' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ee60886 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,62 @@ +name: Tests + +on: + push: + branches: [ main, develop, staging ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.14", "3.12", "3.13", "3.9", "3.10", "3.11" ] + + steps: + - uses: actions/checkout@v5 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Lint with flake8 + run: | + # Stop the build if there are Python syntax errors or undefined names + flake8 src/tmo_api --count --select=E9,F63,F7,F82 --show-source --statistics + # Exit-zero treats all errors as warnings + flake8 src/tmo_api --count --exit-zero --max-line-length=88 --extend-ignore=E203,W503 --statistics + + - name: Check formatting with black + run: | + black --check src/tmo_api tests/ + + - name: Check import sorting with isort + run: | + isort --check-only src/tmo_api tests/ + + - name: Type check with mypy + run: | + mypy src/tmo_api --ignore-missing-imports + continue-on-error: true + + - name: Run tests with pytest + run: | + pytest tests/ -v --cov=tmo_api --cov-report=xml --cov-report=term + + - name: Upload coverage to Codecov + if: matrix.python-version == '3.11' + uses: codecov/codecov-action@v5 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + file: ./coverage.xml + fail_ci_if_error: false diff --git a/docs/api-reference/client.md b/docs/api-reference/client.md new file mode 100644 index 0000000..f894f52 --- /dev/null +++ b/docs/api-reference/client.md @@ -0,0 +1,58 @@ +# Client API Reference + +Complete API reference for the `TMOClient` class. + +## TMOClient + +```python +class TMOClient: + def __init__( + self, + token: str, + database: str, + environment: Union[Environment, str] = Environment.US, + timeout: int = 30, + debug: bool = False + ) +``` + +### Parameters + +- **token** (`str`): Your API token from The Mortgage Office +- **database** (`str`): Your database name +- **environment** (`Union[Environment, str]`): API environment or custom URL (default: `Environment.US`) +- **timeout** (`int`): Request timeout in seconds (default: 30) +- **debug** (`bool`): Enable debug logging (default: False) + +### Shares Resource Attributes + +- **shares_pools**: `PoolsResource` - Shares pool operations +- **shares_partners**: `PartnersResource` - Shares partner operations +- **shares_distributions**: `DistributionsResource` - Shares distribution operations +- **shares_certificates**: `CertificatesResource` - Shares certificate operations +- **shares_history**: `HistoryResource` - Shares history operations + +### Capital Resource Attributes + +- **capital_pools**: `PoolsResource` - Capital pool operations +- **capital_partners**: `PartnersResource` - Capital partner operations +- **capital_distributions**: `DistributionsResource` - Capital distribution operations +- **capital_history**: `HistoryResource` - Capital history operations + +### Methods + +#### get(endpoint: str, params: Optional[Dict[str, Any]] = None) → Dict[str, Any] + +Make a GET request. + +#### post(endpoint: str, json: Optional[Dict[str, Any]] = None) → Dict[str, Any] + +Make a POST request. + +#### put(endpoint: str, json: Optional[Dict[str, Any]] = None) → Dict[str, Any] + +Make a PUT request. + +#### delete(endpoint: str) → Dict[str, Any] + +Make a DELETE request. diff --git a/docs/api-reference/exceptions.md b/docs/api-reference/exceptions.md new file mode 100644 index 0000000..5db0001 --- /dev/null +++ b/docs/api-reference/exceptions.md @@ -0,0 +1,123 @@ +# Exceptions API Reference + +## TMOException + +Base exception for all TMO API errors. + +```python +class TMOException(Exception): + def __init__( + self, + message: str, + error_number: Optional[int] = None + ) +``` + +**Attributes:** +- `message` (str): The error message +- `error_number` (Optional[int]): TMO API error number if available + +## AuthenticationError + +Raised for authentication failures (401/403). + +```python +class AuthenticationError(TMOException): + pass +``` + +**Usage:** +```python +from tmo_api import TMOClient, AuthenticationError + +try: + client = TMOClient(token="invalid", database="test") + pools = client.shares_pools.list_all() +except AuthenticationError as e: + print(f"Authentication failed: {e.message}") + if e.error_number: + print(f"Error number: {e.error_number}") +``` + +## APIError + +Raised when the API returns an error response. + +```python +class APIError(TMOException): + pass +``` + +**Usage:** +```python +from tmo_api import TMOClient, APIError + +try: + client = TMOClient(token="token", database="db") + pool = client.shares_pools.get_pool("INVALID") +except APIError as e: + print(f"API error: {e.message}") + if e.error_number: + print(f"Error number: {e.error_number}") +``` + +## ValidationError + +Raised for client-side validation errors before making API calls. + +```python +class ValidationError(TMOException): + pass +``` + +**Usage:** +```python +from tmo_api import TMOClient, ValidationError + +try: + client = TMOClient(token="token", database="db") + pool = client.shares_pools.get_pool("") # Empty account +except ValidationError as e: + print(f"Validation error: {e.message}") +``` + +## NetworkError + +Raised for network/connection errors (timeouts, connection failures). + +```python +class NetworkError(TMOException): + pass +``` + +**Usage:** +```python +from tmo_api import TMOClient, NetworkError + +try: + client = TMOClient(token="token", database="db", timeout=1) + pools = client.shares_pools.list_all() +except NetworkError as e: + print(f"Network error: {e.message}") +``` + +## Catching All Exceptions + +You can catch all TMO API exceptions using the base class: + +```python +import os +from tmo_api import TMOClient, TMOException + +client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"] +) + +try: + pools = client.shares_pools.list_all() +except TMOException as e: + print(f"TMO API error: {e.message}") + if hasattr(e, 'error_number') and e.error_number: + print(f"Error number: {e.error_number}") +``` diff --git a/docs/api-reference/models.md b/docs/api-reference/models.md new file mode 100644 index 0000000..0634c95 --- /dev/null +++ b/docs/api-reference/models.md @@ -0,0 +1,73 @@ +# Models API Reference + +## BaseModel + +Base class for all data models. + +```python +class BaseModel: + rec_id: Optional[int] +``` + +## Pool + +Represents a mortgage pool. + +```python +class Pool(BaseModel): + Account: Optional[str] + Name: Optional[str] + InceptionDate: Optional[datetime] + LastEvaluation: Optional[datetime] + SysTimeStamp: Optional[datetime] + OtherAssets: List[OtherAsset] + OtherLiabilities: List[OtherLiability] +``` + +## OtherAsset + +Represents an asset in a pool. + +```python +class OtherAsset(BaseModel): + Description: Optional[str] + Value: Optional[float] + DateLastEvaluated: Optional[datetime] +``` + +## OtherLiability + +Represents a liability in a pool. + +```python +class OtherLiability(BaseModel): + Description: Optional[str] + Amount: Optional[float] + MaturityDate: Optional[datetime] + PaymentNextDue: Optional[datetime] +``` + +## Response Models + +### BaseResponse + +```python +class BaseResponse: + status: Optional[int] + message: Optional[str] + data: Any +``` + +### PoolResponse + +```python +class PoolResponse(BaseResponse): + pool: Optional[Pool] +``` + +### PoolsResponse + +```python +class PoolsResponse(BaseResponse): + pools: List[Pool] +``` diff --git a/docs/api-reference/resources.md b/docs/api-reference/resources.md new file mode 100644 index 0000000..7b82a56 --- /dev/null +++ b/docs/api-reference/resources.md @@ -0,0 +1,65 @@ +# Resources API Reference + +## PoolsResource + +```python +class PoolsResource: + def get_pool(self, account: str) -> Pool + def list_all(self) -> List[Pool] + def get_pool_partners(self, account: str) -> list + def get_pool_loans(self, account: str) -> list + def get_pool_bank_accounts(self, account: str) -> list + def get_pool_attachments(self, account: str) -> list +``` + +## PartnersResource + +```python +class PartnersResource: + def get_partner(self, account: str) -> dict + def get_partner_attachments(self, account: str) -> list + def list_all( + self, + start_date: Optional[str] = None, + end_date: Optional[str] = None + ) -> list +``` + +## DistributionsResource + +```python +class DistributionsResource: + def get_distribution(self, rec_id: str) -> dict + def list_all( + self, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + pool_account: Optional[str] = None + ) -> list +``` + +## CertificatesResource + +```python +class CertificatesResource: + def get_certificates( + self, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + partner_account: Optional[str] = None, + pool_account: Optional[str] = None + ) -> list +``` + +## HistoryResource + +```python +class HistoryResource: + def get_history( + self, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + partner_account: Optional[str] = None, + pool_account: Optional[str] = None + ) -> list +``` diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..fc9067f --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.0.1] - 2024-11-06 + +### Added +- Initial release +- Support for Pools, Partners, Distributions, Certificates, and History resources +- Pool data models with date parsing +- Comprehensive test suite (92% coverage) +- Type hints with mypy support +- Multi-environment support (US, Canada, Australia) + +[0.0.1]: https://github.com/inntran/tmo-api-python/releases/tag/v0.0.1 diff --git a/docs/contributing/code-style.md b/docs/contributing/code-style.md new file mode 100644 index 0000000..2f3a165 --- /dev/null +++ b/docs/contributing/code-style.md @@ -0,0 +1,45 @@ +# Code Style + +## Formatting + +- Use **black** for code formatting (line length: 100) +- Use **isort** for import sorting +- Follow PEP 8 guidelines + +## Type Hints + +All functions should have type hints: + +```python +def get_pool(self, account: str) -> Pool: + pass +``` + +## Documentation + +Use Google-style docstrings: + +```python +def get_pool(self, account: str) -> Pool: + """Get pool details by account. + + Args: + account: The pool account identifier + + Returns: + Pool object with detailed information + + Raises: + ValidationError: If account is invalid + """ +``` + +## Commit Messages + +Follow conventional commits: + +- `feat:` New features +- `fix:` Bug fixes +- `docs:` Documentation changes +- `test:` Test additions/changes +- `refactor:` Code refactoring diff --git a/docs/contributing/development.md b/docs/contributing/development.md new file mode 100644 index 0000000..4f97c54 --- /dev/null +++ b/docs/contributing/development.md @@ -0,0 +1,52 @@ +# Development Setup + +## Prerequisites + +- Python 3.9 or higher +- Git +- pip + +## Setup + +Clone the repository: + +```bash +git clone https://github.com/inntran/tmo-api-python.git +cd tmo-api-python +``` + +Install development dependencies: + +```bash +pip install -e ".[dev]" +``` + +## Running Tests + +```bash +pytest tests/ -v +``` + +With coverage: + +```bash +pytest tests/ --cov=tmo_api --cov-report=term +``` + +## Code Quality + +Run all checks: + +```bash +# Format code +black src/tmo_api tests/ + +# Sort imports +isort src/tmo_api tests/ + +# Lint +flake8 src/tmo_api + +# Type check +mypy src/tmo_api +``` diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md new file mode 100644 index 0000000..27ea2ec --- /dev/null +++ b/docs/contributing/testing.md @@ -0,0 +1,48 @@ +# Testing + +The SDK uses pytest for testing with 92% code coverage. + +## Running Tests + +```bash +# All tests +pytest + +# Specific test file +pytest tests/test_client.py + +# Specific test +pytest tests/test_client.py::TestClientInitialization::test_client_init_with_defaults + +# With verbose output +pytest -v + +# With coverage +pytest --cov=tmo_api --cov-report=term +``` + +## Writing Tests + +Tests use pytest fixtures and mocking: + +```python +import os +import pytest +from unittest.mock import patch, MagicMock +from tmo_api import TMOClient + +@pytest.fixture +def client(): + """Create a test client using environment variables.""" + return TMOClient( + token=os.environ.get("TMO_API_TOKEN", "test-token"), + database=os.environ.get("TMO_DATABASE", "test-db") + ) + +def test_example(client): + with patch('requests.Session.request') as mock_request: + mock_request.return_value.json.return_value = {"Status": 0, "Data": []} + mock_request.return_value.raise_for_status.return_value = None + result = client.shares_pools.list_all() + assert isinstance(result, list) +``` diff --git a/docs/getting-started/authentication.md b/docs/getting-started/authentication.md new file mode 100644 index 0000000..30b0555 --- /dev/null +++ b/docs/getting-started/authentication.md @@ -0,0 +1,234 @@ +# Authentication + +The TMO API uses token-based authentication with a database identifier. This guide explains how to obtain and use your credentials with the SDK. + +## Obtaining Credentials + +To use the TMO API, you need to obtain credentials from The Mortgage Office: + +1. Contact The Mortgage Office support +2. Request API access for your organization +3. Receive your API token and database name + +!!! warning "Keep Your Credentials Secure" + Your API token grants access to your TMO data. Never commit it to version control or share it publicly. + +## Using Credentials + +### Basic Authentication + +Pass your credentials when initializing the client: + +```python +from tmo_api import TMOClient + +client = TMOClient( + token="your-api-token", + database="your-database-name" +) +``` + +### Environment Variables + +For better security, store your credentials in environment variables: + +```bash +export TMO_API_TOKEN="your-api-token" +export TMO_DATABASE="your-database-name" +``` + +Then load them in your code: + +```python +import os +from tmo_api import TMOClient + +token = os.environ.get("TMO_API_TOKEN") +database = os.environ.get("TMO_DATABASE") + +client = TMOClient(token=token, database=database) +``` + +### Configuration File + +You can also store your configuration in a file: + +```python +import configparser +from tmo_api import TMOClient, Environment + +# Load configuration +config = configparser.ConfigParser() +config.read('config.ini') + +token = config['tmo']['token'] +database = config['tmo']['database'] +environment = config['tmo']['environment'] + +# Initialize client +client = TMOClient( + token=token, + database=database, + environment=Environment[environment.upper()] +) +``` + +Example `config.ini`: + +```ini +[tmo] +token = your-api-token +database = your-database-name +environment = US +``` + +!!! danger "Never Commit Secrets" + Add `config.ini` to your `.gitignore` to prevent accidentally committing your credentials. + +## Environments + +The TMO API has different endpoints for different regions: + +```python +from tmo_api import Environment + +# United States (default) +Environment.US + +# Canada +Environment.CANADA + +# Australia +Environment.AUSTRALIA +``` + +Specify the environment when creating the client: + +```python +from tmo_api import TMOClient, Environment + +client = TMOClient( + token="your-token", + database="your-database", + environment=Environment.CANADA +) +``` + +## Custom Base URL + +If you need to use a custom API endpoint (string instead of Environment enum): + +```python +client = TMOClient( + token="your-token", + database="your-database", + environment="https://custom.api.endpoint.com" +) +``` + +## Authentication Errors + +The SDK will raise an `AuthenticationError` if authentication fails: + +```python +from tmo_api import TMOClient, AuthenticationError + +try: + client = TMOClient(token="invalid-token", database="invalid-db") + pools = client.shares_pools.list_all() +except AuthenticationError as e: + print(f"Authentication failed: {e}") + if hasattr(e, 'error_number'): + print(f"Error number: {e.error_number}") +``` + +Common authentication errors: + +- **401 Unauthorized** - Invalid token or database name +- **403 Forbidden** - Token doesn't have required permissions + +## Best Practices + +### 1. Use Environment Variables + +```python +import os +from tmo_api import TMOClient + +client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"] +) +``` + +### 2. Validate Credentials at Startup + +```python +def validate_credentials(): + try: + client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"] + ) + # Make a simple API call to validate + client.shares_pools.list_all() + return True + except AuthenticationError: + return False + +if not validate_credentials(): + print("Error: Invalid credentials") + exit(1) +``` + +### 3. Use Separate Credentials for Environments + +```python +# Development +dev_client = TMOClient( + token=os.environ["TMO_DEV_TOKEN"], + database=os.environ["TMO_DEV_DATABASE"], + debug=True +) + +# Production +prod_client = TMOClient( + token=os.environ["TMO_PROD_TOKEN"], + database=os.environ["TMO_PROD_DATABASE"], + debug=False +) +``` + +## Troubleshooting + +### "Invalid Token" Error + +- Verify the token is correct +- Check that there are no extra spaces or newlines +- Ensure you're using the correct environment + +### "Invalid Database" Error + +- Verify the database name is correct +- Check for typos in the database name +- Contact TMO support to verify your database name + +### Environment Variable Not Found + +```python +import os +from tmo_api import TMOClient + +token = os.environ.get("TMO_API_TOKEN") +database = os.environ.get("TMO_DATABASE") + +if not token or not database: + raise ValueError("TMO_API_TOKEN and TMO_DATABASE environment variables must be set") + +client = TMOClient(token=token, database=database) +``` + +## Next Steps + +- [Client Configuration](../user-guide/client.md) - Advanced client options +- [Error Handling](../api-reference/exceptions.md) - Handle authentication errors diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 0000000..a30ab42 --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,79 @@ +# Installation + +This guide will help you install the TMO API Python SDK. + +## Requirements + +- Python 3.9 or higher +- pip (Python package installer) + +## Install from PyPI + +The recommended way to install the SDK is via pip: + +```bash +pip install tmo-api +``` + +## Install from Source + +If you want to install from source or contribute to the project: + +```bash +# Clone the repository +git clone https://github.com/inntran/tmo-api-python.git +cd tmo-api-python + +# Install in development mode +pip install -e ".[dev]" +``` + +## Verify Installation + +To verify that the SDK is installed correctly: + +```python +import tmo_api +print(tmo_api.__version__) +``` + +You should see the version number printed without any errors. + +## Optional Dependencies + +### Development Dependencies + +If you want to contribute to the project, install the development dependencies: + +```bash +pip install tmo-api[dev] +``` + +This includes: + +- pytest - Testing framework +- black - Code formatter +- flake8 - Linter +- isort - Import sorter +- mypy - Type checker + +### Documentation Dependencies + +To build the documentation locally: + +```bash +pip install tmo-api[docs] +``` + +This includes: + +- mkdocs - Documentation generator +- mkdocs-material - Material theme +- mike - Version management + +## Next Steps + +Now that you have the SDK installed, proceed to: + +- [Quick Start](quickstart.md) - Make your first API call +- [Authentication](authentication.md) - Learn about authentication diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md new file mode 100644 index 0000000..32f4611 --- /dev/null +++ b/docs/getting-started/quickstart.md @@ -0,0 +1,192 @@ +# Quick Start + +This guide will help you make your first API call with the TMO API Python SDK. + +## Prerequisites + +Before you begin, make sure you have: + +1. Installed the SDK (see [Installation](installation.md)) +2. Obtained an API token and database name from The Mortgage Office +3. Know which environment to use (US, Canada, or Australia) + +## Basic Usage + +### Initialize the Client + +First, import and initialize the client using environment variables (recommended for CI/CD): + +```python +import os +from tmo_api import TMOClient, Environment + +client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"], + environment=Environment.US # or Environment.CANADA, Environment.AUSTRALIA +) +``` + +!!! tip "Environment Variables" + Using environment variables is recommended for production and CI/CD environments: + ```bash + export TMO_API_TOKEN="your-api-token" + export TMO_DATABASE="your-database-name" + ``` + +### Get a Pool + +Retrieve information about a specific mortgage pool: + +```python +# Get a shares pool by account number +pool = client.shares_pools.get_pool("POOL001") + +print(f"Pool Name: {pool.Name}") +print(f"Account: {pool.Account}") +print(f"Inception Date: {pool.InceptionDate}") +``` + +### List All Pools + +Get a list of all available pools: + +```python +# List all shares pools +pools = client.shares_pools.list_all() + +for pool in pools: + print(f"{pool.Account}: {pool.Name}") + +# For capital pools +capital_pools = client.capital_pools.list_all() +``` + +### Get Partner Information + +Retrieve partner account details: + +```python +# Get a partner by account number (shares) +partner = client.shares_partners.get_partner("PART001") + +print(f"Partner Name: {partner.get('Name')}") +print(f"Account: {partner.get('Account')}") +``` + +### Query Distributions + +Get distribution records with optional filtering: + +```python +# Get all distributions (shares) +distributions = client.shares_distributions.list_all() + +# Filter by date range +distributions = client.shares_distributions.list_all( + start_date="01/01/2024", + end_date="12/31/2024" +) + +# Filter by pool account +distributions = client.shares_distributions.list_all( + pool_account="POOL001" +) +``` + +## Pool Types + +The SDK supports both Shares and Capital pool types: + +```python +# Shares resources (most common) +client.shares_pools +client.shares_partners +client.shares_distributions +client.shares_certificates +client.shares_history + +# Capital resources +client.capital_pools +client.capital_partners +client.capital_distributions +client.capital_history +``` + +## Error Handling + +The SDK raises specific exceptions for different error types: + +```python +from tmo_api import TMOClient, AuthenticationError, APIError, ValidationError + +client = TMOClient(token="your-token", database="your-db") + +try: + pool = client.shares_pools.get_pool("POOL001") +except AuthenticationError as e: + print(f"Authentication failed: {e}") +except ValidationError as e: + print(f"Invalid input: {e}") +except APIError as e: + print(f"API error: {e}") +``` + +## Complete Example + +Here's a complete example that demonstrates various features: + +```python +import os +from tmo_api import TMOClient, Environment, TMOException + +def main(): + # Initialize the client with environment variables + client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"], + environment=Environment.US, + timeout=30, # Optional: custom timeout in seconds + debug=True # Optional: enable debug logging + ) + + try: + # Get all pools + print("Fetching all pools...") + pools = client.shares_pools.list_all() + print(f"Found {len(pools)} pools") + + # Get detailed information for the first pool + if pools: + first_pool = pools[0] + print(f"\nPool Details:") + print(f" Name: {first_pool.Name}") + print(f" Account: {first_pool.Account}") + + # Get partners for this pool + partners = client.shares_pools.get_pool_partners(first_pool.Account) + print(f" Partners: {len(partners)}") + + # Get distributions for this pool + distributions = client.shares_distributions.list_all( + pool_account=first_pool.Account + ) + print(f" Distributions: {len(distributions)}") + + except TMOException as e: + print(f"Error: {e}") + if hasattr(e, 'error_number'): + print(f"Error Number: {e.error_number}") + +if __name__ == "__main__": + main() +``` + +## Next Steps + +Now that you've made your first API call, explore more features: + +- [Authentication](authentication.md) - Learn about authentication +- [Client](../user-guide/client.md) - Client configuration options +- [Pools](../user-guide/pools.md) - Deep dive into pool operations +- [Error Handling](../api-reference/exceptions.md) - Comprehensive error handling guide diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..24a1b48 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,76 @@ +# TMO API Python SDK + +Welcome to the **TMO API Python SDK** documentation. This SDK provides a clean, Pythonic interface for accessing [The Mortgage Office API](https://www.themortgageoffice.com/). + +## About This Project + +**TMO API Python SDK** is an independent, community-maintained wrapper for The Mortgage Office API. It provides a simple and intuitive way to interact with TMO's JSON-based web services. + +!!! warning "Independent Project" + This SDK is **not affiliated with or endorsed by Applied Business Software, Inc. (The Mortgage Office)**. + +## Features + +- 🚀 **Easy to use** - Simple, intuitive API design +- 🔒 **Type-safe** - Full type hints support with mypy +- 🌍 **Multi-region** - Support for US, Canada, and Australia environments +- 📦 **Comprehensive** - Complete coverage of TMO API endpoints +- ✅ **Well-tested** - 92% test coverage with 111+ tests +- 📚 **Well-documented** - Extensive documentation and examples + +## Supported Resources + +The SDK provides access to the following TMO API resources: + +- **Pools** - Access and manage mortgage pool information (Shares/Capital) +- **Partners** - Retrieve partner account details +- **Distributions** - Query distribution records +- **Certificates** - Access certificate information +- **History** - Retrieve account history + +## Quick Example + +```python +import os +from tmo_api import TMOClient, Environment + +# Initialize the client with environment variables +client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"], + environment=Environment.US +) + +# Get a shares pool by account +pool = client.shares_pools.get_pool("POOL001") +print(f"Pool: {pool.Name}") + +# List all shares pools +pools = client.shares_pools.list_all() +print(f"Found {len(pools)} pools") +``` + +## Getting Started + +Ready to get started? Check out the following guides: + +- [Installation](getting-started/installation.md) - Install the SDK +- [Quick Start](getting-started/quickstart.md) - Your first API call +- [Authentication](getting-started/authentication.md) - How to authenticate + +## Support + +- 📖 [Documentation](https://inntran.github.io/tmo-api-python/) +- 🐛 [Issue Tracker](https://github.com/inntran/tmo-api-python/issues) +- 💻 [Source Code](https://github.com/inntran/tmo-api-python) + +## License + +This project is licensed under the **Apache License 2.0**. See the [LICENSE](https://github.com/inntran/tmo-api-python/blob/main/LICENSE) file for details. + +## Contact + +For sponsorship, commercial inquiries, or dedicated support: + +- 📧 Yinchuan Song - [songyinchuan@gmail.com](mailto:songyinchuan@gmail.com) +- 💼 GitHub - [https://github.com/inntran](https://github.com/inntran) diff --git a/docs/user-guide/certificates.md b/docs/user-guide/certificates.md new file mode 100644 index 0000000..ef73326 --- /dev/null +++ b/docs/user-guide/certificates.md @@ -0,0 +1,38 @@ +# Certificates + +The `CertificatesResource` provides methods for accessing certificate information. + +## Methods + +### get_certificates() + +Get certificates with optional filtering. + +```python +# All certificates +certificates = client.certificates.get_certificates() + +# Filter by date range +certificates = client.certificates.get_certificates( + start_date="01/01/2024", + end_date="12/31/2024" +) + +# Filter by partner account +certificates = client.certificates.get_certificates( + partner_account="PART001" +) + +# Filter by pool account +certificates = client.certificates.get_certificates( + pool_account="POOL001" +) + +# Combine filters +certificates = client.certificates.get_certificates( + start_date="01/01/2024", + end_date="12/31/2024", + partner_account="PART001", + pool_account="POOL001" +) +``` diff --git a/docs/user-guide/client.md b/docs/user-guide/client.md new file mode 100644 index 0000000..a1b5965 --- /dev/null +++ b/docs/user-guide/client.md @@ -0,0 +1,282 @@ +# Client + +The `TMOClient` class is the main entry point for interacting with The Mortgage Office API. It handles authentication, request management, and provides access to all resource endpoints. + +## Initialization + +### Basic Initialization (Recommended) + +Using environment variables is recommended for production and CI/CD: + +```python +import os +from tmo_api import TMOClient + +client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"] +) +``` + +### With Environment + +```python +import os +from tmo_api import TMOClient, Environment + +client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"], + environment=Environment.US # US, CANADA, or AUSTRALIA +) +``` + +### With Custom Configuration + +```python +import os + +client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"], + environment="https://custom.endpoint.com", # Custom API endpoint + timeout=60, # Request timeout in seconds (default: 30) + debug=True # Enable debug logging (default: False) +) +``` + +## Configuration Options + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `token` | str | Required | Your TMO API token | +| `database` | str | Required | Your database name | +| `environment` | Environment \| str | `Environment.US` | API environment or custom URL | +| `timeout` | int | 30 | Request timeout in seconds | +| `debug` | bool | False | Enable debug logging | + +## Available Resources + +The client provides access to resources for both Shares and Capital pool types: + +### Shares Resources +```python +client.shares_pools # Pool operations +client.shares_partners # Partner operations +client.shares_distributions # Distribution operations +client.shares_certificates # Certificate operations +client.shares_history # History operations +``` + +### Capital Resources +```python +client.capital_pools # Pool operations +client.capital_partners # Partner operations +client.capital_distributions # Distribution operations +client.capital_history # History operations +``` + +## HTTP Methods + +The client provides low-level HTTP methods for direct API access: + +### GET Request + +```python +response = client.get("/LSS.svc/Shares/Pools") +``` + +### POST Request + +```python +data = {"field": "value"} +response = client.post("/LSS.svc/Shares/Pools", json=data) +``` + +### PUT Request + +```python +data = {"field": "updated_value"} +response = client.put("/LSS.svc/Shares/Pools/123", json=data) +``` + +### DELETE Request + +```python +response = client.delete("/LSS.svc/Shares/Pools/123") +``` + +## Debug Mode + +Enable debug mode to see detailed request/response information: + +```python +import os + +client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"], + debug=True +) + +# This will log: +# - Request method and URL +# - Request headers (with masked sensitive data) +# - Response status +# - Response body +pools = client.shares_pools.list_all() +``` + +## Error Handling + +The client automatically handles API errors and raises appropriate exceptions: + +```python +import os +from tmo_api import TMOClient, AuthenticationError, APIError, NetworkError + +client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"] +) + +try: + pools = client.shares_pools.list_all() +except AuthenticationError as e: + # 401/403 errors + print(f"Authentication failed: {e}") +except NetworkError as e: + # Connection/timeout errors + print(f"Network error: {e}") +except APIError as e: + # Other API errors + print(f"API error: {e.message}") +``` + +## Session Management + +The client uses a persistent `requests.Session` for better performance: + +```python +import os + +# Session is automatically created and reused +client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"] +) + +# Make multiple requests efficiently +pools = client.shares_pools.list_all() +partners = client.shares_partners.list_all() +# Session is reused across requests +``` + +## Best Practices + +### 1. Reuse Client Instances + +```python +import os + +# Good: Create once, use many times +client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"] +) +pools = client.shares_pools.list_all() +partners = client.shares_partners.list_all() + +# Bad: Creating new client for each request +pools = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"] +).shares_pools.list_all() +partners = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"] +).shares_partners.list_all() +``` + +### 2. Use Environment Variables + +```python +import os + +client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"] +) +``` + +### 3. Set Appropriate Timeouts + +```python +import os + +# For long-running operations +client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"], + timeout=120 # 2 minutes +) + +# For quick operations +client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"], + timeout=10 # 10 seconds +) +``` + +### 4. Handle Errors Gracefully + +```python +import os +from tmo_api import TMOClient, TMOException + +client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"] +) + +try: + pools = client.shares_pools.list_all() +except TMOException as e: + # Log error and handle gracefully + logger.error(f"TMO API error: {e}") + pools = [] # Return empty list as fallback +``` + +## Thread Safety + +The client uses `requests.Session` which is not thread-safe. Create separate client instances for each thread: + +```python +import os +import threading + +def fetch_pools(): + # Create separate client for this thread + client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"] + ) + return client.shares_pools.list_all() + +# Create threads with separate clients +threads = [] +for i in range(5): + t = threading.Thread(target=fetch_pools) + threads.append(t) + t.start() + +for t in threads: + t.join() +``` + +## Next Steps + +- [Pools](pools.md) - Working with mortgage pools +- [Partners](partners.md) - Managing partners +- [Distributions](distributions.md) - Querying distributions diff --git a/docs/user-guide/distributions.md b/docs/user-guide/distributions.md new file mode 100644 index 0000000..7980e9a --- /dev/null +++ b/docs/user-guide/distributions.md @@ -0,0 +1,79 @@ +# Distributions + +The `DistributionsResource` provides methods for querying distribution records. + +## Overview + +The SDK provides separate distribution resources for Shares and Capital pool types: + +```python +import os +from tmo_api import TMOClient + +client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"] +) + +# Access shares distributions resource +shares_distributions = client.shares_distributions + +# Access capital distributions resource +capital_distributions = client.capital_distributions +``` + +## Methods + +### get_distribution() + +Get a specific distribution by record ID. + +**Parameters:** +- `rec_id` (str, required): The distribution record identifier + +**Returns:** `Dict[str, Any]` - Distribution data dictionary + +**Example:** +```python +distribution = client.shares_distributions.get_distribution("123") +print(f"Distribution ID: {distribution.get('rec_id')}") +print(f"Amount: {distribution.get('Amount')}") +``` + +### list_all() + +List all distributions with optional filtering. + +**Parameters:** +- `start_date` (str, optional): Start date in MM/DD/YYYY format +- `end_date` (str, optional): End date in MM/DD/YYYY format +- `pool_account` (str, optional): Filter by specific pool account + +**Returns:** `List[Any]` - List of distribution data dictionaries + +**Example:** +```python +# All distributions +distributions = client.shares_distributions.list_all() + +for dist in distributions: + print(f"Distribution: {dist.get('rec_id')} - Amount: {dist.get('Amount')}") + +# Filter by date range +distributions = client.shares_distributions.list_all( + start_date="01/01/2024", + end_date="12/31/2024" +) + +# Filter by pool account +distributions = client.shares_distributions.list_all( + pool_account="POOL001" +) + +# Combine filters +distributions = client.shares_distributions.list_all( + start_date="01/01/2024", + end_date="12/31/2024", + pool_account="POOL001" +) +``` diff --git a/docs/user-guide/history.md b/docs/user-guide/history.md new file mode 100644 index 0000000..2293274 --- /dev/null +++ b/docs/user-guide/history.md @@ -0,0 +1,38 @@ +# History + +The `HistoryResource` provides methods for retrieving account history. + +## Methods + +### get_history() + +Get account history with optional filtering. + +```python +# All history +history = client.history.get_history() + +# Filter by date range +history = client.history.get_history( + start_date="01/01/2024", + end_date="12/31/2024" +) + +# Filter by partner account +history = client.history.get_history( + partner_account="PART001" +) + +# Filter by pool account +history = client.history.get_history( + pool_account="POOL001" +) + +# Combine filters +history = client.history.get_history( + start_date="01/01/2024", + end_date="12/31/2024", + partner_account="PART001", + pool_account="POOL001" +) +``` diff --git a/docs/user-guide/partners.md b/docs/user-guide/partners.md new file mode 100644 index 0000000..95c39db --- /dev/null +++ b/docs/user-guide/partners.md @@ -0,0 +1,83 @@ +# Partners + +The `PartnersResource` provides methods for accessing partner account information. + +## Overview + +The SDK provides separate partner resources for Shares and Capital pool types: + +```python +import os +from tmo_api import TMOClient + +client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"] +) + +# Access shares partners resource +shares_partners = client.shares_partners + +# Access capital partners resource +capital_partners = client.capital_partners +``` + +## Methods + +### get_partner() + +Get detailed information about a specific partner. Returns a dictionary containing partner data. + +**Parameters:** +- `account` (str, required): The partner account identifier + +**Returns:** `Dict[str, Any]` - Partner data dictionary + +**Example:** +```python +partner = client.shares_partners.get_partner("PART001") +print(f"Partner: {partner.get('Name')}") +print(f"Account: {partner.get('Account')}") +``` + +### get_partner_attachments() + +Get attachments for a partner. + +**Parameters:** +- `account` (str, required): The partner account identifier + +**Returns:** `List[Any]` - List of partner attachments + +**Example:** +```python +attachments = client.shares_partners.get_partner_attachments("PART001") + +for attachment in attachments: + print(f"File: {attachment.get('FileName')}") +``` + +### list_all() + +List all partners with optional date filtering. + +**Parameters:** +- `start_date` (str, optional): Start date in MM/DD/YYYY format +- `end_date` (str, optional): End date in MM/DD/YYYY format + +**Returns:** `List[Any]` - List of partner data dictionaries + +**Example:** +```python +# All partners +partners = client.shares_partners.list_all() + +for partner in partners: + print(f"{partner.get('Account')}: {partner.get('Name')}") + +# Filter by date range +partners = client.shares_partners.list_all( + start_date="01/01/2024", + end_date="12/31/2024" +) +``` diff --git a/docs/user-guide/pools.md b/docs/user-guide/pools.md new file mode 100644 index 0000000..ae8bf09 --- /dev/null +++ b/docs/user-guide/pools.md @@ -0,0 +1,265 @@ +# Pools + +The `PoolsResource` provides methods for accessing and managing mortgage pool information. + +## Overview + +Pools represent mortgage pools in The Mortgage Office system. The SDK supports both Shares and Capital pool types. + +## Initialization + +The pools resource is automatically initialized when you create a client: + +```python +import os +from tmo_api import TMOClient + +client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"] +) + +# Access shares pools resource +shares_pools = client.shares_pools + +# Access capital pools resource +capital_pools = client.capital_pools +``` + +## Pool Types + +```python +from tmo_api.resources.pools import PoolType + +# Shares pools (default) +PoolType.SHARES + +# Capital pools +PoolType.CAPITAL +``` + +## Methods + +### get_pool() + +Get detailed information about a specific pool. + +**Parameters:** +- `account` (str, required): The pool account identifier + +**Returns:** `Pool` object + +**Example:** +```python +pool = client.shares_pools.get_pool("POOL001") + +print(f"Pool Name: {pool.Name}") +print(f"Account: {pool.Account}") +print(f"Inception Date: {pool.InceptionDate}") + +# Access nested objects +if pool.OtherAssets: + for asset in pool.OtherAssets: + print(f"Asset: {asset.Description}, Value: {asset.Value}") +``` + +### list_all() + +Get a list of all available pools. + +**Returns:** `List[Pool]` + +**Example:** +```python +pools = client.shares_pools.list_all() + +for pool in pools: + print(f"{pool.Account}: {pool.Name}") +``` + +### get_pool_partners() + +Get partners associated with a pool. + +**Parameters:** +- `account` (str, required): The pool account identifier + +**Returns:** `list` of partner data + +**Example:** +```python +partners = client.shares_pools.get_pool_partners("POOL001") + +for partner in partners: + print(f"Partner: {partner.get('Name')}") +``` + +### get_pool_loans() + +Get loans associated with a pool. + +**Parameters:** +- `account` (str, required): The pool account identifier + +**Returns:** `list` of loan data + +**Example:** +```python +loans = client.shares_pools.get_pool_loans("POOL001") + +for loan in loans: + print(f"Loan: {loan.get('LoanNumber')}") +``` + +### get_pool_bank_accounts() + +Get bank accounts associated with a pool. + +**Parameters:** +- `account` (str, required): The pool account identifier + +**Returns:** `list` of bank account data + +**Example:** +```python +bank_accounts = client.shares_pools.get_pool_bank_accounts("POOL001") + +for account in bank_accounts: + print(f"Bank: {account.get('BankName')}") +``` + +### get_pool_attachments() + +Get attachments associated with a pool. + +**Parameters:** +- `account` (str, required): The pool account identifier + +**Returns:** `list` of attachment data + +**Example:** +```python +attachments = client.shares_pools.get_pool_attachments("POOL001") + +for attachment in attachments: + print(f"File: {attachment.get('FileName')}") +``` + +## Pool Model + +The `Pool` model represents a mortgage pool with the following key attributes: + +```python +pool = client.shares_pools.get_pool("POOL001") + +# Basic information +pool.rec_id # Record ID +pool.Account # Account number +pool.Name # Pool name + +# Date fields (automatically parsed to datetime) +pool.InceptionDate # datetime object +pool.LastEvaluation # datetime object +pool.SysTimeStamp # datetime object + +# Nested objects +pool.OtherAssets # List[OtherAsset] +pool.OtherLiabilities # List[OtherLiability] + +# Access all fields dynamically +for key, value in pool.__dict__.items(): + print(f"{key}: {value}") +``` + +## Error Handling + +```python +import os +from tmo_api import TMOClient, ValidationError, APIError + +client = TMOClient( + token=os.environ["TMO_API_TOKEN"], + database=os.environ["TMO_DATABASE"] +) + +try: + pool = client.shares_pools.get_pool("INVALID") +except ValidationError as e: + print(f"Invalid input: {e}") +except APIError as e: + print(f"API error: {e}") +``` + +## Common Use Cases + +### 1. List All Pools with Details + +```python +pools = client.shares_pools.list_all() + +for pool in pools: + print(f"\nPool: {pool.Account}") + print(f" Name: {pool.Name}") + if pool.InceptionDate: + print(f" Inception: {pool.InceptionDate.strftime('%Y-%m-%d')}") +``` + +### 2. Get Complete Pool Information + +```python +account = "POOL001" + +# Get basic pool info +pool = client.shares_pools.get_pool(account) + +# Get related data +partners = client.shares_pools.get_pool_partners(account) +loans = client.shares_pools.get_pool_loans(account) +bank_accounts = client.shares_pools.get_pool_bank_accounts(account) + +print(f"Pool: {pool.Name}") +print(f"Partners: {len(partners)}") +print(f"Loans: {len(loans)}") +print(f"Bank Accounts: {len(bank_accounts)}") +``` + +### 3. Filter Pools by Criteria + +```python +pools = client.shares_pools.list_all() + +# Filter by inception date +from datetime import datetime + +recent_pools = [ + p for p in pools + if p.InceptionDate and p.InceptionDate.year >= 2024 +] + +print(f"Found {len(recent_pools)} pools from 2024") +``` + +### 4. Export Pool Data + +```python +import csv + +pools = client.shares_pools.list_all() + +with open('pools.csv', 'w', newline='') as f: + writer = csv.writer(f) + writer.writerow(['Account', 'Name', 'Inception Date']) + + for pool in pools: + writer.writerow([ + pool.Account, + pool.Name, + pool.InceptionDate.strftime('%Y-%m-%d') if pool.InceptionDate else '' + ]) +``` + +## Next Steps + +- [Partners](partners.md) - Working with partners +- [Distributions](distributions.md) - Querying distributions +- [Models API Reference](../api-reference/models.md) - Pool model details diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..992aabf --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,96 @@ +site_name: TMO API Python SDK +site_description: Python SDK for The Mortgage Office API +site_author: Yinchuan Song +site_url: https://inntran.github.io/tmo-api-python/ + +repo_name: inntran/tmo-api-python +repo_url: https://github.com/inntran/tmo-api-python + +theme: + name: material + palette: + # Palette toggle for light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + # Palette toggle for dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.instant + - navigation.tracking + - navigation.tabs + - navigation.sections + - navigation.expand + - navigation.top + - search.suggest + - search.highlight + - content.code.copy + - content.code.annotate + +markdown_extensions: + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - admonition + - pymdownx.details + - attr_list + - md_in_html + - tables + - toc: + permalink: true + +plugins: + - search + - mike: + version_selector: true + css_dir: css + javascript_dir: js + canonical_version: latest + +extra: + version: + provider: mike + default: latest + social: + - icon: fontawesome/brands/github + link: https://github.com/inntran/tmo-api-python + +nav: + - Home: index.md + - Getting Started: + - Installation: getting-started/installation.md + - Quick Start: getting-started/quickstart.md + - Authentication: getting-started/authentication.md + - User Guide: + - Client: user-guide/client.md + - Pools: user-guide/pools.md + - Partners: user-guide/partners.md + - Distributions: user-guide/distributions.md + - Certificates: user-guide/certificates.md + - History: user-guide/history.md + - API Reference: + - Client: api-reference/client.md + - Models: api-reference/models.md + - Resources: api-reference/resources.md + - Exceptions: api-reference/exceptions.md + - Contributing: + - Development Setup: contributing/development.md + - Testing: contributing/testing.md + - Code Style: contributing/code-style.md + - Changelog: changelog.md diff --git a/pyproject.toml b/pyproject.toml index 467fe94..97b6bf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,9 @@ authors = [ { name = "Yinchuan Song", email = "songyinchuan@gmail.com" } ] requires-python = ">=3.9" -dependencies = [] +dependencies = [ + "requests>=2.25.0,<3.0.0", +] keywords = ["TMO", "The Mortgage Office", "Mortgage Pools", "Mortgage Pool Shares"] classifiers = [ "Development Status :: 4 - Beta", @@ -29,9 +31,46 @@ classifiers = [ ] [project.urls] -Repository = https://github.com/inntran/tmo-api-python -Issues = https://github.com/inntran/tmo-api-python/issues +Repository = "https://github.com/inntran/tmo-api-python" +Issues = "https://github.com/inntran/tmo-api-python/issues" + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "black>=23.0.0", + "flake8>=6.0.0", + "isort>=5.12.0", + "mypy>=1.0.0", + "types-requests>=2.31.0", +] +docs = [ + "mkdocs>=1.6.0", + "mkdocs-material>=9.6.0", + "mike>=2.1.0", +] [build-system] -requires = ["uv_build>=0.9.5,<0.10.0"] -build-backend = "uv_build" +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/tmo_api"] + +[tool.black] +line-length = 100 +target-version = ['py39', 'py310', 'py311', 'py312', 'py313', 'py314'] + +[tool.isort] +profile = "black" +line_length = 100 + +[tool.mypy] +python_version = "3.9" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--cov=tmo_api --cov-report=term-missing --cov-report=xml" diff --git a/src/tmo_api/__init__.py b/src/tmo_api/__init__.py index 1011720..cf085d6 100644 --- a/src/tmo_api/__init__.py +++ b/src/tmo_api/__init__.py @@ -1,2 +1,41 @@ -def main() -> None: - print("Hello from tmo-api!") +"""The Mortgage Office API SDK for Python.""" + +from .client import TMOClient +from .environments import DEFAULT_ENVIRONMENT, Environment +from .exceptions import ( + APIError, + AuthenticationError, + NetworkError, + TMOException, + ValidationError, +) +from .models import BaseModel, BaseResponse +from .resources import ( + CertificatesResource, + DistributionsResource, + HistoryResource, + PartnersResource, + PoolsResource, + PoolType, +) + +__version__ = "0.0.1" + +__all__ = [ + "TMOClient", + "Environment", + "DEFAULT_ENVIRONMENT", + "TMOException", + "APIError", + "AuthenticationError", + "NetworkError", + "ValidationError", + "BaseModel", + "BaseResponse", + "PoolsResource", + "PoolType", + "PartnersResource", + "DistributionsResource", + "CertificatesResource", + "HistoryResource", +] diff --git a/src/tmo_api/client.py b/src/tmo_api/client.py new file mode 100644 index 0000000..644b7cf --- /dev/null +++ b/src/tmo_api/client.py @@ -0,0 +1,257 @@ +"""Base client for The Mortgage Office API.""" + +import json +import sys +from typing import Any, Dict, Optional, Union +from urllib.parse import urljoin + +import requests + +from .environments import DEFAULT_ENVIRONMENT, Environment +from .exceptions import APIError, AuthenticationError, NetworkError +from .resources import ( + CertificatesResource, + DistributionsResource, + HistoryResource, + PartnersResource, + PoolsResource, +) + + +class TMOClient: + """Base client for The Mortgage Office API.""" + + def __init__( + self, + token: str, + database: str, + environment: Union[Environment, str] = DEFAULT_ENVIRONMENT, + timeout: int = 30, + debug: bool = False, + ) -> None: + """Initialize the client. + + Args: + token: Your API token assigned by Applied Business Software + database: The name of your company database + environment: API environment (US, CANADA, AUSTRALIA) or custom URL + timeout: Request timeout in seconds (default: 30) + debug: Enable debug logging (default: False) + """ + self.token: str = token + self.database: str = database + self.timeout: int = timeout + self.debug: bool = debug + + # Handle environment parameter + if isinstance(environment, str): + # If string, treat as custom URL + self.base_url: str = environment + else: + # If Environment enum, use its value + self.base_url = environment.value + + self.session: requests.Session = requests.Session() + + # Set default headers + self.session.headers.update( + { + "Token": self.token, + "Database": self.database, + "Content-Type": "application/json", + "User-Agent": "themortgageoffice-sdk-python", + } + ) + + # Import PoolType here to avoid circular imports + from .resources.pools import PoolType + + # Initialize Shares resources + self.shares_pools: PoolsResource = PoolsResource(self, PoolType.SHARES) + self.shares_partners: PartnersResource = PartnersResource(self, PoolType.SHARES) + self.shares_distributions: DistributionsResource = DistributionsResource( + self, PoolType.SHARES + ) + self.shares_certificates: CertificatesResource = CertificatesResource(self, PoolType.SHARES) + self.shares_history: HistoryResource = HistoryResource(self, PoolType.SHARES) + + # Initialize Capital resources + self.capital_pools: PoolsResource = PoolsResource(self, PoolType.CAPITAL) + self.capital_partners: PartnersResource = PartnersResource(self, PoolType.CAPITAL) + self.capital_distributions: DistributionsResource = DistributionsResource( + self, PoolType.CAPITAL + ) + self.capital_history: HistoryResource = HistoryResource(self, PoolType.CAPITAL) + + def _debug_log(self, message: str) -> None: + """Log debug message to stderr if debug mode is enabled.""" + if self.debug: + print(f"DEBUG: {message}", file=sys.stderr) + + def _debug_log_request( + self, + method: str, + url: str, + headers: Dict[str, str], + params: Optional[Dict[str, Any]] = None, + json_data: Optional[Dict[str, Any]] = None, + ) -> None: + """Log request details if debug mode is enabled.""" + if not self.debug: + return + + print("DEBUG: === REQUEST ===", file=sys.stderr) + print(f"DEBUG: {method} {url}", file=sys.stderr) + print("DEBUG: Headers:", file=sys.stderr) + for key, value in headers.items(): + # Mask sensitive headers + if key.lower() in ["token", "authorization"]: + masked_value = ( + "*" * min(len(value), 8) + value[-4:] if len(value) > 4 else "*" * len(value) + ) + print(f"DEBUG: {key}: {masked_value}", file=sys.stderr) + else: + print(f"DEBUG: {key}: {value}", file=sys.stderr) + + if params: + print("DEBUG: Query Parameters:", file=sys.stderr) + for key, value in params.items(): + print(f"DEBUG: {key}: {value}", file=sys.stderr) + + if json_data: + print("DEBUG: Request Body:", file=sys.stderr) + print(f"DEBUG: {json.dumps(json_data, indent=2)}", file=sys.stderr) + + def _debug_log_response( + self, response: requests.Response, response_data: Dict[str, Any] + ) -> None: + """Log response details if debug mode is enabled.""" + if not self.debug: + return + + print("DEBUG: === RESPONSE ===", file=sys.stderr) + print(f"DEBUG: Status: {response.status_code}", file=sys.stderr) + print("DEBUG: Response Headers:", file=sys.stderr) + for key, value in response.headers.items(): + print(f"DEBUG: {key}: {value}", file=sys.stderr) + + print("DEBUG: Response Body:", file=sys.stderr) + print( + f"DEBUG: {json.dumps(response_data, indent=2, default=str)}", + file=sys.stderr, + ) + print("DEBUG: ==================", file=sys.stderr) + + def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]: + """Make a request to the API. + + Args: + method: HTTP method (GET, POST, PUT, DELETE) + endpoint: API endpoint path + **kwargs: Additional arguments to pass to requests + + Returns: + API response data + + Raises: + AuthenticationError: If authentication fails + APIError: If the API returns an error + NetworkError: If a network error occurs + """ + url: str = urljoin(self.base_url + "/", endpoint) + + # Log request details if debug mode is enabled + self._debug_log_request( + method=method, + url=url, + headers={k: str(v) for k, v in self.session.headers.items()}, + params=kwargs.get("params"), + json_data=kwargs.get("json"), + ) + + try: + response = self.session.request(method=method, url=url, timeout=self.timeout, **kwargs) + response.raise_for_status() + + except requests.exceptions.Timeout: + self._debug_log("Request timed out") + raise NetworkError("Request timed out") + except requests.exceptions.ConnectionError: + self._debug_log("Connection error occurred") + raise NetworkError("Connection error occurred") + except requests.exceptions.HTTPError as e: + self._debug_log(f"HTTP error: {response.status_code}") + if response.status_code == 401: + raise AuthenticationError("Invalid token or database") + elif response.status_code == 403: + raise AuthenticationError("Access denied") + else: + raise NetworkError(f"HTTP {response.status_code}: {str(e)}") + except requests.exceptions.RequestException as e: + self._debug_log(f"Request exception: {str(e)}") + raise NetworkError(f"Request failed: {str(e)}") + + try: + data: Dict[str, Any] = response.json() + except ValueError: + self._debug_log("Failed to parse JSON response") + raise APIError("Invalid JSON response from API") + + # Log response details if debug mode is enabled + self._debug_log_response(response, data) + + # Check for API-level errors + if data.get("Status") != 0: + error_message: str = data.get("ErrorMessage", "Unknown API error") + error_number: Optional[int] = data.get("ErrorNumber") + self._debug_log(f"API error: {error_message} (Number: {error_number})") + raise APIError(error_message, error_number) + + return data + + def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Make a GET request. + + Args: + endpoint: API endpoint path + params: Query parameters + + Returns: + API response data + """ + return self._make_request("GET", endpoint, params=params) + + def post(self, endpoint: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Make a POST request. + + Args: + endpoint: API endpoint path + json: JSON data to send + + Returns: + API response data + """ + return self._make_request("POST", endpoint, json=json) + + def put(self, endpoint: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Make a PUT request. + + Args: + endpoint: API endpoint path + json: JSON data to send + + Returns: + API response data + """ + return self._make_request("PUT", endpoint, json=json) + + def delete(self, endpoint: str) -> Dict[str, Any]: + """Make a DELETE request. + + Args: + endpoint: API endpoint path + + Returns: + API response data + """ + return self._make_request("DELETE", endpoint) diff --git a/src/tmo_api/environments.py b/src/tmo_api/environments.py new file mode 100644 index 0000000..5823824 --- /dev/null +++ b/src/tmo_api/environments.py @@ -0,0 +1,16 @@ +"""Environment configurations for The Mortgage Office SDK.""" + +from enum import Enum +from typing import Final + + +class Environment(Enum): + """Supported API environments.""" + + US = "https://api.themortgageoffice.com" + CANADA = "https://api-ca.themortgageoffice.com" + AUSTRALIA = "https://api-aus.themortgageoffice.com" + + +# Default environment +DEFAULT_ENVIRONMENT: Final[Environment] = Environment.US diff --git a/src/tmo_api/exceptions.py b/src/tmo_api/exceptions.py new file mode 100644 index 0000000..f29e3e2 --- /dev/null +++ b/src/tmo_api/exceptions.py @@ -0,0 +1,36 @@ +"""Custom exceptions for The Mortgage Office SDK.""" + +from typing import Optional + + +class TMOException(Exception): + """Base exception for The Mortgage Office SDK.""" + + def __init__(self, message: str, error_number: Optional[int] = None) -> None: + super().__init__(message) + self.message: str = message + self.error_number: Optional[int] = error_number + + +class AuthenticationError(TMOException): + """Raised when authentication fails.""" + + pass + + +class APIError(TMOException): + """Raised when the API returns an error response.""" + + pass + + +class ValidationError(TMOException): + """Raised when request validation fails.""" + + pass + + +class NetworkError(TMOException): + """Raised when network-related errors occur.""" + + pass diff --git a/src/tmo_api/models/__init__.py b/src/tmo_api/models/__init__.py new file mode 100644 index 0000000..f7bf64a --- /dev/null +++ b/src/tmo_api/models/__init__.py @@ -0,0 +1,14 @@ +"""Models package for The Mortgage Office SDK.""" + +from .base import BaseModel, BaseResponse +from .pool import OtherAsset, OtherLiability, Pool, PoolResponse, PoolsResponse + +__all__ = [ + "BaseModel", + "BaseResponse", + "Pool", + "PoolResponse", + "PoolsResponse", + "OtherAsset", + "OtherLiability", +] diff --git a/src/tmo_api/models/base.py b/src/tmo_api/models/base.py new file mode 100644 index 0000000..0d39cf4 --- /dev/null +++ b/src/tmo_api/models/base.py @@ -0,0 +1,96 @@ +"""Base models for The Mortgage Office SDK.""" + +from datetime import datetime +from typing import Any, Dict, Optional + + +class BaseResponse: + """Base response model for API responses.""" + + def __init__(self, data: Dict[str, Any]) -> None: + """Initialize response from API data. + + Args: + data: Raw API response data + """ + self.raw_data: Dict[str, Any] = data + self.data: Dict[str, Any] = data.get("Data") or {} + self.error_message: Optional[str] = data.get("ErrorMessage") + self.error_number: Optional[int] = data.get("ErrorNumber") + self.status: int = data.get("Status", 0) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(status={self.status})" + + +class BaseModel: + """Base model for API data objects.""" + + def __init__(self, data: Dict[str, Any]) -> None: + """Initialize model from API data. + + Args: + data: Raw API data for this object + """ + self.raw_data: Dict[str, Any] = data + self._parse_data(data) + + def _parse_data(self, data: Dict[str, Any]) -> None: + """Parse raw API data into model attributes. + + Args: + data: Raw API data + """ + # Set basic attributes from data, preserving original field names + for key, value in data.items(): + # Use the original field name as-is + setattr(self, key, value) + + def _to_snake_case(self, name: str) -> str: + """Convert CamelCase to snake_case. + + Args: + name: CamelCase string + + Returns: + snake_case string + """ + result: list[str] = [] + for i, c in enumerate(name): + if c.isupper() and i > 0: + result.append("_") + result.append(c.lower()) + return "".join(result) + + def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]: + """Parse date string to datetime object. + + Args: + date_str: Date string from API + + Returns: + Parsed datetime or None + """ + if not date_str: + return None + + # Try common date formats + formats: list[str] = [ + "%m/%d/%Y", + "%m/%d/%Y %H:%M:%S", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d", + ] + + for fmt in formats: + try: + return datetime.strptime(date_str, fmt) + except ValueError: + continue + + # If no format matches, return None + return None + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({getattr(self, 'rec_id', 'unknown')})" diff --git a/src/tmo_api/models/pool.py b/src/tmo_api/models/pool.py new file mode 100644 index 0000000..e8fb4ee --- /dev/null +++ b/src/tmo_api/models/pool.py @@ -0,0 +1,84 @@ +"""Pool-related models for The Mortgage Office SDK.""" + +from typing import Any, Dict, List, Optional + +from .base import BaseModel, BaseResponse + + +class OtherAsset(BaseModel): + """Represents an other asset in a mortgage pool.""" + + def _parse_data(self, data: Dict[str, Any]) -> None: + super()._parse_data(data) + + # Parse dates specifically (since they need conversion) + if "DateLastEvaluated" in data: + self.DateLastEvaluated = self._parse_date(data.get("DateLastEvaluated")) + + +class OtherLiability(BaseModel): + """Represents an other liability in a mortgage pool.""" + + def _parse_data(self, data: Dict[str, Any]) -> None: + super()._parse_data(data) + + # Parse dates specifically (since they need conversion) + if "MaturityDate" in data: + self.MaturityDate = self._parse_date(data.get("MaturityDate")) + if "PaymentNextDue" in data: + self.PaymentNextDue = self._parse_date(data.get("PaymentNextDue")) + + +class Pool(BaseModel): + """Represents a mortgage pool.""" + + def _parse_data(self, data: Dict[str, Any]) -> None: + super()._parse_data(data) + + # Parse dates specifically (since they need conversion) + if "InceptionDate" in data: + self.InceptionDate = self._parse_date(data.get("InceptionDate")) + if "LastEvaluation" in data: + self.LastEvaluation = self._parse_date(data.get("LastEvaluation")) + if "SysTimeStamp" in data: + self.SysTimeStamp = self._parse_date(data.get("SysTimeStamp")) + + # Parse nested objects (override the raw arrays with parsed objects) + if "OtherAssets" in data: + self.OtherAssets: List[OtherAsset] = [] + for asset_data in data.get("OtherAssets", []): + self.OtherAssets.append(OtherAsset(asset_data)) + + if "OtherLiabilities" in data: + self.OtherLiabilities: List[OtherLiability] = [] + for liability_data in data.get("OtherLiabilities", []): + self.OtherLiabilities.append(OtherLiability(liability_data)) + + +class PoolResponse(BaseResponse): + """Response containing pool data.""" + + pool: Optional["Pool"] + + def __init__(self, data: Dict[str, Any]) -> None: + super().__init__(data) + if self.data: + self.pool = Pool(self.data) + else: + self.pool = None + + +class PoolsResponse(BaseResponse): + """Response containing multiple pools.""" + + def __init__(self, data: Dict[str, Any]) -> None: + super().__init__(data) + self.pools: List[Pool] = [] + + # Handle both single pool and list of pools + pool_data: Any = self.data + if isinstance(pool_data, list): + for item in pool_data: + self.pools.append(Pool(item)) + elif isinstance(pool_data, dict) and pool_data: + self.pools.append(Pool(pool_data)) diff --git a/src/tmo_api/resources/__init__.py b/src/tmo_api/resources/__init__.py new file mode 100644 index 0000000..f28cd44 --- /dev/null +++ b/src/tmo_api/resources/__init__.py @@ -0,0 +1,16 @@ +"""Resources package for The Mortgage Office SDK.""" + +from .certificates import CertificatesResource +from .distributions import DistributionsResource +from .history import HistoryResource +from .partners import PartnersResource +from .pools import PoolsResource, PoolType + +__all__ = [ + "PoolsResource", + "PoolType", + "PartnersResource", + "DistributionsResource", + "CertificatesResource", + "HistoryResource", +] diff --git a/src/tmo_api/resources/certificates.py b/src/tmo_api/resources/certificates.py new file mode 100644 index 0000000..b47feb0 --- /dev/null +++ b/src/tmo_api/resources/certificates.py @@ -0,0 +1,89 @@ +"""Certificates resource for The Mortgage Office SDK.""" + +from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast + +from .pools import PoolType + +if TYPE_CHECKING: + from ..client import TMOClient + + +class CertificatesResource: + """Resource for managing share certificates.""" + + def __init__(self, client: "TMOClient", pool_type: PoolType = PoolType.SHARES) -> None: + """Initialize the certificates resource. + + Args: + client: The base client instance + pool_type: The type of pool (Shares or Capital) - Note: Certificates + are only available for Shares + """ + self.client = client + self.pool_type = pool_type + self.base_path = f"LSS.svc/{pool_type.value}" + + def get_certificates( + self, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + partner_account: Optional[str] = None, + pool_account: Optional[str] = None, + ) -> List[Any]: + """Get share certificates with optional filtering. + + Args: + start_date: Start date for filtering (MM/DD/YYYY format) + end_date: End date for filtering (MM/DD/YYYY format) + partner_account: Partner account filter + pool_account: Pool account filter + + Returns: + List of share certificates + + Raises: + APIError: If the API returns an error + ValidationError: If date format is invalid + """ + endpoint = f"{self.base_path}/Certificates" + params: Dict[str, str] = {} + + if start_date: + if not self._validate_date_format(start_date): + from ..exceptions import ValidationError + + raise ValidationError("start_date must be in MM/DD/YYYY format") + params["from-date"] = start_date + + if end_date: + if not self._validate_date_format(end_date): + from ..exceptions import ValidationError + + raise ValidationError("end_date must be in MM/DD/YYYY format") + params["to-date"] = end_date + + if partner_account: + params["partner-account"] = partner_account + + if pool_account: + params["pool-account"] = pool_account + + response_data = self.client.get(endpoint, params=params if params else None) + return cast(List[Any], response_data.get("Data", [])) + + def _validate_date_format(self, date_str: str) -> bool: + """Validate date format MM/DD/YYYY. + + Args: + date_str: Date string to validate + + Returns: + True if format is valid, False otherwise + """ + try: + from datetime import datetime + + datetime.strptime(date_str, "%m/%d/%Y") + return True + except ValueError: + return False diff --git a/src/tmo_api/resources/distributions.py b/src/tmo_api/resources/distributions.py new file mode 100644 index 0000000..196d406 --- /dev/null +++ b/src/tmo_api/resources/distributions.py @@ -0,0 +1,105 @@ +"""Distributions resource for The Mortgage Office SDK.""" + +from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast + +from .pools import PoolType + +if TYPE_CHECKING: + from ..client import TMOClient + + +class DistributionsResource: + """Resource for managing pool distributions.""" + + def __init__(self, client: "TMOClient", pool_type: PoolType = PoolType.SHARES) -> None: + """Initialize the distributions resource. + + Args: + client: The base client instance + pool_type: The type of pool (Shares or Capital) + """ + self.client = client + self.pool_type = pool_type + self.base_path = f"LSS.svc/{pool_type.value}" + + def get_distribution(self, rec_id: str) -> Dict[str, Any]: + """Get distribution details by RecID. + + Args: + rec_id: The distribution record ID + + Returns: + Distribution data dictionary + + Raises: + APIError: If the API returns an error + ValidationError: If rec_id is invalid + """ + if not rec_id: + from ..exceptions import ValidationError + + raise ValidationError("RecID parameter is required") + + endpoint = f"{self.base_path}/Distributions/{rec_id}" + response_data = self.client.get(endpoint) + return cast(Dict[str, Any], response_data.get("Data", {})) + + def list_all( + self, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + pool_account: Optional[str] = None, + ) -> List[Any]: + """List all distributions with optional filtering. + + Args: + start_date: Start date for filtering (MM/DD/YYYY format) + end_date: End date for filtering (MM/DD/YYYY format) + pool_account: Pool account filter + + Returns: + List of distributions + + Raises: + APIError: If the API returns an error + ValidationError: If date format is invalid + """ + endpoint = f"{self.base_path}/Distributions" + params: Dict[str, str] = {} + + if start_date: + if not self._validate_date_format(start_date): + from ..exceptions import ValidationError + + raise ValidationError("start_date must be in MM/DD/YYYY format") + params["from-date"] = start_date + + if end_date: + if not self._validate_date_format(end_date): + from ..exceptions import ValidationError + + raise ValidationError("end_date must be in MM/DD/YYYY format") + params["to-date"] = end_date + + if pool_account: + params["pool-account"] = pool_account + + response_data = self.client.get(endpoint, params=params if params else None) + return cast(List[Any], response_data.get("Data", [])) + + def _validate_date_format(self, date_str: str) -> bool: + """Validate date format MM/DD/YYYY. + + Args: + date_str: Date string to validate + + Returns: + True if format is valid, False otherwise + """ + try: + from datetime import datetime + + datetime.strptime(date_str, "%m/%d/%Y") + return True + except ValueError: + return False diff --git a/src/tmo_api/resources/history.py b/src/tmo_api/resources/history.py new file mode 100644 index 0000000..e34ff76 --- /dev/null +++ b/src/tmo_api/resources/history.py @@ -0,0 +1,88 @@ +"""History resource for The Mortgage Office SDK.""" + +from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast + +from .pools import PoolType + +if TYPE_CHECKING: + from ..client import TMOClient + + +class HistoryResource: + """Resource for managing share transaction history.""" + + def __init__(self, client: "TMOClient", pool_type: PoolType = PoolType.SHARES) -> None: + """Initialize the history resource. + + Args: + client: The base client instance + pool_type: The type of pool (Shares or Capital) + """ + self.client = client + self.pool_type = pool_type + self.base_path = f"LSS.svc/{pool_type.value}" + + def get_history( + self, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + partner_account: Optional[str] = None, + pool_account: Optional[str] = None, + ) -> List[Any]: + """Get share transaction history with optional filtering. + + Args: + start_date: Start date for filtering (MM/DD/YYYY format) + end_date: End date for filtering (MM/DD/YYYY format) + partner_account: Partner account filter + pool_account: Pool account filter + + Returns: + List of share transaction history records + + Raises: + APIError: If the API returns an error + ValidationError: If date format is invalid + """ + endpoint = f"{self.base_path}/History" + params: Dict[str, str] = {} + + if start_date: + if not self._validate_date_format(start_date): + from ..exceptions import ValidationError + + raise ValidationError("start_date must be in MM/DD/YYYY format") + params["from-date"] = start_date + + if end_date: + if not self._validate_date_format(end_date): + from ..exceptions import ValidationError + + raise ValidationError("end_date must be in MM/DD/YYYY format") + params["to-date"] = end_date + + if partner_account: + params["partner-account"] = partner_account + + if pool_account: + params["pool-account"] = pool_account + + response_data = self.client.get(endpoint, params=params if params else None) + return cast(List[Any], response_data.get("Data", [])) + + def _validate_date_format(self, date_str: str) -> bool: + """Validate date format MM/DD/YYYY. + + Args: + date_str: Date string to validate + + Returns: + True if format is valid, False otherwise + """ + try: + from datetime import datetime + + datetime.strptime(date_str, "%m/%d/%Y") + return True + except ValueError: + return False diff --git a/src/tmo_api/resources/partners.py b/src/tmo_api/resources/partners.py new file mode 100644 index 0000000..33b6fa7 --- /dev/null +++ b/src/tmo_api/resources/partners.py @@ -0,0 +1,120 @@ +"""Partners resource for The Mortgage Office SDK.""" + +from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast + +from .pools import PoolType + +if TYPE_CHECKING: + from ..client import TMOClient + + +class PartnersResource: + """Resource for managing pool partners.""" + + def __init__(self, client: "TMOClient", pool_type: PoolType = PoolType.SHARES) -> None: + """Initialize the partners resource. + + Args: + client: The base client instance + pool_type: The type of pool (Shares or Capital) + """ + self.client = client + self.pool_type = pool_type + self.base_path = f"LSS.svc/{pool_type.value}" + + def get_partner(self, account: str) -> Dict[str, Any]: + """Get partner details by Account. + + Args: + account: The partner account identifier + + Returns: + Partner data dictionary + + Raises: + APIError: If the API returns an error + ValidationError: If account is invalid + """ + if not account: + from ..exceptions import ValidationError + + raise ValidationError("Account parameter is required") + + endpoint = f"{self.base_path}/Partners/{account}" + response_data = self.client.get(endpoint) + return cast(Dict[str, Any], response_data.get("Data", {})) + + def get_partner_attachments(self, account: str) -> List[Any]: + """Get partner attachments by Account. + + Args: + account: The partner account identifier + + Returns: + List of partner attachments + + Raises: + APIError: If the API returns an error + ValidationError: If account is invalid + """ + if not account: + from ..exceptions import ValidationError + + raise ValidationError("Account parameter is required") + + endpoint = f"{self.base_path}/Partners/{account}/Attachments" + response_data = self.client.get(endpoint) + return cast(List[Any], response_data.get("Data", [])) + + def list_all( + self, start_date: Optional[str] = None, end_date: Optional[str] = None + ) -> List[Any]: + """List all partners with optional date filtering. + + Args: + start_date: Start date for filtering (MM/DD/YYYY format) + end_date: End date for filtering (MM/DD/YYYY format) + + Returns: + List of partners + + Raises: + APIError: If the API returns an error + ValidationError: If date format is invalid + """ + endpoint = f"{self.base_path}/Partners" + params: Dict[str, str] = {} + + if start_date: + if not self._validate_date_format(start_date): + from ..exceptions import ValidationError + + raise ValidationError("start_date must be in MM/DD/YYYY format") + params["from-date"] = start_date + + if end_date: + if not self._validate_date_format(end_date): + from ..exceptions import ValidationError + + raise ValidationError("end_date must be in MM/DD/YYYY format") + params["to-date"] = end_date + + response_data = self.client.get(endpoint, params=params if params else None) + return cast(List[Any], response_data.get("Data", [])) + + def _validate_date_format(self, date_str: str) -> bool: + """Validate date format MM/DD/YYYY. + + Args: + date_str: Date string to validate + + Returns: + True if format is valid, False otherwise + """ + try: + from datetime import datetime + + datetime.strptime(date_str, "%m/%d/%Y") + return True + except ValueError: + return False diff --git a/src/tmo_api/resources/pools.py b/src/tmo_api/resources/pools.py new file mode 100644 index 0000000..113d1c1 --- /dev/null +++ b/src/tmo_api/resources/pools.py @@ -0,0 +1,156 @@ +"""Pools resource for The Mortgage Office SDK.""" + +from enum import Enum +from typing import TYPE_CHECKING, Any, List, cast + +from ..models.pool import Pool, PoolResponse, PoolsResponse + +if TYPE_CHECKING: + from ..client import TMOClient + + +class PoolType(Enum): + """Pool types supported by the API.""" + + SHARES = "Shares" + CAPITAL = "Capital" + + +class PoolsResource: + """Resource for managing mortgage pools.""" + + def __init__(self, client: "TMOClient", pool_type: PoolType = PoolType.SHARES) -> None: + """Initialize the pools resource. + + Args: + client: The base client instance + pool_type: The type of pool (Shares or Capital) + """ + self.client = client + self.pool_type = pool_type + self.base_path = f"LSS.svc/{pool_type.value}" + + def get_pool(self, account: str) -> Pool: + """Get pool details by account. + + Args: + account: The pool account identifier + + Returns: + Pool object with detailed information + + Raises: + APIError: If the API returns an error + ValidationError: If account is invalid + """ + if not account: + from ..exceptions import ValidationError + + raise ValidationError("Account parameter is required") + + endpoint = f"{self.base_path}/Pools/{account}" + response_data = self.client.get(endpoint) + response = PoolResponse(response_data) + return response.pool # type: ignore + + def get_pool_partners(self, account: str) -> list: + """Get pool partners by account. + + Args: + account: The pool account identifier + + Returns: + List of pool partners + + Raises: + APIError: If the API returns an error + ValidationError: If account is invalid + """ + if not account: + from ..exceptions import ValidationError + + raise ValidationError("Account parameter is required") + + endpoint = f"{self.base_path}/Pools/{account}/Partners" + response_data = self.client.get(endpoint) + return cast(List[Any], response_data.get("Data", [])) + + def get_pool_loans(self, account: str) -> list: + """Get pool loans by account. + + Args: + account: The pool account identifier + + Returns: + List of pool loans + + Raises: + APIError: If the API returns an error + ValidationError: If account is invalid + """ + if not account: + from ..exceptions import ValidationError + + raise ValidationError("Account parameter is required") + + endpoint = f"{self.base_path}/Pools/{account}/Loans" + response_data = self.client.get(endpoint) + return cast(List[Any], response_data.get("Data", [])) + + def get_pool_bank_accounts(self, account: str) -> list: + """Get pool bank accounts by account. + + Args: + account: The pool account identifier + + Returns: + List of pool bank accounts + + Raises: + APIError: If the API returns an error + ValidationError: If account is invalid + """ + if not account: + from ..exceptions import ValidationError + + raise ValidationError("Account parameter is required") + + endpoint = f"{self.base_path}/Pools/{account}/BankAccounts" + response_data = self.client.get(endpoint) + return cast(List[Any], response_data.get("Data", [])) + + def get_pool_attachments(self, account: str) -> list: + """Get pool attachments by account. + + Args: + account: The pool account identifier + + Returns: + List of pool attachments + + Raises: + APIError: If the API returns an error + ValidationError: If account is invalid + """ + if not account: + from ..exceptions import ValidationError + + raise ValidationError("Account parameter is required") + + endpoint = f"{self.base_path}/Pools/{account}/Attachments" + response_data = self.client.get(endpoint) + return cast(List[Any], response_data.get("Data", [])) + + def list_all(self) -> List[Pool]: + """List all pools. + + Returns: + List of all pools + + Raises: + APIError: If the API returns an error + """ + endpoint = f"{self.base_path}/Pools" + response_data = self.client.get(endpoint) + response = PoolsResponse(response_data) + return response.pools diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..cea5f82 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for tmo_api package.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a518a5d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,57 @@ +"""Pytest configuration and fixtures.""" + +import pytest + + +@pytest.fixture +def mock_token(): + """Mock API token for testing.""" + return "test_token_12345" + + +@pytest.fixture +def mock_database(): + """Mock database name for testing.""" + return "test_database" + + +@pytest.fixture +def mock_pool_account(): + """Mock pool account for testing.""" + return "POOL001" + + +@pytest.fixture +def mock_api_response_success(): + """Mock successful API response.""" + return { + "Status": 0, + "ErrorMessage": None, + "ErrorNumber": None, + "Data": {"rec_id": 1, "account": "POOL001", "name": "Test Pool"}, + } + + +@pytest.fixture +def mock_api_response_error(): + """Mock error API response.""" + return { + "Status": 1, + "ErrorMessage": "Test error message", + "ErrorNumber": 500, + "Data": None, + } + + +@pytest.fixture +def mock_pools_response(): + """Mock pools list response.""" + return { + "Status": 0, + "ErrorMessage": None, + "ErrorNumber": None, + "Data": [ + {"rec_id": 1, "account": "POOL001", "name": "Pool 1"}, + {"rec_id": 2, "account": "POOL002", "name": "Pool 2"}, + ], + } diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..1cf7b1a --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,265 @@ +"""Tests for TMOClient.""" + +from unittest.mock import MagicMock, Mock, patch + +import pytest +import requests + +from tmo_api.client import TMOClient +from tmo_api.environments import Environment +from tmo_api.exceptions import ( + APIError, + AuthenticationError, + NetworkError, +) + + +class TestClientInitialization: + """Test client initialization.""" + + def test_client_init_with_defaults(self, mock_token, mock_database): + """Test client initialization with default values.""" + client = TMOClient(token=mock_token, database=mock_database) + assert client.token == mock_token + assert client.database == mock_database + assert client.base_url == Environment.US.value + assert client.timeout == 30 + assert client.debug is False + + def test_client_init_with_environment_enum(self, mock_token, mock_database): + """Test client initialization with environment enum.""" + client = TMOClient( + token=mock_token, + database=mock_database, + environment=Environment.CANADA, + ) + assert client.base_url == Environment.CANADA.value + + def test_client_init_with_custom_url(self, mock_token, mock_database): + """Test client initialization with custom URL string.""" + custom_url = "https://custom-api.example.com" + client = TMOClient( + token=mock_token, + database=mock_database, + environment=custom_url, + ) + assert client.base_url == custom_url + + def test_client_init_with_custom_timeout(self, mock_token, mock_database): + """Test client initialization with custom timeout.""" + client = TMOClient( + token=mock_token, + database=mock_database, + timeout=60, + ) + assert client.timeout == 60 + + def test_client_init_with_debug(self, mock_token, mock_database): + """Test client initialization with debug mode.""" + client = TMOClient( + token=mock_token, + database=mock_database, + debug=True, + ) + assert client.debug is True + + def test_client_session_headers(self, mock_token, mock_database): + """Test session headers are set correctly.""" + client = TMOClient(token=mock_token, database=mock_database) + assert client.session.headers["Token"] == mock_token + assert client.session.headers["Database"] == mock_database + assert client.session.headers["Content-Type"] == "application/json" + assert "User-Agent" in client.session.headers + + def test_client_resources_initialized(self, mock_token, mock_database): + """Test that all resource objects are initialized.""" + client = TMOClient(token=mock_token, database=mock_database) + + # Shares resources + assert hasattr(client, "shares_pools") + assert hasattr(client, "shares_partners") + assert hasattr(client, "shares_distributions") + assert hasattr(client, "shares_certificates") + assert hasattr(client, "shares_history") + + # Capital resources + assert hasattr(client, "capital_pools") + assert hasattr(client, "capital_partners") + assert hasattr(client, "capital_distributions") + assert hasattr(client, "capital_history") + + +class TestClientRequests: + """Test client HTTP request methods.""" + + @patch("tmo_api.client.requests.Session.request") + def test_get_request_success( + self, mock_request, mock_token, mock_database, mock_api_response_success + ): + """Test successful GET request.""" + mock_response = Mock() + mock_response.json.return_value = mock_api_response_success + mock_response.status_code = 200 + mock_request.return_value = mock_response + + client = TMOClient(token=mock_token, database=mock_database) + result = client.get("test/endpoint") + + assert result == mock_api_response_success + mock_request.assert_called_once() + + @patch("tmo_api.client.requests.Session.request") + def test_post_request_success( + self, mock_request, mock_token, mock_database, mock_api_response_success + ): + """Test successful POST request.""" + mock_response = Mock() + mock_response.json.return_value = mock_api_response_success + mock_response.status_code = 200 + mock_request.return_value = mock_response + + client = TMOClient(token=mock_token, database=mock_database) + result = client.post("test/endpoint", json={"key": "value"}) + + assert result == mock_api_response_success + + @patch("tmo_api.client.requests.Session.request") + def test_put_request_success( + self, mock_request, mock_token, mock_database, mock_api_response_success + ): + """Test successful PUT request.""" + mock_response = Mock() + mock_response.json.return_value = mock_api_response_success + mock_response.status_code = 200 + mock_request.return_value = mock_response + + client = TMOClient(token=mock_token, database=mock_database) + result = client.put("test/endpoint", json={"key": "value"}) + + assert result == mock_api_response_success + + @patch("tmo_api.client.requests.Session.request") + def test_delete_request_success( + self, mock_request, mock_token, mock_database, mock_api_response_success + ): + """Test successful DELETE request.""" + mock_response = Mock() + mock_response.json.return_value = mock_api_response_success + mock_response.status_code = 200 + mock_request.return_value = mock_response + + client = TMOClient(token=mock_token, database=mock_database) + result = client.delete("test/endpoint") + + assert result == mock_api_response_success + + +class TestClientErrors: + """Test client error handling.""" + + @patch("tmo_api.client.requests.Session.request") + def test_api_error_response( + self, mock_request, mock_token, mock_database, mock_api_response_error + ): + """Test handling of API error response.""" + mock_response = Mock() + mock_response.json.return_value = mock_api_response_error + mock_response.status_code = 200 + mock_request.return_value = mock_response + + client = TMOClient(token=mock_token, database=mock_database) + + with pytest.raises(APIError) as exc_info: + client.get("test/endpoint") + + assert str(exc_info.value) == "Test error message" + assert exc_info.value.error_number == 500 + + @patch("tmo_api.client.requests.Session.request") + def test_authentication_error_401(self, mock_request, mock_token, mock_database): + """Test handling of 401 authentication error.""" + mock_response = Mock() + mock_response.status_code = 401 + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError() + mock_request.return_value = mock_response + + client = TMOClient(token=mock_token, database=mock_database) + + with pytest.raises(AuthenticationError) as exc_info: + client.get("test/endpoint") + + assert "Invalid token or database" in str(exc_info.value) + + @patch("tmo_api.client.requests.Session.request") + def test_authentication_error_403(self, mock_request, mock_token, mock_database): + """Test handling of 403 forbidden error.""" + mock_response = Mock() + mock_response.status_code = 403 + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError() + mock_request.return_value = mock_response + + client = TMOClient(token=mock_token, database=mock_database) + + with pytest.raises(AuthenticationError) as exc_info: + client.get("test/endpoint") + + assert "Access denied" in str(exc_info.value) + + @patch("tmo_api.client.requests.Session.request") + def test_timeout_error(self, mock_request, mock_token, mock_database): + """Test handling of timeout error.""" + mock_request.side_effect = requests.exceptions.Timeout() + + client = TMOClient(token=mock_token, database=mock_database) + + with pytest.raises(NetworkError) as exc_info: + client.get("test/endpoint") + + assert "timed out" in str(exc_info.value) + + @patch("tmo_api.client.requests.Session.request") + def test_connection_error(self, mock_request, mock_token, mock_database): + """Test handling of connection error.""" + mock_request.side_effect = requests.exceptions.ConnectionError() + + client = TMOClient(token=mock_token, database=mock_database) + + with pytest.raises(NetworkError) as exc_info: + client.get("test/endpoint") + + assert "Connection error" in str(exc_info.value) + + @patch("tmo_api.client.requests.Session.request") + def test_invalid_json_response(self, mock_request, mock_token, mock_database): + """Test handling of invalid JSON response.""" + mock_response = Mock() + mock_response.json.side_effect = ValueError("Invalid JSON") + mock_response.status_code = 200 + mock_request.return_value = mock_response + + client = TMOClient(token=mock_token, database=mock_database) + + with pytest.raises(APIError) as exc_info: + client.get("test/endpoint") + + assert "Invalid JSON" in str(exc_info.value) + + +class TestClientDebug: + """Test client debug logging.""" + + def test_debug_log_disabled(self, mock_token, mock_database, capsys): + """Test debug logging is disabled by default.""" + client = TMOClient(token=mock_token, database=mock_database, debug=False) + client._debug_log("Test message") + + captured = capsys.readouterr() + assert "Test message" not in captured.err + + def test_debug_log_enabled(self, mock_token, mock_database, capsys): + """Test debug logging when enabled.""" + client = TMOClient(token=mock_token, database=mock_database, debug=True) + client._debug_log("Test message") + + captured = capsys.readouterr() + assert "DEBUG: Test message" in captured.err diff --git a/tests/test_environments.py b/tests/test_environments.py new file mode 100644 index 0000000..8831b35 --- /dev/null +++ b/tests/test_environments.py @@ -0,0 +1,36 @@ +"""Tests for environment configurations.""" + +import pytest + +from tmo_api.environments import DEFAULT_ENVIRONMENT, Environment + + +class TestEnvironments: + """Test environment configurations.""" + + def test_us_environment(self): + """Test US environment URL.""" + assert Environment.US.value == "https://api.themortgageoffice.com" + + def test_canada_environment(self): + """Test Canada environment URL.""" + assert Environment.CANADA.value == "https://api-ca.themortgageoffice.com" + + def test_australia_environment(self): + """Test Australia environment URL.""" + assert Environment.AUSTRALIA.value == "https://api-aus.themortgageoffice.com" + + def test_default_environment(self): + """Test default environment is US.""" + assert DEFAULT_ENVIRONMENT == Environment.US + + def test_environment_enum_members(self): + """Test all expected environment members exist.""" + expected_members = {"US", "CANADA", "AUSTRALIA"} + actual_members = {env.name for env in Environment} + assert expected_members == actual_members + + def test_environment_values_are_https(self): + """Test all environment URLs use HTTPS.""" + for env in Environment: + assert env.value.startswith("https://") diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..57235d0 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,60 @@ +"""Tests for exception classes.""" + +import pytest + +from tmo_api.exceptions import ( + APIError, + AuthenticationError, + NetworkError, + TMOException, + ValidationError, +) + + +class TestExceptions: + """Test custom exception classes.""" + + def test_base_exception(self): + """Test TMOException base exception.""" + error = TMOException("Test error", error_number=123) + assert str(error) == "Test error" + assert error.message == "Test error" + assert error.error_number == 123 + + def test_base_exception_without_error_number(self): + """Test base exception without error number.""" + error = TMOException("Test error") + assert error.message == "Test error" + assert error.error_number is None + + def test_authentication_error(self): + """Test AuthenticationError.""" + error = AuthenticationError("Invalid credentials") + assert isinstance(error, TMOException) + assert str(error) == "Invalid credentials" + + def test_api_error(self): + """Test APIError.""" + error = APIError("API returned error", error_number=500) + assert isinstance(error, TMOException) + assert error.message == "API returned error" + assert error.error_number == 500 + + def test_validation_error(self): + """Test ValidationError.""" + error = ValidationError("Invalid input") + assert isinstance(error, TMOException) + assert str(error) == "Invalid input" + + def test_network_error(self): + """Test NetworkError.""" + error = NetworkError("Connection failed") + assert isinstance(error, TMOException) + assert str(error) == "Connection failed" + + def test_exception_inheritance(self): + """Test that all custom exceptions inherit from base exception.""" + assert issubclass(AuthenticationError, TMOException) + assert issubclass(APIError, TMOException) + assert issubclass(ValidationError, TMOException) + assert issubclass(NetworkError, TMOException) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..0d26378 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,84 @@ +"""Tests for data models.""" + +from datetime import datetime + +import pytest + +from tmo_api.models import BaseModel, BaseResponse + + +class TestBaseResponse: + """Test BaseResponse model.""" + + def test_base_response_success(self, mock_api_response_success): + """Test BaseResponse with successful response.""" + response = BaseResponse(mock_api_response_success) + assert response.status == 0 + assert response.error_message is None + assert response.error_number is None + assert response.data == mock_api_response_success["Data"] + assert response.raw_data == mock_api_response_success + + def test_base_response_error(self, mock_api_response_error): + """Test BaseResponse with error response.""" + response = BaseResponse(mock_api_response_error) + assert response.status == 1 + assert response.error_message == "Test error message" + assert response.error_number == 500 + assert response.data == {} + + def test_base_response_repr(self, mock_api_response_success): + """Test BaseResponse string representation.""" + response = BaseResponse(mock_api_response_success) + assert repr(response) == "BaseResponse(status=0)" + + +class TestBaseModel: + """Test BaseModel.""" + + def test_base_model_initialization(self): + """Test BaseModel initialization with data.""" + data = {"rec_id": 123, "account": "TEST001", "name": "Test"} + model = BaseModel(data) + assert model.rec_id == 123 + assert model.account == "TEST001" + assert model.name == "Test" + assert model.raw_data == data + + def test_base_model_repr(self): + """Test BaseModel string representation.""" + data = {"rec_id": 456} + model = BaseModel(data) + assert repr(model) == "BaseModel(456)" + + def test_base_model_repr_without_rec_id(self): + """Test BaseModel repr without rec_id.""" + data = {"account": "TEST001"} + model = BaseModel(data) + assert repr(model) == "BaseModel(unknown)" + + def test_to_snake_case(self): + """Test CamelCase to snake_case conversion.""" + model = BaseModel({}) + assert model._to_snake_case("CamelCase") == "camel_case" + assert model._to_snake_case("HTTPResponse") == "h_t_t_p_response" + assert model._to_snake_case("ID") == "i_d" + assert model._to_snake_case("lowercase") == "lowercase" + + def test_parse_date_valid_formats(self): + """Test date parsing with valid formats.""" + model = BaseModel({}) + + # Test various date formats + assert model._parse_date("12/31/2023") == datetime(2023, 12, 31) + assert model._parse_date("12/31/2023 14:30:00") == datetime(2023, 12, 31, 14, 30, 0) + assert model._parse_date("2023-12-31T14:30:00") == datetime(2023, 12, 31, 14, 30, 0) + assert model._parse_date("2023-12-31 14:30:00") == datetime(2023, 12, 31, 14, 30, 0) + assert model._parse_date("2023-12-31") == datetime(2023, 12, 31) + + def test_parse_date_invalid(self): + """Test date parsing with invalid format.""" + model = BaseModel({}) + assert model._parse_date("invalid-date") is None + assert model._parse_date(None) is None + assert model._parse_date("") is None diff --git a/tests/test_models_pool.py b/tests/test_models_pool.py new file mode 100644 index 0000000..3a25245 --- /dev/null +++ b/tests/test_models_pool.py @@ -0,0 +1,206 @@ +"""Tests for Pool models.""" + +from datetime import datetime + +import pytest + +from tmo_api.models.pool import OtherAsset, OtherLiability, Pool, PoolResponse, PoolsResponse + + +class TestOtherAsset: + """Test OtherAsset model.""" + + def test_other_asset_initialization(self): + """Test OtherAsset initialization.""" + data = { + "rec_id": 123, + "Description": "Test Asset", + "Value": 10000.00, + "DateLastEvaluated": "12/31/2024", + } + asset = OtherAsset(data) + + assert asset.rec_id == 123 + assert asset.Description == "Test Asset" + assert asset.Value == 10000.00 + assert isinstance(asset.DateLastEvaluated, datetime) + assert asset.DateLastEvaluated == datetime(2024, 12, 31) + + def test_other_asset_without_date(self): + """Test OtherAsset without date.""" + data = {"rec_id": 456, "Description": "Asset without date"} + asset = OtherAsset(data) + + assert asset.rec_id == 456 + assert asset.Description == "Asset without date" + + +class TestOtherLiability: + """Test OtherLiability model.""" + + def test_other_liability_initialization(self): + """Test OtherLiability initialization.""" + data = { + "rec_id": 789, + "Description": "Test Liability", + "Balance": 50000.00, + "MaturityDate": "06/30/2025", + "PaymentNextDue": "01/15/2025", + } + liability = OtherLiability(data) + + assert liability.rec_id == 789 + assert liability.Description == "Test Liability" + assert liability.Balance == 50000.00 + assert isinstance(liability.MaturityDate, datetime) + assert liability.MaturityDate == datetime(2025, 6, 30) + assert isinstance(liability.PaymentNextDue, datetime) + assert liability.PaymentNextDue == datetime(2025, 1, 15) + + def test_other_liability_without_dates(self): + """Test OtherLiability without dates.""" + data = {"rec_id": 101, "Description": "Liability without dates"} + liability = OtherLiability(data) + + assert liability.rec_id == 101 + assert liability.Description == "Liability without dates" + + +class TestPool: + """Test Pool model.""" + + def test_pool_initialization(self): + """Test Pool initialization with basic data.""" + data = { + "rec_id": 1, + "Account": "POOL001", + "Name": "Test Pool", + "InceptionDate": "01/01/2024", + "LastEvaluation": "12/31/2024", + "SysTimeStamp": "11/15/2024", + } + pool = Pool(data) + + assert pool.rec_id == 1 + assert pool.Account == "POOL001" + assert pool.Name == "Test Pool" + assert isinstance(pool.InceptionDate, datetime) + assert pool.InceptionDate == datetime(2024, 1, 1) + assert isinstance(pool.LastEvaluation, datetime) + assert pool.LastEvaluation == datetime(2024, 12, 31) + assert isinstance(pool.SysTimeStamp, datetime) + assert pool.SysTimeStamp == datetime(2024, 11, 15) + + def test_pool_with_nested_objects(self): + """Test Pool with nested OtherAssets and OtherLiabilities.""" + data = { + "rec_id": 2, + "Account": "POOL002", + "OtherAssets": [ + {"rec_id": 10, "Description": "Asset 1", "Value": 1000}, + {"rec_id": 11, "Description": "Asset 2", "Value": 2000}, + ], + "OtherLiabilities": [ + {"rec_id": 20, "Description": "Liability 1", "Balance": 5000}, + ], + } + pool = Pool(data) + + assert pool.rec_id == 2 + assert len(pool.OtherAssets) == 2 + assert isinstance(pool.OtherAssets[0], OtherAsset) + assert pool.OtherAssets[0].Description == "Asset 1" + assert pool.OtherAssets[1].Description == "Asset 2" + + assert len(pool.OtherLiabilities) == 1 + assert isinstance(pool.OtherLiabilities[0], OtherLiability) + assert pool.OtherLiabilities[0].Description == "Liability 1" + + def test_pool_repr(self): + """Test Pool string representation.""" + data = {"rec_id": 999, "Account": "POOL999"} + pool = Pool(data) + assert repr(pool) == "Pool(999)" + + +class TestPoolResponse: + """Test PoolResponse model.""" + + def test_pool_response_with_data(self): + """Test PoolResponse with pool data.""" + response_data = { + "Status": 0, + "Data": { + "rec_id": 1, + "Account": "POOL001", + "Name": "Test Pool", + }, + } + response = PoolResponse(response_data) + + assert response.status == 0 + assert response.pool is not None + assert isinstance(response.pool, Pool) + assert response.pool.Account == "POOL001" + + def test_pool_response_without_data(self): + """Test PoolResponse without pool data.""" + response_data = {"Status": 1, "ErrorMessage": "Not found", "ErrorNumber": 404} + response = PoolResponse(response_data) + + assert response.status == 1 + assert response.pool is None + assert response.error_message == "Not found" + + def test_pool_response_repr(self): + """Test PoolResponse string representation.""" + response_data = {"Status": 0, "Data": {"rec_id": 1}} + response = PoolResponse(response_data) + assert repr(response) == "PoolResponse(status=0)" + + +class TestPoolsResponse: + """Test PoolsResponse model.""" + + def test_pools_response_with_list(self): + """Test PoolsResponse with list of pools.""" + response_data = { + "Status": 0, + "Data": [ + {"rec_id": 1, "Account": "POOL001"}, + {"rec_id": 2, "Account": "POOL002"}, + {"rec_id": 3, "Account": "POOL003"}, + ], + } + response = PoolsResponse(response_data) + + assert response.status == 0 + assert len(response.pools) == 3 + assert all(isinstance(pool, Pool) for pool in response.pools) + assert response.pools[0].Account == "POOL001" + assert response.pools[1].Account == "POOL002" + assert response.pools[2].Account == "POOL003" + + def test_pools_response_with_single_pool(self): + """Test PoolsResponse with single pool dict.""" + response_data = {"Status": 0, "Data": {"rec_id": 1, "Account": "POOL001"}} + response = PoolsResponse(response_data) + + assert response.status == 0 + assert len(response.pools) == 1 + assert isinstance(response.pools[0], Pool) + assert response.pools[0].Account == "POOL001" + + def test_pools_response_empty(self): + """Test PoolsResponse with empty data.""" + response_data = {"Status": 0, "Data": []} + response = PoolsResponse(response_data) + + assert response.status == 0 + assert len(response.pools) == 0 + + def test_pools_response_repr(self): + """Test PoolsResponse string representation.""" + response_data = {"Status": 0, "Data": []} + response = PoolsResponse(response_data) + assert repr(response) == "PoolsResponse(status=0)" diff --git a/tests/test_resources_certificates.py b/tests/test_resources_certificates.py new file mode 100644 index 0000000..0d5ba5d --- /dev/null +++ b/tests/test_resources_certificates.py @@ -0,0 +1,144 @@ +"""Tests for CertificatesResource.""" + +from unittest.mock import patch + +import pytest + +from tmo_api.client import TMOClient +from tmo_api.exceptions import ValidationError +from tmo_api.resources.certificates import CertificatesResource +from tmo_api.resources.pools import PoolType + + +class TestCertificatesResource: + """Test CertificatesResource functionality.""" + + @pytest.fixture + def client(self, mock_token, mock_database): + """Create a test client.""" + return TMOClient(token=mock_token, database=mock_database) + + def test_certificates_resource_init_shares(self, client): + """Test CertificatesResource initialization with Shares type.""" + resource = CertificatesResource(client, PoolType.SHARES) + assert resource.client == client + assert resource.pool_type == PoolType.SHARES + assert resource.base_path == "LSS.svc/Shares" + + def test_certificates_resource_init_capital(self, client): + """Test CertificatesResource initialization with Capital type.""" + resource = CertificatesResource(client, PoolType.CAPITAL) + assert resource.pool_type == PoolType.CAPITAL + assert resource.base_path == "LSS.svc/Capital" + + @patch.object(TMOClient, "get") + def test_get_certificates_no_filters(self, mock_get, client): + """Test get_certificates without filters.""" + mock_get.return_value = {"Status": 0, "Data": [{"certificate_id": 1}]} + resource = CertificatesResource(client, PoolType.SHARES) + + certificates = resource.get_certificates() + + mock_get.assert_called_once_with("LSS.svc/Shares/Certificates", params=None) + assert isinstance(certificates, list) + + @patch.object(TMOClient, "get") + def test_get_certificates_with_date_filters(self, mock_get, client): + """Test get_certificates with date filters.""" + mock_get.return_value = {"Status": 0, "Data": [{"certificate_id": 1}]} + resource = CertificatesResource(client, PoolType.SHARES) + + certificates = resource.get_certificates(start_date="01/01/2024", end_date="12/31/2024") + + mock_get.assert_called_once_with( + "LSS.svc/Shares/Certificates", + params={"from-date": "01/01/2024", "to-date": "12/31/2024"}, + ) + assert isinstance(certificates, list) + + @patch.object(TMOClient, "get") + def test_get_certificates_with_partner_account(self, mock_get, client): + """Test get_certificates with partner_account filter.""" + mock_get.return_value = {"Status": 0, "Data": [{"certificate_id": 1}]} + resource = CertificatesResource(client, PoolType.SHARES) + + certificates = resource.get_certificates(partner_account="PARTNER001") + + mock_get.assert_called_once_with( + "LSS.svc/Shares/Certificates", params={"partner-account": "PARTNER001"} + ) + assert isinstance(certificates, list) + + @patch.object(TMOClient, "get") + def test_get_certificates_with_pool_account(self, mock_get, client): + """Test get_certificates with pool_account filter.""" + mock_get.return_value = {"Status": 0, "Data": [{"certificate_id": 1}]} + resource = CertificatesResource(client, PoolType.SHARES) + + certificates = resource.get_certificates(pool_account="POOL001") + + mock_get.assert_called_once_with( + "LSS.svc/Shares/Certificates", params={"pool-account": "POOL001"} + ) + assert isinstance(certificates, list) + + @patch.object(TMOClient, "get") + def test_get_certificates_with_all_filters(self, mock_get, client): + """Test get_certificates with all filters.""" + mock_get.return_value = {"Status": 0, "Data": [{"certificate_id": 1}]} + resource = CertificatesResource(client, PoolType.SHARES) + + certificates = resource.get_certificates( + start_date="01/01/2024", + end_date="12/31/2024", + partner_account="PARTNER001", + pool_account="POOL001", + ) + + mock_get.assert_called_once_with( + "LSS.svc/Shares/Certificates", + params={ + "from-date": "01/01/2024", + "to-date": "12/31/2024", + "partner-account": "PARTNER001", + "pool-account": "POOL001", + }, + ) + assert isinstance(certificates, list) + + @patch.object(TMOClient, "get") + def test_get_certificates_invalid_start_date(self, mock_get, client): + """Test get_certificates with invalid start_date format.""" + resource = CertificatesResource(client, PoolType.SHARES) + + with pytest.raises(ValidationError) as exc_info: + resource.get_certificates(start_date="2024-01-01") + + assert "start_date must be in MM/DD/YYYY format" in str(exc_info.value) + mock_get.assert_not_called() + + @patch.object(TMOClient, "get") + def test_get_certificates_invalid_end_date(self, mock_get, client): + """Test get_certificates with invalid end_date format.""" + resource = CertificatesResource(client, PoolType.SHARES) + + with pytest.raises(ValidationError) as exc_info: + resource.get_certificates(end_date="invalid-date") + + assert "end_date must be in MM/DD/YYYY format" in str(exc_info.value) + mock_get.assert_not_called() + + def test_validate_date_format_valid(self, client): + """Test _validate_date_format with valid date.""" + resource = CertificatesResource(client, PoolType.SHARES) + + assert resource._validate_date_format("12/31/2024") is True + assert resource._validate_date_format("01/01/2024") is True + + def test_validate_date_format_invalid(self, client): + """Test _validate_date_format with invalid dates.""" + resource = CertificatesResource(client, PoolType.SHARES) + + assert resource._validate_date_format("2024-12-31") is False + assert resource._validate_date_format("31/12/2024") is False + assert resource._validate_date_format("invalid") is False diff --git a/tests/test_resources_distributions.py b/tests/test_resources_distributions.py new file mode 100644 index 0000000..07f1c35 --- /dev/null +++ b/tests/test_resources_distributions.py @@ -0,0 +1,149 @@ +"""Tests for DistributionsResource.""" + +from unittest.mock import patch + +import pytest + +from tmo_api.client import TMOClient +from tmo_api.exceptions import ValidationError +from tmo_api.resources.distributions import DistributionsResource +from tmo_api.resources.pools import PoolType + + +class TestDistributionsResource: + """Test DistributionsResource functionality.""" + + @pytest.fixture + def client(self, mock_token, mock_database): + """Create a test client.""" + return TMOClient(token=mock_token, database=mock_database) + + def test_distributions_resource_init_shares(self, client): + """Test DistributionsResource initialization with Shares type.""" + resource = DistributionsResource(client, PoolType.SHARES) + assert resource.client == client + assert resource.pool_type == PoolType.SHARES + assert resource.base_path == "LSS.svc/Shares" + + def test_distributions_resource_init_capital(self, client): + """Test DistributionsResource initialization with Capital type.""" + resource = DistributionsResource(client, PoolType.CAPITAL) + assert resource.pool_type == PoolType.CAPITAL + assert resource.base_path == "LSS.svc/Capital" + + @patch.object(TMOClient, "get") + def test_get_distribution_success(self, mock_get, client, mock_api_response_success): + """Test successful get_distribution call.""" + mock_get.return_value = mock_api_response_success + resource = DistributionsResource(client, PoolType.SHARES) + + distribution = resource.get_distribution("12345") + + mock_get.assert_called_once_with("LSS.svc/Shares/Distributions/12345") + assert distribution is not None + + @patch.object(TMOClient, "get") + def test_get_distribution_empty_rec_id(self, mock_get, client): + """Test get_distribution with empty rec_id raises ValidationError.""" + resource = DistributionsResource(client, PoolType.SHARES) + + with pytest.raises(ValidationError) as exc_info: + resource.get_distribution("") + + assert "RecID parameter is required" in str(exc_info.value) + mock_get.assert_not_called() + + @patch.object(TMOClient, "get") + def test_list_all_no_filters(self, mock_get, client): + """Test list_all without filters.""" + mock_get.return_value = {"Status": 0, "Data": [{"distribution_id": 1}]} + resource = DistributionsResource(client, PoolType.SHARES) + + distributions = resource.list_all() + + mock_get.assert_called_once_with("LSS.svc/Shares/Distributions", params=None) + assert isinstance(distributions, list) + + @patch.object(TMOClient, "get") + def test_list_all_with_date_filters(self, mock_get, client): + """Test list_all with date filters.""" + mock_get.return_value = {"Status": 0, "Data": [{"distribution_id": 1}]} + resource = DistributionsResource(client, PoolType.SHARES) + + distributions = resource.list_all(start_date="01/01/2024", end_date="12/31/2024") + + mock_get.assert_called_once_with( + "LSS.svc/Shares/Distributions", + params={"from-date": "01/01/2024", "to-date": "12/31/2024"}, + ) + assert isinstance(distributions, list) + + @patch.object(TMOClient, "get") + def test_list_all_with_pool_account(self, mock_get, client): + """Test list_all with pool_account filter.""" + mock_get.return_value = {"Status": 0, "Data": [{"distribution_id": 1}]} + resource = DistributionsResource(client, PoolType.SHARES) + + distributions = resource.list_all(pool_account="POOL001") + + mock_get.assert_called_once_with( + "LSS.svc/Shares/Distributions", params={"pool-account": "POOL001"} + ) + assert isinstance(distributions, list) + + @patch.object(TMOClient, "get") + def test_list_all_with_all_filters(self, mock_get, client): + """Test list_all with all filters.""" + mock_get.return_value = {"Status": 0, "Data": [{"distribution_id": 1}]} + resource = DistributionsResource(client, PoolType.SHARES) + + distributions = resource.list_all( + start_date="01/01/2024", end_date="12/31/2024", pool_account="POOL001" + ) + + mock_get.assert_called_once_with( + "LSS.svc/Shares/Distributions", + params={ + "from-date": "01/01/2024", + "to-date": "12/31/2024", + "pool-account": "POOL001", + }, + ) + assert isinstance(distributions, list) + + @patch.object(TMOClient, "get") + def test_list_all_invalid_start_date(self, mock_get, client): + """Test list_all with invalid start_date format.""" + resource = DistributionsResource(client, PoolType.SHARES) + + with pytest.raises(ValidationError) as exc_info: + resource.list_all(start_date="2024-01-01") + + assert "start_date must be in MM/DD/YYYY format" in str(exc_info.value) + mock_get.assert_not_called() + + @patch.object(TMOClient, "get") + def test_list_all_invalid_end_date(self, mock_get, client): + """Test list_all with invalid end_date format.""" + resource = DistributionsResource(client, PoolType.SHARES) + + with pytest.raises(ValidationError) as exc_info: + resource.list_all(end_date="invalid-date") + + assert "end_date must be in MM/DD/YYYY format" in str(exc_info.value) + mock_get.assert_not_called() + + def test_validate_date_format_valid(self, client): + """Test _validate_date_format with valid date.""" + resource = DistributionsResource(client, PoolType.SHARES) + + assert resource._validate_date_format("12/31/2024") is True + assert resource._validate_date_format("01/01/2024") is True + + def test_validate_date_format_invalid(self, client): + """Test _validate_date_format with invalid dates.""" + resource = DistributionsResource(client, PoolType.SHARES) + + assert resource._validate_date_format("2024-12-31") is False + assert resource._validate_date_format("31/12/2024") is False + assert resource._validate_date_format("invalid") is False diff --git a/tests/test_resources_history.py b/tests/test_resources_history.py new file mode 100644 index 0000000..fcc1bb2 --- /dev/null +++ b/tests/test_resources_history.py @@ -0,0 +1,144 @@ +"""Tests for HistoryResource.""" + +from unittest.mock import patch + +import pytest + +from tmo_api.client import TMOClient +from tmo_api.exceptions import ValidationError +from tmo_api.resources.history import HistoryResource +from tmo_api.resources.pools import PoolType + + +class TestHistoryResource: + """Test HistoryResource functionality.""" + + @pytest.fixture + def client(self, mock_token, mock_database): + """Create a test client.""" + return TMOClient(token=mock_token, database=mock_database) + + def test_history_resource_init_shares(self, client): + """Test HistoryResource initialization with Shares type.""" + resource = HistoryResource(client, PoolType.SHARES) + assert resource.client == client + assert resource.pool_type == PoolType.SHARES + assert resource.base_path == "LSS.svc/Shares" + + def test_history_resource_init_capital(self, client): + """Test HistoryResource initialization with Capital type.""" + resource = HistoryResource(client, PoolType.CAPITAL) + assert resource.pool_type == PoolType.CAPITAL + assert resource.base_path == "LSS.svc/Capital" + + @patch.object(TMOClient, "get") + def test_get_history_no_filters(self, mock_get, client): + """Test get_history without filters.""" + mock_get.return_value = {"Status": 0, "Data": [{"history_id": 1}]} + resource = HistoryResource(client, PoolType.SHARES) + + history = resource.get_history() + + mock_get.assert_called_once_with("LSS.svc/Shares/History", params=None) + assert isinstance(history, list) + + @patch.object(TMOClient, "get") + def test_get_history_with_date_filters(self, mock_get, client): + """Test get_history with date filters.""" + mock_get.return_value = {"Status": 0, "Data": [{"history_id": 1}]} + resource = HistoryResource(client, PoolType.SHARES) + + history = resource.get_history(start_date="01/01/2024", end_date="12/31/2024") + + mock_get.assert_called_once_with( + "LSS.svc/Shares/History", + params={"from-date": "01/01/2024", "to-date": "12/31/2024"}, + ) + assert isinstance(history, list) + + @patch.object(TMOClient, "get") + def test_get_history_with_partner_account(self, mock_get, client): + """Test get_history with partner_account filter.""" + mock_get.return_value = {"Status": 0, "Data": [{"history_id": 1}]} + resource = HistoryResource(client, PoolType.SHARES) + + history = resource.get_history(partner_account="PARTNER001") + + mock_get.assert_called_once_with( + "LSS.svc/Shares/History", params={"partner-account": "PARTNER001"} + ) + assert isinstance(history, list) + + @patch.object(TMOClient, "get") + def test_get_history_with_pool_account(self, mock_get, client): + """Test get_history with pool_account filter.""" + mock_get.return_value = {"Status": 0, "Data": [{"history_id": 1}]} + resource = HistoryResource(client, PoolType.SHARES) + + history = resource.get_history(pool_account="POOL001") + + mock_get.assert_called_once_with( + "LSS.svc/Shares/History", params={"pool-account": "POOL001"} + ) + assert isinstance(history, list) + + @patch.object(TMOClient, "get") + def test_get_history_with_all_filters(self, mock_get, client): + """Test get_history with all filters.""" + mock_get.return_value = {"Status": 0, "Data": [{"history_id": 1}]} + resource = HistoryResource(client, PoolType.SHARES) + + history = resource.get_history( + start_date="01/01/2024", + end_date="12/31/2024", + partner_account="PARTNER001", + pool_account="POOL001", + ) + + mock_get.assert_called_once_with( + "LSS.svc/Shares/History", + params={ + "from-date": "01/01/2024", + "to-date": "12/31/2024", + "partner-account": "PARTNER001", + "pool-account": "POOL001", + }, + ) + assert isinstance(history, list) + + @patch.object(TMOClient, "get") + def test_get_history_invalid_start_date(self, mock_get, client): + """Test get_history with invalid start_date format.""" + resource = HistoryResource(client, PoolType.SHARES) + + with pytest.raises(ValidationError) as exc_info: + resource.get_history(start_date="2024-01-01") + + assert "start_date must be in MM/DD/YYYY format" in str(exc_info.value) + mock_get.assert_not_called() + + @patch.object(TMOClient, "get") + def test_get_history_invalid_end_date(self, mock_get, client): + """Test get_history with invalid end_date format.""" + resource = HistoryResource(client, PoolType.SHARES) + + with pytest.raises(ValidationError) as exc_info: + resource.get_history(end_date="invalid-date") + + assert "end_date must be in MM/DD/YYYY format" in str(exc_info.value) + mock_get.assert_not_called() + + def test_validate_date_format_valid(self, client): + """Test _validate_date_format with valid date.""" + resource = HistoryResource(client, PoolType.SHARES) + + assert resource._validate_date_format("12/31/2024") is True + assert resource._validate_date_format("01/01/2024") is True + + def test_validate_date_format_invalid(self, client): + """Test _validate_date_format with invalid dates.""" + resource = HistoryResource(client, PoolType.SHARES) + + assert resource._validate_date_format("2024-12-31") is False + assert resource._validate_date_format("31/12/2024") is False + assert resource._validate_date_format("invalid") is False diff --git a/tests/test_resources_partners.py b/tests/test_resources_partners.py new file mode 100644 index 0000000..d1444a7 --- /dev/null +++ b/tests/test_resources_partners.py @@ -0,0 +1,138 @@ +"""Tests for PartnersResource.""" + +from unittest.mock import patch + +import pytest + +from tmo_api.client import TMOClient +from tmo_api.exceptions import ValidationError +from tmo_api.resources.partners import PartnersResource +from tmo_api.resources.pools import PoolType + + +class TestPartnersResource: + """Test PartnersResource functionality.""" + + @pytest.fixture + def client(self, mock_token, mock_database): + """Create a test client.""" + return TMOClient(token=mock_token, database=mock_database) + + def test_partners_resource_init_shares(self, client): + """Test PartnersResource initialization with Shares type.""" + resource = PartnersResource(client, PoolType.SHARES) + assert resource.client == client + assert resource.pool_type == PoolType.SHARES + assert resource.base_path == "LSS.svc/Shares" + + def test_partners_resource_init_capital(self, client): + """Test PartnersResource initialization with Capital type.""" + resource = PartnersResource(client, PoolType.CAPITAL) + assert resource.pool_type == PoolType.CAPITAL + assert resource.base_path == "LSS.svc/Capital" + + @patch.object(TMOClient, "get") + def test_get_partner_success(self, mock_get, client, mock_api_response_success): + """Test successful get_partner call.""" + mock_get.return_value = mock_api_response_success + resource = PartnersResource(client, PoolType.SHARES) + + partner = resource.get_partner("PARTNER001") + + mock_get.assert_called_once_with("LSS.svc/Shares/Partners/PARTNER001") + assert partner is not None + + @patch.object(TMOClient, "get") + def test_get_partner_empty_account(self, mock_get, client): + """Test get_partner with empty account raises ValidationError.""" + resource = PartnersResource(client, PoolType.SHARES) + + with pytest.raises(ValidationError) as exc_info: + resource.get_partner("") + + assert "Account parameter is required" in str(exc_info.value) + mock_get.assert_not_called() + + @patch.object(TMOClient, "get") + def test_get_partner_attachments(self, mock_get, client): + """Test get_partner_attachments.""" + mock_get.return_value = {"Status": 0, "Data": [{"attachment_id": 1}]} + resource = PartnersResource(client, PoolType.SHARES) + + attachments = resource.get_partner_attachments("PARTNER001") + + mock_get.assert_called_once_with("LSS.svc/Shares/Partners/PARTNER001/Attachments") + assert isinstance(attachments, list) + + @patch.object(TMOClient, "get") + def test_get_partner_attachments_empty_account(self, mock_get, client): + """Test get_partner_attachments with empty account raises ValidationError.""" + resource = PartnersResource(client, PoolType.SHARES) + + with pytest.raises(ValidationError) as exc_info: + resource.get_partner_attachments("") + + assert "Account parameter is required" in str(exc_info.value) + mock_get.assert_not_called() + + @patch.object(TMOClient, "get") + def test_list_all_no_filters(self, mock_get, client): + """Test list_all without filters.""" + mock_get.return_value = {"Status": 0, "Data": [{"partner_id": 1}]} + resource = PartnersResource(client, PoolType.SHARES) + + partners = resource.list_all() + + mock_get.assert_called_once_with("LSS.svc/Shares/Partners", params=None) + assert isinstance(partners, list) + + @patch.object(TMOClient, "get") + def test_list_all_with_date_filters(self, mock_get, client): + """Test list_all with date filters.""" + mock_get.return_value = {"Status": 0, "Data": [{"partner_id": 1}]} + resource = PartnersResource(client, PoolType.SHARES) + + partners = resource.list_all(start_date="01/01/2024", end_date="12/31/2024") + + mock_get.assert_called_once_with( + "LSS.svc/Shares/Partners", + params={"from-date": "01/01/2024", "to-date": "12/31/2024"}, + ) + assert isinstance(partners, list) + + @patch.object(TMOClient, "get") + def test_list_all_invalid_start_date(self, mock_get, client): + """Test list_all with invalid start_date format.""" + resource = PartnersResource(client, PoolType.SHARES) + + with pytest.raises(ValidationError) as exc_info: + resource.list_all(start_date="2024-01-01") + + assert "start_date must be in MM/DD/YYYY format" in str(exc_info.value) + mock_get.assert_not_called() + + @patch.object(TMOClient, "get") + def test_list_all_invalid_end_date(self, mock_get, client): + """Test list_all with invalid end_date format.""" + resource = PartnersResource(client, PoolType.SHARES) + + with pytest.raises(ValidationError) as exc_info: + resource.list_all(end_date="invalid-date") + + assert "end_date must be in MM/DD/YYYY format" in str(exc_info.value) + mock_get.assert_not_called() + + def test_validate_date_format_valid(self, client): + """Test _validate_date_format with valid date.""" + resource = PartnersResource(client, PoolType.SHARES) + + assert resource._validate_date_format("12/31/2024") is True + assert resource._validate_date_format("01/01/2024") is True + + def test_validate_date_format_invalid(self, client): + """Test _validate_date_format with invalid dates.""" + resource = PartnersResource(client, PoolType.SHARES) + + assert resource._validate_date_format("2024-12-31") is False + assert resource._validate_date_format("31/12/2024") is False + assert resource._validate_date_format("invalid") is False diff --git a/tests/test_resources_pools.py b/tests/test_resources_pools.py new file mode 100644 index 0000000..3654be9 --- /dev/null +++ b/tests/test_resources_pools.py @@ -0,0 +1,116 @@ +"""Tests for PoolsResource.""" + +from unittest.mock import Mock, patch + +import pytest + +from tmo_api.client import TMOClient +from tmo_api.exceptions import ValidationError +from tmo_api.models.pool import Pool +from tmo_api.resources.pools import PoolsResource, PoolType + + +class TestPoolsResource: + """Test PoolsResource functionality.""" + + @pytest.fixture + def client(self, mock_token, mock_database): + """Create a test client.""" + return TMOClient(token=mock_token, database=mock_database) + + def test_pools_resource_init_shares(self, client): + """Test PoolsResource initialization with Shares type.""" + resource = PoolsResource(client, PoolType.SHARES) + assert resource.client == client + assert resource.pool_type == PoolType.SHARES + assert resource.base_path == "LSS.svc/Shares" + + def test_pools_resource_init_capital(self, client): + """Test PoolsResource initialization with Capital type.""" + resource = PoolsResource(client, PoolType.CAPITAL) + assert resource.pool_type == PoolType.CAPITAL + assert resource.base_path == "LSS.svc/Capital" + + @patch.object(TMOClient, "get") + def test_get_pool_success(self, mock_get, client, mock_pool_account, mock_api_response_success): + """Test successful get_pool call.""" + mock_get.return_value = mock_api_response_success + resource = PoolsResource(client, PoolType.SHARES) + + pool = resource.get_pool(mock_pool_account) + + mock_get.assert_called_once_with(f"LSS.svc/Shares/Pools/{mock_pool_account}") + assert pool is not None + assert isinstance(pool, Pool) + + @patch.object(TMOClient, "get") + def test_get_pool_empty_account(self, mock_get, client): + """Test get_pool with empty account raises ValidationError.""" + resource = PoolsResource(client, PoolType.SHARES) + + with pytest.raises(ValidationError) as exc_info: + resource.get_pool("") + + assert "Account parameter is required" in str(exc_info.value) + mock_get.assert_not_called() + + @patch.object(TMOClient, "get") + def test_get_pool_partners(self, mock_get, client, mock_pool_account): + """Test get_pool_partners.""" + mock_get.return_value = {"Status": 0, "Data": [{"partner_id": 1}]} + resource = PoolsResource(client, PoolType.SHARES) + + partners = resource.get_pool_partners(mock_pool_account) + + mock_get.assert_called_once_with(f"LSS.svc/Shares/Pools/{mock_pool_account}/Partners") + assert isinstance(partners, list) + + @patch.object(TMOClient, "get") + def test_get_pool_loans(self, mock_get, client, mock_pool_account): + """Test get_pool_loans.""" + mock_get.return_value = {"Status": 0, "Data": [{"loan_id": 1}]} + resource = PoolsResource(client, PoolType.SHARES) + + loans = resource.get_pool_loans(mock_pool_account) + + mock_get.assert_called_once_with(f"LSS.svc/Shares/Pools/{mock_pool_account}/Loans") + assert isinstance(loans, list) + + @patch.object(TMOClient, "get") + def test_get_pool_bank_accounts(self, mock_get, client, mock_pool_account): + """Test get_pool_bank_accounts.""" + mock_get.return_value = {"Status": 0, "Data": [{"account_id": 1}]} + resource = PoolsResource(client, PoolType.SHARES) + + accounts = resource.get_pool_bank_accounts(mock_pool_account) + + mock_get.assert_called_once_with(f"LSS.svc/Shares/Pools/{mock_pool_account}/BankAccounts") + assert isinstance(accounts, list) + + @patch.object(TMOClient, "get") + def test_get_pool_attachments(self, mock_get, client, mock_pool_account): + """Test get_pool_attachments.""" + mock_get.return_value = {"Status": 0, "Data": [{"attachment_id": 1}]} + resource = PoolsResource(client, PoolType.SHARES) + + attachments = resource.get_pool_attachments(mock_pool_account) + + mock_get.assert_called_once_with(f"LSS.svc/Shares/Pools/{mock_pool_account}/Attachments") + assert isinstance(attachments, list) + + @patch.object(TMOClient, "get") + def test_list_all_pools(self, mock_get, client, mock_pools_response): + """Test list_all pools.""" + mock_get.return_value = mock_pools_response + resource = PoolsResource(client, PoolType.SHARES) + + pools = resource.list_all() + + mock_get.assert_called_once_with("LSS.svc/Shares/Pools") + assert isinstance(pools, list) + assert all(isinstance(pool, Pool) for pool in pools) + + def test_pool_type_enum(self): + """Test PoolType enum values.""" + assert PoolType.SHARES.value == "Shares" + assert PoolType.CAPITAL.value == "Capital"