Add initial project structure and tests for Elytra PIM Client
- Created pyproject.toml for project metadata and dependencies. - Added requirements.txt for development and production dependencies. - Implemented basic test structure in the tests module. - Developed unit tests for ElytraClient, including Pydantic validation and error handling.
This commit is contained in:
commit
05fca294f9
15 changed files with 10532 additions and 0 deletions
14
.env.example
Normal file
14
.env.example
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Elytra PIM Client Configuration
|
||||||
|
# Copy this file to .env and fill in your actual values
|
||||||
|
|
||||||
|
# The base URL of the Elytra PIM API
|
||||||
|
ELYTRA_BASE_URL=https://example.com/api/v1
|
||||||
|
|
||||||
|
# Your API key for authentication
|
||||||
|
ELYTRA_API_KEY=your-api-key-here
|
||||||
|
|
||||||
|
# Request timeout in seconds (optional, default: 30)
|
||||||
|
ELYTRA_TIMEOUT=30
|
||||||
|
|
||||||
|
# Verify SSL certificates (optional, default: true)
|
||||||
|
ELYTRA_VERIFY_SSL=true
|
||||||
134
.gitignore
vendored
Normal file
134
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
Pipfile.lock
|
||||||
|
|
||||||
|
# PEP 582
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# OS specific
|
||||||
|
Thumbs.db
|
||||||
238
README.md
Normal file
238
README.md
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
# Elytra PIM Client
|
||||||
|
|
||||||
|
A fully Pythonic and **Pydantic-driven** client for the Elytra PIM (Product Information Management) API.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🐍 Fully Pythonic with Pydantic v2 data validation
|
||||||
|
- 📦 Auto-generated Pydantic models from OpenAPI specification
|
||||||
|
- 🔐 Bearer token authentication
|
||||||
|
- ✅ Request/Response validation with Pydantic
|
||||||
|
- 🌍 Multi-language support
|
||||||
|
- 📄 Full type hints throughout the codebase
|
||||||
|
- 🧪 Comprehensive error handling
|
||||||
|
- 🔄 Context manager support
|
||||||
|
- 🔄 Automatic serialization/deserialization
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### From Source
|
||||||
|
|
||||||
|
Clone the repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.him-tools.de/HIM-public/elytra_client.git
|
||||||
|
cd elytra_client
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Development Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from elytra_client import ElytraClient, SingleProductResponse
|
||||||
|
|
||||||
|
# Initialize the client
|
||||||
|
client = ElytraClient(
|
||||||
|
base_url="https://example.com/api/v1",
|
||||||
|
api_key="your-api-key"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get all products - returns dict with Pydantic validated items
|
||||||
|
products_response = client.get_products(lang="en", page=1, limit=10)
|
||||||
|
for product in products_response["items"]:
|
||||||
|
print(f"Product: {product.productName}, ID: {product.id}")
|
||||||
|
|
||||||
|
# Get a specific product - returns Pydantic model directly
|
||||||
|
product: SingleProductResponse = client.get_product(product_id=123, lang="en")
|
||||||
|
print(f"Name: {product.productName}")
|
||||||
|
print(f"Status: {product.objectStatus}")
|
||||||
|
|
||||||
|
# Close the client
|
||||||
|
client.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating Products with Validation
|
||||||
|
|
||||||
|
```python
|
||||||
|
from elytra_client import (
|
||||||
|
ElytraClient,
|
||||||
|
SingleNewProductRequestBody,
|
||||||
|
AttributeRequestBody,
|
||||||
|
)
|
||||||
|
|
||||||
|
with ElytraClient(base_url="https://example.com/api/v1", api_key="your-api-key") as client:
|
||||||
|
# Create a new product with validated Pydantic model
|
||||||
|
new_product = SingleNewProductRequestBody(
|
||||||
|
productName="NEW-PRODUCT-001",
|
||||||
|
parentId=1,
|
||||||
|
attributeGroupId=10,
|
||||||
|
attributes=[
|
||||||
|
AttributeRequestBody(
|
||||||
|
attributeId=1,
|
||||||
|
value="Sample Value",
|
||||||
|
languageCode="en"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validation happens automatically
|
||||||
|
created_product = client.create_product(new_product)
|
||||||
|
print(f"Created product ID: {created_product.id}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variable Configuration
|
||||||
|
|
||||||
|
Set your environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ELYTRA_BASE_URL="https://example.com/api/v1"
|
||||||
|
export ELYTRA_API_KEY="your-api-key"
|
||||||
|
export ELYTRA_TIMEOUT="30"
|
||||||
|
export ELYTRA_VERIFY_SSL="true"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then load from environment:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from elytra_client import ElytraClient
|
||||||
|
from elytra_client.config import ElytraConfig
|
||||||
|
|
||||||
|
config = ElytraConfig.from_env()
|
||||||
|
client = ElytraClient(base_url=config.base_url, api_key=config.api_key)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Methods
|
||||||
|
|
||||||
|
All methods return Pydantic models with full type validation and IDE autocompletion support.
|
||||||
|
|
||||||
|
### Products
|
||||||
|
|
||||||
|
- `get_products(...) -> Dict` - Get all products (items are `SingleProductResponse` Pydantic models)
|
||||||
|
- `get_product(id, lang) -> SingleProductResponse` - Get single product
|
||||||
|
- `create_product(data) -> SingleProductResponse` - Create new product with validation
|
||||||
|
- `update_product(data) -> SingleProductResponse` - Update product with validation
|
||||||
|
- `delete_product(id) -> Dict` - Delete product
|
||||||
|
|
||||||
|
### Product Groups
|
||||||
|
|
||||||
|
- `get_product_groups(...) -> Dict` - Get all product groups (items are `SingleProductGroupResponse` models)
|
||||||
|
- `get_product_group(id, lang) -> SingleProductGroupResponse` - Get single product group
|
||||||
|
- `create_product_group(data) -> SingleProductGroupResponse` - Create new product group
|
||||||
|
- `update_product_group(data) -> SingleProductGroupResponse` - Update product group
|
||||||
|
- `delete_product_group(id) -> Dict` - Delete product group
|
||||||
|
|
||||||
|
### Attributes
|
||||||
|
|
||||||
|
- `get_attributes(...) -> Dict` - Get all attributes (items are `SingleAttributeResponse` models)
|
||||||
|
- `get_attribute(id, lang) -> SingleAttributeResponse` - Get single attribute
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
- `health_check() -> Dict` - Check API health status
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The client provides specific exception classes for different error types:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from elytra_client import ElytraClient
|
||||||
|
from elytra_client.exceptions import (
|
||||||
|
ElytraAuthenticationError,
|
||||||
|
ElytraNotFoundError,
|
||||||
|
ElytraValidationError,
|
||||||
|
ElytraAPIError,
|
||||||
|
)
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = ElytraClient(base_url="https://example.com/api/v1", api_key="invalid-key")
|
||||||
|
product = client.get_product(123)
|
||||||
|
except ElytraAuthenticationError:
|
||||||
|
print("Authentication failed")
|
||||||
|
except ElytraNotFoundError:
|
||||||
|
print("Product not found")
|
||||||
|
except ElytraValidationError as e:
|
||||||
|
print(f"API response validation failed: {e}")
|
||||||
|
except ValidationError as e:
|
||||||
|
print(f"Request model validation failed: {e}")
|
||||||
|
except ElytraAPIError as e:
|
||||||
|
print(f"API error: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- **Request validation**: Pydantic models validate all input before sending to API
|
||||||
|
- **Response validation**: Pydantic models validate API responses for data integrity
|
||||||
|
- **Automatic deserialization**: Responses are automatically converted to Pydantic models
|
||||||
|
|
||||||
|
## Pydantic Models
|
||||||
|
|
||||||
|
All request and response models are automatically generated from the OpenAPI specification using [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator).
|
||||||
|
|
||||||
|
### Available Models
|
||||||
|
|
||||||
|
- **Response Models**: `SingleProductResponse`, `SingleProductGroupResponse`, `SingleAttributeResponse`, etc.
|
||||||
|
- **Request Models**: `SingleNewProductRequestBody`, `SingleUpdateProductRequestBody`, `SingleNewProductGroupRequestBody`, etc.
|
||||||
|
- **Attribute Models**: `ProductAttributeResponse`, `AttributeRequestBody`
|
||||||
|
|
||||||
|
All models include:
|
||||||
|
- ✅ Full type hints and validation
|
||||||
|
- ✅ Documentation from OpenAPI spec
|
||||||
|
- ✅ IDE autocompletion support
|
||||||
|
- ✅ Automatic serialization/deserialization
|
||||||
|
|
||||||
|
### Regenerating Models
|
||||||
|
|
||||||
|
To regenerate models from the OpenAPI spec:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m datamodel_code_generator --input openapi.yaml --input-file-type openapi --output elytra_client/models.py --target-python-version 3.10
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
Format code with Black:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
black elytra_client tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Check with flake8:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flake8 elytra_client tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Type checking with mypy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mypy elytra_client
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
For complete API documentation, refer to the OpenAPI specification in `openapi.yaml` or visit the Elytra website: https://www.elytra.ch/
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
For support, please email: support@elytra.ch
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - see LICENSE file for details
|
||||||
31
elytra_client/__init__.py
Normal file
31
elytra_client/__init__.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Elytra PIM Client - A Pythonic client for the Elytra PIM API"""
|
||||||
|
|
||||||
|
__version__ = "0.2.0"
|
||||||
|
__author__ = "Your Name"
|
||||||
|
|
||||||
|
from .client import ElytraClient
|
||||||
|
from .exceptions import ElytraAPIError, ElytraAuthenticationError
|
||||||
|
from .models import (
|
||||||
|
SingleProductResponse,
|
||||||
|
SingleProductGroupResponse,
|
||||||
|
SingleNewProductRequestBody,
|
||||||
|
SingleUpdateProductRequestBody,
|
||||||
|
SingleNewProductGroupRequestBody,
|
||||||
|
SingleUpdateProductGroupRequestBody,
|
||||||
|
ProductAttributeResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ElytraClient",
|
||||||
|
"ElytraAPIError",
|
||||||
|
"ElytraAuthenticationError",
|
||||||
|
# Response models
|
||||||
|
"SingleProductResponse",
|
||||||
|
"SingleProductGroupResponse",
|
||||||
|
"ProductAttributeResponse",
|
||||||
|
# Request models
|
||||||
|
"SingleNewProductRequestBody",
|
||||||
|
"SingleUpdateProductRequestBody",
|
||||||
|
"SingleNewProductGroupRequestBody",
|
||||||
|
"SingleUpdateProductGroupRequestBody",
|
||||||
|
]
|
||||||
430
elytra_client/client.py
Normal file
430
elytra_client/client.py
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
"""Main Elytra PIM API Client - Fully Pydantic Driven"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from typing import Optional, Dict, Any, List, TypeVar, Type, cast
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
|
from .exceptions import (
|
||||||
|
ElytraAPIError,
|
||||||
|
ElytraAuthenticationError,
|
||||||
|
ElytraNotFoundError,
|
||||||
|
ElytraValidationError,
|
||||||
|
)
|
||||||
|
from .models import (
|
||||||
|
SingleProductResponse,
|
||||||
|
SingleProductGroupResponse,
|
||||||
|
SingleNewProductRequestBody,
|
||||||
|
SingleUpdateProductRequestBody,
|
||||||
|
SingleNewProductGroupRequestBody,
|
||||||
|
SingleUpdateProductGroupRequestBody,
|
||||||
|
)
|
||||||
|
|
||||||
|
T = TypeVar('T', bound=BaseModel)
|
||||||
|
|
||||||
|
|
||||||
|
class ElytraClient:
|
||||||
|
"""
|
||||||
|
A Pythonic client for the Elytra PIM API.
|
||||||
|
|
||||||
|
This client provides convenient methods for interacting with the Elytra PIM API,
|
||||||
|
including authentication, product management, attributes, and more.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: The base URL of the Elytra PIM API (e.g., https://example.com/api/v1)
|
||||||
|
api_key: The API key for authentication
|
||||||
|
timeout: Request timeout in seconds (default: 30)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
api_key: str,
|
||||||
|
timeout: int = 30,
|
||||||
|
):
|
||||||
|
"""Initialize the Elytra PIM client"""
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
self.session = requests.Session()
|
||||||
|
self._setup_headers()
|
||||||
|
|
||||||
|
def _setup_headers(self) -> None:
|
||||||
|
"""Setup default headers with authentication"""
|
||||||
|
self.session.headers.update(
|
||||||
|
{
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _make_request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
endpoint: str,
|
||||||
|
params: Optional[Dict[str, Any]] = None,
|
||||||
|
json_data: Optional[Dict[str, Any] | BaseModel] = None,
|
||||||
|
response_model: Optional[Type[T]] = None,
|
||||||
|
) -> T | Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Make an HTTP request to the API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: HTTP method (GET, POST, PUT, DELETE, etc.)
|
||||||
|
endpoint: API endpoint path
|
||||||
|
params: Query parameters
|
||||||
|
json_data: JSON body data (dict or Pydantic model)
|
||||||
|
response_model: Pydantic model to validate response
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response data (validated with Pydantic if response_model provided)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ElytraAuthenticationError: If authentication fails
|
||||||
|
ElytraNotFoundError: If resource not found
|
||||||
|
ElytraValidationError: If validation fails
|
||||||
|
ElytraAPIError: For other API errors
|
||||||
|
"""
|
||||||
|
url = urljoin(self.base_url, endpoint)
|
||||||
|
|
||||||
|
# Convert Pydantic model to dict if needed
|
||||||
|
json_payload = None
|
||||||
|
if json_data is not None:
|
||||||
|
if isinstance(json_data, BaseModel):
|
||||||
|
json_payload = json_data.model_dump(exclude_none=True)
|
||||||
|
else:
|
||||||
|
json_payload = json_data
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.session.request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
params=params,
|
||||||
|
json=json_payload,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Validate response with Pydantic model if provided
|
||||||
|
if response_model is not None:
|
||||||
|
try:
|
||||||
|
return response_model(**data)
|
||||||
|
except ValidationError as e:
|
||||||
|
raise ElytraValidationError(
|
||||||
|
f"Response validation failed: {e}", response.status_code
|
||||||
|
)
|
||||||
|
|
||||||
|
return data
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
self._handle_http_error(e)
|
||||||
|
raise # Re-raise after handling to satisfy type checker,
|
||||||
|
# even though _handle_http_error will raise a specific exception and this line should never be reached
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise ElytraAPIError(f"Request failed: {str(e)}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _handle_http_error(error: requests.exceptions.HTTPError) -> None:
|
||||||
|
"""Handle HTTP errors and raise appropriate exceptions"""
|
||||||
|
status_code = error.response.status_code
|
||||||
|
message = error.response.text
|
||||||
|
|
||||||
|
if status_code == 401:
|
||||||
|
raise ElytraAuthenticationError(message, status_code)
|
||||||
|
elif status_code == 404:
|
||||||
|
raise ElytraNotFoundError(message, status_code)
|
||||||
|
elif status_code == 400:
|
||||||
|
raise ElytraValidationError(message, status_code)
|
||||||
|
else:
|
||||||
|
raise ElytraAPIError(message, status_code)
|
||||||
|
|
||||||
|
# Product endpoints
|
||||||
|
|
||||||
|
def get_products(
|
||||||
|
self,
|
||||||
|
lang: str = "en",
|
||||||
|
page: int = 1,
|
||||||
|
limit: int = 10,
|
||||||
|
group_id: Optional[int] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get all products.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lang: Language code (e.g., 'en', 'de')
|
||||||
|
page: Page number (starting from 1)
|
||||||
|
limit: Number of products per page (1-200)
|
||||||
|
group_id: Optional product group ID to filter products
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing products list (validated Pydantic models) and pagination info
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"lang": lang,
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
}
|
||||||
|
if group_id is not None:
|
||||||
|
params["groupId"] = group_id
|
||||||
|
|
||||||
|
response = self._make_request("GET", "/products", params=params)
|
||||||
|
|
||||||
|
# Validate items with Pydantic models
|
||||||
|
if isinstance(response, dict) and "items" in response:
|
||||||
|
try:
|
||||||
|
response["items"] = [
|
||||||
|
SingleProductResponse(**item) for item in response["items"]
|
||||||
|
]
|
||||||
|
except ValidationError as e:
|
||||||
|
raise ElytraValidationError(f"Product list validation failed: {e}")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_product(self, product_id: int, lang: str = "en") -> SingleProductResponse:
|
||||||
|
"""
|
||||||
|
Get a single product by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
product_id: The product ID
|
||||||
|
lang: Language code (e.g., 'en', 'de')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Product details (Pydantic model)
|
||||||
|
"""
|
||||||
|
params = {"lang": lang}
|
||||||
|
return cast(
|
||||||
|
SingleProductResponse,
|
||||||
|
self._make_request(
|
||||||
|
"GET",
|
||||||
|
f"/products/{product_id}",
|
||||||
|
params=params,
|
||||||
|
response_model=SingleProductResponse,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Product Group endpoints
|
||||||
|
|
||||||
|
def get_product_groups(
|
||||||
|
self, lang: str = "en", page: int = 1, limit: int = 10
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get all product groups.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lang: Language code
|
||||||
|
page: Page number
|
||||||
|
limit: Number of groups per page
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing product groups list (validated Pydantic models) and pagination info
|
||||||
|
"""
|
||||||
|
params = {"lang": lang, "page": page, "limit": limit}
|
||||||
|
response = self._make_request("GET", "/groups", params=params)
|
||||||
|
|
||||||
|
# Validate items with Pydantic models
|
||||||
|
if isinstance(response, dict) and "items" in response:
|
||||||
|
try:
|
||||||
|
response["items"] = [
|
||||||
|
SingleProductGroupResponse(**item) for item in response["items"]
|
||||||
|
]
|
||||||
|
except ValidationError as e:
|
||||||
|
raise ElytraValidationError(f"Product group list validation failed: {e}")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_product_group(self, group_id: int, lang: str = "en") -> SingleProductGroupResponse:
|
||||||
|
"""
|
||||||
|
Get a single product group by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id: The product group ID
|
||||||
|
lang: Language code
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Product group details (Pydantic model)
|
||||||
|
"""
|
||||||
|
params = {"lang": lang}
|
||||||
|
return cast(
|
||||||
|
SingleProductGroupResponse,
|
||||||
|
self._make_request(
|
||||||
|
"GET",
|
||||||
|
f"/groups/{group_id}",
|
||||||
|
params=params,
|
||||||
|
response_model=SingleProductGroupResponse,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attribute endpoints
|
||||||
|
|
||||||
|
def get_attributes(
|
||||||
|
self, lang: str = "en", page: int = 1, limit: int = 10
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get all attributes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lang: Language code
|
||||||
|
page: Page number
|
||||||
|
limit: Number of attributes per page
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing attributes list and pagination info
|
||||||
|
"""
|
||||||
|
params = {"lang": lang, "page": page, "limit": limit}
|
||||||
|
response = self._make_request("GET", "/attributes", params=params)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_attribute(self, attribute_id: int, lang: str = "en") -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get a single attribute by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attribute_id: The attribute ID
|
||||||
|
lang: Language code
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Attribute details (Pydantic validated model)
|
||||||
|
"""
|
||||||
|
params = {"lang": lang}
|
||||||
|
return self._make_request("GET", f"/attributes/{attribute_id}", params=params)
|
||||||
|
|
||||||
|
# Product CRUD operations with Pydantic validation
|
||||||
|
|
||||||
|
def create_product(
|
||||||
|
self, product_data: SingleNewProductRequestBody
|
||||||
|
) -> SingleProductResponse:
|
||||||
|
"""
|
||||||
|
Create a new product.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
product_data: Product data (Pydantic model)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created product details (Pydantic model)
|
||||||
|
"""
|
||||||
|
return cast(
|
||||||
|
SingleProductResponse,
|
||||||
|
self._make_request(
|
||||||
|
"POST",
|
||||||
|
"/products",
|
||||||
|
json_data=product_data,
|
||||||
|
response_model=SingleProductResponse,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_product(
|
||||||
|
self, product_data: SingleUpdateProductRequestBody
|
||||||
|
) -> SingleProductResponse:
|
||||||
|
"""
|
||||||
|
Update an existing product.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
product_data: Updated product data (Pydantic model)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated product details (Pydantic model)
|
||||||
|
"""
|
||||||
|
return cast(
|
||||||
|
SingleProductResponse,
|
||||||
|
self._make_request(
|
||||||
|
"PATCH",
|
||||||
|
"/products",
|
||||||
|
json_data=product_data,
|
||||||
|
response_model=SingleProductResponse,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_product(self, product_id: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Delete a product by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
product_id: The product ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Deletion response
|
||||||
|
"""
|
||||||
|
return self._make_request("DELETE", f"/products/{product_id}")
|
||||||
|
|
||||||
|
# Product Group CRUD operations with Pydantic validation
|
||||||
|
|
||||||
|
def create_product_group(
|
||||||
|
self, group_data: SingleNewProductGroupRequestBody
|
||||||
|
) -> SingleProductGroupResponse:
|
||||||
|
"""
|
||||||
|
Create a new product group.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_data: Product group data (Pydantic model)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created product group details (Pydantic model)
|
||||||
|
"""
|
||||||
|
return cast(
|
||||||
|
SingleProductGroupResponse,
|
||||||
|
self._make_request(
|
||||||
|
"POST",
|
||||||
|
"/groups",
|
||||||
|
json_data=group_data,
|
||||||
|
response_model=SingleProductGroupResponse,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_product_group(
|
||||||
|
self, group_data: SingleUpdateProductGroupRequestBody
|
||||||
|
) -> SingleProductGroupResponse:
|
||||||
|
"""
|
||||||
|
Update an existing product group.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_data: Updated product group data (Pydantic model)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated product group details (Pydantic model)
|
||||||
|
"""
|
||||||
|
return cast(
|
||||||
|
SingleProductGroupResponse,
|
||||||
|
self._make_request(
|
||||||
|
"PATCH",
|
||||||
|
"/groups",
|
||||||
|
json_data=group_data,
|
||||||
|
response_model=SingleProductGroupResponse,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_product_group(self, group_id: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Delete a product group by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id: The product group ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Deletion response
|
||||||
|
"""
|
||||||
|
return self._make_request("DELETE", f"/groups/{group_id}")
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
|
||||||
|
def health_check(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Check API health status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Health status information
|
||||||
|
"""
|
||||||
|
return self._make_request("GET", "/health")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close the client session"""
|
||||||
|
self.session.close()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""Context manager entry"""
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""Context manager exit"""
|
||||||
|
self.close()
|
||||||
63
elytra_client/config.py
Normal file
63
elytra_client/config.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""Configuration handling for Elytra PIM Client"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ElytraConfig:
|
||||||
|
"""Configuration for Elytra PIM Client"""
|
||||||
|
|
||||||
|
base_url: str
|
||||||
|
api_key: str
|
||||||
|
timeout: int = 30
|
||||||
|
verify_ssl: bool = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "ElytraConfig":
|
||||||
|
"""
|
||||||
|
Load configuration from environment variables.
|
||||||
|
|
||||||
|
Expected environment variables:
|
||||||
|
- ELYTRA_BASE_URL: The base URL of the API
|
||||||
|
- ELYTRA_API_KEY: The API key for authentication
|
||||||
|
- ELYTRA_TIMEOUT: Request timeout in seconds (optional, default: 30)
|
||||||
|
- ELYTRA_VERIFY_SSL: Verify SSL certificates (optional, default: true)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ElytraConfig instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If required environment variables are missing
|
||||||
|
"""
|
||||||
|
base_url = os.getenv("ELYTRA_BASE_URL")
|
||||||
|
api_key = os.getenv("ELYTRA_API_KEY")
|
||||||
|
|
||||||
|
if not base_url or not api_key:
|
||||||
|
raise ValueError(
|
||||||
|
"Missing required environment variables: ELYTRA_BASE_URL and ELYTRA_API_KEY"
|
||||||
|
)
|
||||||
|
|
||||||
|
timeout = int(os.getenv("ELYTRA_TIMEOUT", "30"))
|
||||||
|
verify_ssl = os.getenv("ELYTRA_VERIFY_SSL", "true").lower() == "true"
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
base_url=base_url,
|
||||||
|
api_key=api_key,
|
||||||
|
timeout=timeout,
|
||||||
|
verify_ssl=verify_ssl,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, config_dict: dict) -> "ElytraConfig":
|
||||||
|
"""
|
||||||
|
Create configuration from a dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_dict: Dictionary containing configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ElytraConfig instance
|
||||||
|
"""
|
||||||
|
return cls(**config_dict)
|
||||||
28
elytra_client/exceptions.py
Normal file
28
elytra_client/exceptions.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""Exception classes for the Elytra PIM Client"""
|
||||||
|
|
||||||
|
|
||||||
|
class ElytraAPIError(Exception):
|
||||||
|
"""Base exception for Elytra API errors"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: int | None = None):
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class ElytraAuthenticationError(ElytraAPIError):
|
||||||
|
"""Exception raised for authentication errors"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ElytraNotFoundError(ElytraAPIError):
|
||||||
|
"""Exception raised when a requested resource is not found"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ElytraValidationError(ElytraAPIError):
|
||||||
|
"""Exception raised for validation errors"""
|
||||||
|
|
||||||
|
pass
|
||||||
2759
elytra_client/models.py
Normal file
2759
elytra_client/models.py
Normal file
File diff suppressed because it is too large
Load diff
1
examples/__init__.py
Normal file
1
examples/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Examples module for Elytra PIM Client"""
|
||||||
115
examples/basic_usage.py
Normal file
115
examples/basic_usage.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
"""Example usage of the Elytra PIM Client with Pydantic validation"""
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from elytra_client import (
|
||||||
|
ElytraClient,
|
||||||
|
SingleProductResponse,
|
||||||
|
SingleNewProductRequestBody,
|
||||||
|
AttributeRequestBody,
|
||||||
|
)
|
||||||
|
from elytra_client.config import ElytraConfig
|
||||||
|
|
||||||
|
# Load environment variables from .env file
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_products_example(client):
|
||||||
|
"""Example showing product fetching with Pydantic validation"""
|
||||||
|
print("\n--- Fetching Products with Pydantic Validation ---")
|
||||||
|
products_response = client.get_products(lang="en", page=1, limit=3)
|
||||||
|
|
||||||
|
print(f"Total products: {products_response.get('total')}")
|
||||||
|
print(f"Current page: {products_response.get('page')}")
|
||||||
|
|
||||||
|
# Items are Pydantic validated models with full type hints
|
||||||
|
if products_response.get("items"):
|
||||||
|
for product in products_response["items"]:
|
||||||
|
assert isinstance(product, SingleProductResponse)
|
||||||
|
print(f" - {product.productName} (ID: {product.id})")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_single_product_example(client):
|
||||||
|
"""Example showing single product fetch with direct Pydantic model"""
|
||||||
|
print("\n--- Fetching Single Product ---")
|
||||||
|
try:
|
||||||
|
product: SingleProductResponse = client.get_product(123, lang="en")
|
||||||
|
print(f"Product Name: {product.productName}")
|
||||||
|
print(f"Status: {product.objectStatus}")
|
||||||
|
print(f"Created: {product.created}")
|
||||||
|
|
||||||
|
if product.attributes:
|
||||||
|
print(f"Attributes: {len(product.attributes)}")
|
||||||
|
for attr in product.attributes:
|
||||||
|
print(f" - {attr.attributeName}: {attr.value}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Product not found or error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def create_product_example(client):
|
||||||
|
"""Example showing product creation with Pydantic validation"""
|
||||||
|
print("\n--- Creating Product with Pydantic Validation ---")
|
||||||
|
try:
|
||||||
|
# Create with Pydantic model - validation happens automatically
|
||||||
|
new_product = SingleNewProductRequestBody(
|
||||||
|
productName="EXAMPLE-PRODUCT-001",
|
||||||
|
parentId=1,
|
||||||
|
attributeGroupId=10,
|
||||||
|
attributes=[
|
||||||
|
AttributeRequestBody(
|
||||||
|
attributeId=1,
|
||||||
|
value="Sample Value",
|
||||||
|
languageCode="en"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create product - returns validated Pydantic model
|
||||||
|
created: SingleProductResponse = client.create_product(new_product)
|
||||||
|
print(f"✅ Product created successfully!")
|
||||||
|
print(f" ID: {created.id}")
|
||||||
|
print(f" Name: {created.productName}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_data_example():
|
||||||
|
"""Example showing Pydantic validation"""
|
||||||
|
print("\n--- Pydantic Validation Example ---")
|
||||||
|
try:
|
||||||
|
# This will validate the data
|
||||||
|
product = SingleNewProductRequestBody(
|
||||||
|
productName="VALID-PRODUCT",
|
||||||
|
parentId=1,
|
||||||
|
attributeGroupId=10
|
||||||
|
)
|
||||||
|
print("✅ Valid product data")
|
||||||
|
print(f" Data: {product.model_dump()}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Validation failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main example demonstrating Pydantic-driven Elytra client"""
|
||||||
|
try:
|
||||||
|
# Load configuration from environment
|
||||||
|
config = ElytraConfig.from_env()
|
||||||
|
|
||||||
|
# Create client with context manager
|
||||||
|
with ElytraClient(base_url=config.base_url, api_key=config.api_key) as client:
|
||||||
|
print("✅ Connected to Elytra PIM API")
|
||||||
|
print(" Using Pydantic v2 for data validation\n")
|
||||||
|
|
||||||
|
# Run examples
|
||||||
|
validate_data_example()
|
||||||
|
fetch_products_example(client)
|
||||||
|
fetch_single_product_example(client)
|
||||||
|
create_product_example(client)
|
||||||
|
|
||||||
|
print("\n✅ All examples completed successfully!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
6466
openapi.yaml
Normal file
6466
openapi.yaml
Normal file
File diff suppressed because it is too large
Load diff
63
pyproject.toml
Normal file
63
pyproject.toml
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=65.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "elytra-pim-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Pythonic client for the Elytra PIM API"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
authors = [
|
||||||
|
{name = "Your Name", email = "your.email@example.com"}
|
||||||
|
]
|
||||||
|
keywords = ["elytra", "pim", "api", "client"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"requests>=2.28.0",
|
||||||
|
"python-dotenv>=0.21.0",
|
||||||
|
"pydantic>=2.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.0.0",
|
||||||
|
"pytest-cov>=4.0.0",
|
||||||
|
"black>=23.0.0",
|
||||||
|
"isort>=5.12.0",
|
||||||
|
"flake8>=6.0.0",
|
||||||
|
"mypy>=1.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Repository = "https://git.him-tools.de/HIM-public/elytra_client.git"
|
||||||
|
Documentation = "https://www.elytra.ch/"
|
||||||
|
Issues = "https://git.him-tools.de/HIM-public/elytra_client/issues"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = ["elytra_client"]
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 100
|
||||||
|
target-version = ['py39', 'py310', 'py311', 'py312']
|
||||||
|
|
||||||
|
[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
|
||||||
11
requirements.txt
Normal file
11
requirements.txt
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
requests>=2.28.0
|
||||||
|
python-dotenv>=0.21.0
|
||||||
|
pydantic>=2.0.0
|
||||||
|
|
||||||
|
# Development dependencies
|
||||||
|
pytest>=7.0.0
|
||||||
|
pytest-cov>=4.0.0
|
||||||
|
black>=23.0.0
|
||||||
|
isort>=5.12.0
|
||||||
|
flake8>=6.0.0
|
||||||
|
mypy>=1.0.0
|
||||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Tests module for Elytra PIM Client"""
|
||||||
178
tests/test_client.py
Normal file
178
tests/test_client.py
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
"""Tests for the Elytra PIM Client with Pydantic validation"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from elytra_client import (
|
||||||
|
ElytraClient,
|
||||||
|
SingleProductResponse,
|
||||||
|
SingleNewProductRequestBody,
|
||||||
|
)
|
||||||
|
from elytra_client.exceptions import ElytraAuthenticationError, ElytraNotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
"""Create a test client"""
|
||||||
|
return ElytraClient(
|
||||||
|
base_url="https://test.example.com/api/v1",
|
||||||
|
api_key="test-api-key",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_initialization(client):
|
||||||
|
"""Test client initialization"""
|
||||||
|
assert client.base_url == "https://test.example.com/api/v1"
|
||||||
|
assert client.api_key == "test-api-key"
|
||||||
|
assert client.timeout == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_context_manager():
|
||||||
|
"""Test client context manager"""
|
||||||
|
with ElytraClient(
|
||||||
|
base_url="https://test.example.com/api/v1",
|
||||||
|
api_key="test-api-key",
|
||||||
|
) as client:
|
||||||
|
assert client is not None
|
||||||
|
|
||||||
|
|
||||||
|
@patch("elytra_client.client.requests.Session.request")
|
||||||
|
def test_get_products_with_pydantic_validation(mock_request, client):
|
||||||
|
"""Test get_products with Pydantic validation"""
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"clientId": 231,
|
||||||
|
"productName": "Product 1",
|
||||||
|
"treeId": 0,
|
||||||
|
"created": "2025-04-08T12:00:00Z",
|
||||||
|
"modified": "2025-04-08T12:00:00Z",
|
||||||
|
"creatorUserId": 235,
|
||||||
|
"modifierUserId": 235,
|
||||||
|
"objectStatus": "original",
|
||||||
|
"originalId": 0,
|
||||||
|
"attributes": [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"page": 1,
|
||||||
|
}
|
||||||
|
mock_request.return_value = mock_response
|
||||||
|
|
||||||
|
result = client.get_products(lang="en", page=1, limit=10)
|
||||||
|
|
||||||
|
assert result["total"] == 1
|
||||||
|
assert len(result["items"]) == 1
|
||||||
|
# Items should be Pydantic models
|
||||||
|
assert isinstance(result["items"][0], SingleProductResponse)
|
||||||
|
assert result["items"][0].productName == "Product 1"
|
||||||
|
mock_request.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("elytra_client.client.requests.Session.request")
|
||||||
|
def test_get_product_returns_pydantic_model(mock_request, client):
|
||||||
|
"""Test get_product returns Pydantic model"""
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"id": 123,
|
||||||
|
"clientId": 231,
|
||||||
|
"productName": "Test Product",
|
||||||
|
"treeId": 0,
|
||||||
|
"created": "2025-04-08T12:00:00Z",
|
||||||
|
"modified": "2025-04-08T12:00:00Z",
|
||||||
|
"creatorUserId": 235,
|
||||||
|
"modifierUserId": 235,
|
||||||
|
"objectStatus": "original",
|
||||||
|
"originalId": 0,
|
||||||
|
"attributes": [],
|
||||||
|
}
|
||||||
|
mock_request.return_value = mock_response
|
||||||
|
|
||||||
|
result = client.get_product(product_id=123, lang="en")
|
||||||
|
|
||||||
|
assert isinstance(result, SingleProductResponse)
|
||||||
|
assert result.id == 123
|
||||||
|
assert result.productName == "Test Product"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("elytra_client.client.requests.Session.request")
|
||||||
|
def test_create_product_with_pydantic(mock_request, client):
|
||||||
|
"""Test product creation with Pydantic validation"""
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"id": 999,
|
||||||
|
"clientId": 231,
|
||||||
|
"productName": "NEW-PRODUCT-001",
|
||||||
|
"treeId": 0,
|
||||||
|
"created": "2025-04-08T12:00:00Z",
|
||||||
|
"modified": "2025-04-08T12:00:00Z",
|
||||||
|
"creatorUserId": 235,
|
||||||
|
"modifierUserId": 235,
|
||||||
|
"objectStatus": "original",
|
||||||
|
"originalId": 0,
|
||||||
|
"attributes": [],
|
||||||
|
}
|
||||||
|
mock_request.return_value = mock_response
|
||||||
|
|
||||||
|
# Create with Pydantic model
|
||||||
|
new_product = SingleNewProductRequestBody(
|
||||||
|
productName="NEW-PRODUCT-001",
|
||||||
|
parentId=1,
|
||||||
|
attributeGroupId=10,
|
||||||
|
) # type: ignore - validation happens automatically, so type checker should recognize this as valid
|
||||||
|
|
||||||
|
result = client.create_product(new_product)
|
||||||
|
|
||||||
|
assert isinstance(result, SingleProductResponse)
|
||||||
|
assert result.id == 999
|
||||||
|
assert result.productName == "NEW-PRODUCT-001"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pydantic_validation_on_creation():
|
||||||
|
"""Test Pydantic validation on model creation"""
|
||||||
|
# Valid model
|
||||||
|
valid_product = SingleNewProductRequestBody(
|
||||||
|
productName="VALID-PRODUCT",
|
||||||
|
parentId=1,
|
||||||
|
attributeGroupId=10,
|
||||||
|
) # type: ignore - validation happens automatically, so type checker should recognize this as valid
|
||||||
|
assert valid_product.productName == "VALID-PRODUCT"
|
||||||
|
|
||||||
|
# Invalid model - missing required field
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
SingleNewProductRequestBody(
|
||||||
|
productName="INVALID-PRODUCT",
|
||||||
|
# Missing parentId - required
|
||||||
|
attributeGroupId=10,
|
||||||
|
) # type: ignore - this will raise a ValidationError, so type checker should recognize this as invalid
|
||||||
|
|
||||||
|
|
||||||
|
@patch("elytra_client.client.requests.Session.request")
|
||||||
|
def test_authentication_error(mock_request, client):
|
||||||
|
"""Test authentication error handling"""
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 401
|
||||||
|
mock_response.text = "Unauthorized"
|
||||||
|
mock_request.return_value.raise_for_status.side_effect = (
|
||||||
|
__import__("requests").exceptions.HTTPError(response=mock_response)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ElytraAuthenticationError):
|
||||||
|
client.get_products()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("elytra_client.client.requests.Session.request")
|
||||||
|
def test_not_found_error(mock_request, client):
|
||||||
|
"""Test not found error handling"""
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 404
|
||||||
|
mock_response.text = "Not Found"
|
||||||
|
mock_request.return_value.raise_for_status.side_effect = (
|
||||||
|
__import__("requests").exceptions.HTTPError(response=mock_response)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ElytraNotFoundError):
|
||||||
|
client.get_product(product_id=999)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue