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:
claudi 2026-02-20 09:08:43 +01:00
commit 05fca294f9
15 changed files with 10532 additions and 0 deletions

14
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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)

View 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

File diff suppressed because it is too large Load diff

1
examples/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Examples module for Elytra PIM Client"""

115
examples/basic_usage.py Normal file
View 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

File diff suppressed because it is too large Load diff

63
pyproject.toml Normal file
View 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
View 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
View file

@ -0,0 +1 @@
"""Tests module for Elytra PIM Client"""

178
tests/test_client.py Normal file
View 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)