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