Merge pull request #4 from l3ocho/claude/sdk-improvements-011CUNfB1LKh2uZDNLMZ3PHe

Claude/sdk improvements 011 cu nf b1 l kh2u zdnlmz3 p he
This commit is contained in:
Leo Miranda
2025-10-22 16:50:04 -04:00
committed by GitHub
36 changed files with 10851 additions and 9 deletions

418
docs/async_usage.md Normal file
View File

@@ -0,0 +1,418 @@
# Async/Await Support
The Wiki.js Python SDK provides full async/await support for high-performance concurrent operations using `aiohttp`.
## Installation
```bash
pip install wikijs-python-sdk[async]
```
## Quick Start
```python
import asyncio
from wikijs.aio import AsyncWikiJSClient
async def main():
# Use async context manager for automatic cleanup
async with AsyncWikiJSClient(
base_url="https://wiki.example.com",
auth="your-api-key"
) as client:
# All operations are now async
pages = await client.pages.list()
page = await client.pages.get(123)
print(f"Found {len(pages)} pages")
print(f"Page title: {page.title}")
# Run the async function
asyncio.run(main())
```
## Why Async?
Async operations provide significant performance benefits for concurrent requests:
- **Sequential (Sync)**: Requests happen one-by-one
- 100 requests @ 100ms each = 10 seconds
- **Concurrent (Async)**: Requests happen simultaneously
- 100 requests @ 100ms each = ~100ms total
- **>3x faster** for typical workloads!
## Basic Operations
### Connection Testing
```python
async with AsyncWikiJSClient(url, auth) as client:
connected = await client.test_connection()
print(f"Connected: {connected}")
```
### Listing Pages
```python
# List all pages
pages = await client.pages.list()
# List with filtering
pages = await client.pages.list(
limit=10,
offset=0,
search="documentation",
locale="en",
order_by="title",
order_direction="ASC"
)
```
### Getting Pages
```python
# Get by ID
page = await client.pages.get(123)
# Get by path
page = await client.pages.get_by_path("getting-started")
```
### Creating Pages
```python
from wikijs.models.page import PageCreate
new_page = PageCreate(
title="New Page",
path="new-page",
content="# New Page\n\nContent here.",
description="A new page",
tags=["new", "example"]
)
created_page = await client.pages.create(new_page)
print(f"Created page with ID: {created_page.id}")
```
### Updating Pages
```python
from wikijs.models.page import PageUpdate
updates = PageUpdate(
title="Updated Title",
content="# Updated\n\nNew content.",
tags=["updated"]
)
updated_page = await client.pages.update(123, updates)
```
### Deleting Pages
```python
success = await client.pages.delete(123)
print(f"Deleted: {success}")
```
### Searching Pages
```python
results = await client.pages.search("api documentation", limit=10)
for page in results:
print(f"- {page.title}")
```
## Concurrent Operations
The real power of async is running multiple operations concurrently:
### Fetch Multiple Pages
```python
import asyncio
# Sequential (slow)
pages = []
for page_id in [1, 2, 3, 4, 5]:
page = await client.pages.get(page_id)
pages.append(page)
# Concurrent (fast!)
tasks = [client.pages.get(page_id) for page_id in [1, 2, 3, 4, 5]]
pages = await asyncio.gather(*tasks)
```
### Bulk Create Operations
```python
# Create multiple pages concurrently
pages_to_create = [
PageCreate(title=f"Page {i}", path=f"page-{i}", content=f"Content {i}")
for i in range(1, 11)
]
tasks = [client.pages.create(page) for page in pages_to_create]
created_pages = await asyncio.gather(*tasks, return_exceptions=True)
# Filter out any errors
successful = [p for p in created_pages if isinstance(p, Page)]
print(f"Created {len(successful)} pages")
```
### Parallel Search Operations
```python
# Search multiple terms concurrently
search_terms = ["api", "guide", "tutorial", "reference"]
tasks = [client.pages.search(term) for term in search_terms]
results = await asyncio.gather(*tasks)
for term, pages in zip(search_terms, results):
print(f"{term}: {len(pages)} pages found")
```
## Error Handling
Handle errors gracefully with try/except:
```python
from wikijs.exceptions import (
AuthenticationError,
NotFoundError,
APIError
)
async with AsyncWikiJSClient(url, auth) as client:
try:
page = await client.pages.get(999)
except NotFoundError:
print("Page not found")
except AuthenticationError:
print("Invalid API key")
except APIError as e:
print(f"API error: {e}")
```
### Handle Errors in Concurrent Operations
```python
# Use return_exceptions=True to continue on errors
tasks = [client.pages.get(page_id) for page_id in [1, 2, 999, 4, 5]]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Process results
for i, result in enumerate(results):
if isinstance(result, Exception):
print(f"Page {i}: Error - {result}")
else:
print(f"Page {i}: {result.title}")
```
## Resource Management
### Automatic Cleanup with Context Manager
```python
# Recommended: Use async context manager
async with AsyncWikiJSClient(url, auth) as client:
# Session automatically closed when block exits
pages = await client.pages.list()
```
### Manual Resource Management
```python
# If you need manual control
client = AsyncWikiJSClient(url, auth)
try:
pages = await client.pages.list()
finally:
await client.close() # Important: close the session
```
## Advanced Configuration
### Custom Connection Pool
```python
import aiohttp
# Create custom connector for fine-tuned control
connector = aiohttp.TCPConnector(
limit=200, # Max connections
limit_per_host=50, # Max per host
ttl_dns_cache=600, # DNS cache TTL
)
async with AsyncWikiJSClient(
url,
auth,
connector=connector
) as client:
# Use client with custom connector
pages = await client.pages.list()
```
### Custom Timeout
```python
# Set custom timeout (in seconds)
async with AsyncWikiJSClient(
url,
auth,
timeout=60 # 60 second timeout
) as client:
pages = await client.pages.list()
```
### Disable SSL Verification (Development Only)
```python
async with AsyncWikiJSClient(
url,
auth,
verify_ssl=False # NOT recommended for production!
) as client:
pages = await client.pages.list()
```
## Performance Best Practices
### 1. Use Connection Pooling
The async client automatically uses connection pooling. Keep a single client instance for your application:
```python
# Good: Reuse client
client = AsyncWikiJSClient(url, auth)
for i in range(100):
await client.pages.get(i)
await client.close()
# Bad: Create new client each time
for i in range(100):
async with AsyncWikiJSClient(url, auth) as client:
await client.pages.get(i) # New connection each time!
```
### 2. Batch Concurrent Operations
Use `asyncio.gather()` for concurrent operations:
```python
# Fetch 100 pages concurrently (fast!)
tasks = [client.pages.get(i) for i in range(1, 101)]
pages = await asyncio.gather(*tasks, return_exceptions=True)
```
### 3. Use Semaphores to Control Concurrency
Limit concurrent connections to avoid overwhelming the server:
```python
import asyncio
async def fetch_page_with_semaphore(client, page_id, sem):
async with sem: # Limit concurrent operations
return await client.pages.get(page_id)
# Limit to 10 concurrent requests
sem = asyncio.Semaphore(10)
tasks = [
fetch_page_with_semaphore(client, i, sem)
for i in range(1, 101)
]
pages = await asyncio.gather(*tasks)
```
## Comparison: Sync vs Async
| Feature | Sync Client | Async Client |
|---------|-------------|--------------|
| Import | `from wikijs import WikiJSClient` | `from wikijs.aio import AsyncWikiJSClient` |
| Usage | `client.pages.get(123)` | `await client.pages.get(123)` |
| Context Manager | `with WikiJSClient(...) as client:` | `async with AsyncWikiJSClient(...) as client:` |
| Concurrency | Sequential only | Concurrent with `asyncio.gather()` |
| Performance | Good for single requests | Excellent for multiple requests |
| Dependencies | `requests` | `aiohttp` |
| Best For | Simple scripts, sequential operations | Web apps, high-throughput, concurrent ops |
## When to Use Async
**Use Async When:**
- Making multiple concurrent API calls
- Building async web applications (FastAPI, aiohttp)
- Need maximum throughput
- Working with other async libraries
**Use Sync When:**
- Simple scripts or automation
- Sequential operations only
- Don't need concurrency
- Simpler code is preferred
## Complete Example
```python
import asyncio
from wikijs.aio import AsyncWikiJSClient
from wikijs.models.page import PageCreate, PageUpdate
async def main():
async with AsyncWikiJSClient(
base_url="https://wiki.example.com",
auth="your-api-key"
) as client:
# Test connection
print("Testing connection...")
connected = await client.test_connection()
print(f"Connected: {connected}")
# Create page
print("\nCreating page...")
new_page = PageCreate(
title="Test Page",
path="test-page",
content="# Test\n\nContent here.",
tags=["test"]
)
page = await client.pages.create(new_page)
print(f"Created page {page.id}: {page.title}")
# Update page
print("\nUpdating page...")
updates = PageUpdate(title="Updated Test Page")
page = await client.pages.update(page.id, updates)
print(f"Updated: {page.title}")
# List pages concurrently
print("\nFetching multiple pages...")
tasks = [
client.pages.list(limit=5),
client.pages.search("test"),
client.pages.get_by_tags(["test"])
]
list_results, search_results, tag_results = await asyncio.gather(*tasks)
print(f"Listed: {len(list_results)}")
print(f"Searched: {len(search_results)}")
print(f"By tags: {len(tag_results)}")
# Clean up
print("\nDeleting test page...")
await client.pages.delete(page.id)
print("Done!")
if __name__ == "__main__":
asyncio.run(main())
```
## See Also
- [Basic Usage Guide](../README.md#usage)
- [API Reference](api/)
- [Examples](../examples/)
- [Performance Benchmarks](benchmarks.md)

745
docs/users_api.md Normal file
View File

@@ -0,0 +1,745 @@
# Users API Guide
Comprehensive guide for managing Wiki.js users through the SDK.
## Table of Contents
- [Overview](#overview)
- [User Models](#user-models)
- [Basic Operations](#basic-operations)
- [Async Operations](#async-operations)
- [Advanced Usage](#advanced-usage)
- [Error Handling](#error-handling)
- [Best Practices](#best-practices)
## Overview
The Users API provides complete user management capabilities for Wiki.js, including:
- **CRUD Operations**: Create, read, update, and delete users
- **User Search**: Find users by name or email
- **User Listing**: List all users with filtering and pagination
- **Group Management**: Assign users to groups
- **Profile Management**: Update user profiles and settings
Both **synchronous** and **asynchronous** clients are supported with identical interfaces.
## User Models
### User
Represents a complete Wiki.js user with all profile information.
```python
from wikijs.models import User
# User fields
user = User(
id=1,
name="John Doe",
email="john@example.com",
provider_key="local", # Authentication provider
is_system=False, # System user flag
is_active=True, # Account active status
is_verified=True, # Email verified
location="New York", # Optional location
job_title="Developer", # Optional job title
timezone="America/New_York", # Optional timezone
groups=[ # User's groups
{"id": 1, "name": "Administrators"},
{"id": 2, "name": "Editors"}
],
created_at="2024-01-01T00:00:00Z",
updated_at="2024-01-01T00:00:00Z",
last_login_at="2024-01-15T12:00:00Z"
)
```
### UserCreate
Model for creating new users.
```python
from wikijs.models import UserCreate
# Minimal user creation
new_user = UserCreate(
email="newuser@example.com",
name="New User",
password_raw="SecurePassword123"
)
# Complete user creation
new_user = UserCreate(
email="newuser@example.com",
name="New User",
password_raw="SecurePassword123",
provider_key="local", # Default: "local"
groups=[1, 2], # Group IDs
must_change_password=False, # Force password change on first login
send_welcome_email=True, # Send welcome email
location="San Francisco",
job_title="Software Engineer",
timezone="America/Los_Angeles"
)
```
**Validation Rules:**
- Email must be valid format
- Name must be 2-255 characters
- Password must be 6-255 characters
- Groups must be list of integer IDs
### UserUpdate
Model for updating existing users. All fields are optional.
```python
from wikijs.models import UserUpdate
# Partial update - only specified fields are changed
update_data = UserUpdate(
name="Jane Doe",
location="Los Angeles"
)
# Complete update
update_data = UserUpdate(
name="Jane Doe",
email="jane@example.com",
password_raw="NewPassword123",
location="Los Angeles",
job_title="Senior Developer",
timezone="America/Los_Angeles",
groups=[1, 2, 3], # Replace all groups
is_active=True,
is_verified=True
)
```
**Notes:**
- Only non-None fields are sent to the API
- Partial updates are fully supported
- Password is optional (only include if changing)
### UserGroup
Represents a user's group membership.
```python
from wikijs.models import UserGroup
group = UserGroup(
id=1,
name="Administrators"
)
```
## Basic Operations
### Synchronous Client
```python
from wikijs import WikiJSClient
from wikijs.models import UserCreate, UserUpdate
# Initialize client
client = WikiJSClient(
base_url="https://wiki.example.com",
auth="your-api-key"
)
# List all users
users = client.users.list()
for user in users:
print(f"{user.name} ({user.email})")
# List with filtering
users = client.users.list(
limit=10,
offset=0,
search="john",
order_by="name",
order_direction="ASC"
)
# Get a specific user
user = client.users.get(user_id=1)
print(f"User: {user.name}")
print(f"Email: {user.email}")
print(f"Groups: {[g.name for g in user.groups]}")
# Create a new user
new_user_data = UserCreate(
email="newuser@example.com",
name="New User",
password_raw="SecurePassword123",
groups=[1, 2]
)
created_user = client.users.create(new_user_data)
print(f"Created user: {created_user.id}")
# Update a user
update_data = UserUpdate(
name="Updated Name",
location="New Location"
)
updated_user = client.users.update(
user_id=created_user.id,
user_data=update_data
)
# Search for users
results = client.users.search("john", limit=5)
for user in results:
print(f"Found: {user.name} ({user.email})")
# Delete a user
success = client.users.delete(user_id=created_user.id)
if success:
print("User deleted successfully")
```
## Async Operations
### Async Client
```python
import asyncio
from wikijs.aio import AsyncWikiJSClient
from wikijs.models import UserCreate, UserUpdate
async def manage_users():
# Initialize async client
async with AsyncWikiJSClient(
base_url="https://wiki.example.com",
auth="your-api-key"
) as client:
# List users
users = await client.users.list()
# Get specific user
user = await client.users.get(user_id=1)
# Create user
new_user_data = UserCreate(
email="newuser@example.com",
name="New User",
password_raw="SecurePassword123"
)
created_user = await client.users.create(new_user_data)
# Update user
update_data = UserUpdate(name="Updated Name")
updated_user = await client.users.update(
user_id=created_user.id,
user_data=update_data
)
# Search users
results = await client.users.search("john", limit=5)
# Delete user
success = await client.users.delete(user_id=created_user.id)
# Run async function
asyncio.run(manage_users())
```
### Concurrent Operations
Process multiple users concurrently for better performance:
```python
import asyncio
from wikijs.aio import AsyncWikiJSClient
from wikijs.models import UserUpdate
async def update_users_concurrently():
async with AsyncWikiJSClient(
base_url="https://wiki.example.com",
auth="your-api-key"
) as client:
# Get all users
users = await client.users.list()
# Update all users concurrently
update_data = UserUpdate(is_verified=True)
tasks = [
client.users.update(user.id, update_data)
for user in users
if not user.is_verified
]
# Execute all updates concurrently
results = await asyncio.gather(*tasks, return_exceptions=True)
# Process results
success_count = sum(1 for r in results if not isinstance(r, Exception))
print(f"Updated {success_count}/{len(tasks)} users")
asyncio.run(update_users_concurrently())
```
## Advanced Usage
### Using Dictionaries Instead of Models
You can use dictionaries instead of model objects:
```python
# Create user from dict
user_dict = {
"email": "user@example.com",
"name": "Test User",
"password_raw": "SecurePassword123",
"groups": [1, 2]
}
created_user = client.users.create(user_dict)
# Update user from dict
update_dict = {
"name": "Updated Name",
"location": "New Location"
}
updated_user = client.users.update(user_id=1, user_data=update_dict)
```
### Pagination
Handle large user lists with pagination:
```python
# Fetch users in batches
def fetch_all_users(client, batch_size=50):
all_users = []
offset = 0
while True:
batch = client.users.list(
limit=batch_size,
offset=offset,
order_by="id",
order_direction="ASC"
)
if not batch:
break
all_users.extend(batch)
offset += batch_size
print(f"Fetched {len(all_users)} users so far...")
return all_users
# Async pagination
async def fetch_all_users_async(client, batch_size=50):
all_users = []
offset = 0
while True:
batch = await client.users.list(
limit=batch_size,
offset=offset,
order_by="id",
order_direction="ASC"
)
if not batch:
break
all_users.extend(batch)
offset += batch_size
return all_users
```
### Group Management
Manage user group assignments:
```python
from wikijs.models import UserUpdate
# Add user to groups
update_data = UserUpdate(groups=[1, 2, 3]) # Group IDs
updated_user = client.users.update(user_id=1, user_data=update_data)
# Remove user from all groups
update_data = UserUpdate(groups=[])
updated_user = client.users.update(user_id=1, user_data=update_data)
# Get user's current groups
user = client.users.get(user_id=1)
print("User groups:")
for group in user.groups:
print(f" - {group.name} (ID: {group.id})")
```
### Bulk User Creation
Create multiple users efficiently:
```python
from wikijs.models import UserCreate
# Sync bulk creation
def create_users_bulk(client, user_data_list):
created_users = []
for user_data in user_data_list:
try:
user = client.users.create(user_data)
created_users.append(user)
print(f"Created: {user.name}")
except Exception as e:
print(f"Failed to create {user_data['name']}: {e}")
return created_users
# Async bulk creation (concurrent)
async def create_users_bulk_async(client, user_data_list):
tasks = [
client.users.create(user_data)
for user_data in user_data_list
]
results = await asyncio.gather(*tasks, return_exceptions=True)
created_users = [
r for r in results if not isinstance(r, Exception)
]
print(f"Created {len(created_users)}/{len(user_data_list)} users")
return created_users
```
## Error Handling
### Common Exceptions
```python
from wikijs.exceptions import (
ValidationError,
APIError,
AuthenticationError,
ConnectionError,
TimeoutError
)
try:
# Create user with invalid data
user_data = UserCreate(
email="invalid-email", # Invalid format
name="Test",
password_raw="123" # Too short
)
except ValidationError as e:
print(f"Validation error: {e}")
try:
# Get non-existent user
user = client.users.get(user_id=99999)
except APIError as e:
print(f"API error: {e}")
try:
# Invalid authentication
client = WikiJSClient(
base_url="https://wiki.example.com",
auth="invalid-key"
)
users = client.users.list()
except AuthenticationError as e:
print(f"Authentication failed: {e}")
```
### Robust Error Handling
```python
from wikijs.exceptions import ValidationError, APIError
def create_user_safely(client, user_data):
"""Create user with comprehensive error handling."""
try:
# Validate data first
validated_data = UserCreate(**user_data)
# Create user
user = client.users.create(validated_data)
print(f"✓ Created user: {user.name} (ID: {user.id})")
return user
except ValidationError as e:
print(f"✗ Validation error: {e}")
# Handle validation errors (e.g., fix data and retry)
return None
except APIError as e:
if "already exists" in str(e).lower():
print(f"✗ User already exists: {user_data['email']}")
# Handle duplicate user
return None
else:
print(f"✗ API error: {e}")
raise
except Exception as e:
print(f"✗ Unexpected error: {e}")
raise
# Async version
async def create_user_safely_async(client, user_data):
try:
validated_data = UserCreate(**user_data)
user = await client.users.create(validated_data)
print(f"✓ Created user: {user.name} (ID: {user.id})")
return user
except ValidationError as e:
print(f"✗ Validation error: {e}")
return None
except APIError as e:
if "already exists" in str(e).lower():
print(f"✗ User already exists: {user_data['email']}")
return None
else:
print(f"✗ API error: {e}")
raise
```
## Best Practices
### 1. Use Models for Type Safety
Always use Pydantic models for better validation and IDE support:
```python
# Good - type safe with validation
user_data = UserCreate(
email="user@example.com",
name="Test User",
password_raw="SecurePassword123"
)
user = client.users.create(user_data)
# Acceptable - but less type safe
user_dict = {
"email": "user@example.com",
"name": "Test User",
"password_raw": "SecurePassword123"
}
user = client.users.create(user_dict)
```
### 2. Handle Pagination for Large Datasets
Always paginate when dealing with many users:
```python
# Good - paginated
all_users = []
offset = 0
batch_size = 50
while True:
batch = client.users.list(limit=batch_size, offset=offset)
if not batch:
break
all_users.extend(batch)
offset += batch_size
# Bad - loads all users at once
all_users = client.users.list() # May be slow for large user bases
```
### 3. Use Async for Concurrent Operations
Use async client for better performance when processing multiple users:
```python
# Good - concurrent async operations
async with AsyncWikiJSClient(...) as client:
tasks = [client.users.get(id) for id in user_ids]
users = await asyncio.gather(*tasks)
# Less efficient - sequential sync operations
for user_id in user_ids:
user = client.users.get(user_id)
```
### 4. Validate Before API Calls
Catch validation errors early:
```python
# Good - validate first
try:
user_data = UserCreate(**raw_data)
user = client.users.create(user_data)
except ValidationError as e:
print(f"Invalid data: {e}")
# Fix data before API call
# Less efficient - validation happens during API call
user = client.users.create(raw_data)
```
### 5. Use Partial Updates
Only update fields that changed:
```python
# Good - only update changed fields
update_data = UserUpdate(name="New Name")
user = client.users.update(user_id=1, user_data=update_data)
# Wasteful - updates all fields
update_data = UserUpdate(
name="New Name",
email=user.email,
location=user.location,
# ... all other fields
)
user = client.users.update(user_id=1, user_data=update_data)
```
### 6. Implement Retry Logic for Production
```python
import time
from wikijs.exceptions import ConnectionError, TimeoutError
def create_user_with_retry(client, user_data, max_retries=3):
"""Create user with automatic retry on transient failures."""
for attempt in range(max_retries):
try:
return client.users.create(user_data)
except (ConnectionError, TimeoutError) as e:
if attempt < max_retries - 1:
wait_time = 2 ** attempt # Exponential backoff
print(f"Retry {attempt + 1}/{max_retries} after {wait_time}s...")
time.sleep(wait_time)
else:
raise
```
### 7. Secure Password Handling
```python
import getpass
from wikijs.models import UserCreate
# Good - prompt for password securely
password = getpass.getpass("Enter password: ")
user_data = UserCreate(
email="user@example.com",
name="Test User",
password_raw=password
)
# Bad - hardcoded passwords
user_data = UserCreate(
email="user@example.com",
name="Test User",
password_raw="password123" # Never do this!
)
```
## Examples
See the `examples/` directory for complete working examples:
- `examples/users_basic.py` - Basic user management operations
- `examples/users_async.py` - Async user management with concurrency
- `examples/users_bulk_import.py` - Bulk user import from CSV
## API Reference
### UsersEndpoint / AsyncUsersEndpoint
#### `list(limit=None, offset=None, search=None, order_by="name", order_direction="ASC")`
List users with optional filtering and pagination.
**Parameters:**
- `limit` (int, optional): Maximum number of users to return
- `offset` (int, optional): Number of users to skip
- `search` (str, optional): Search term (filters by name or email)
- `order_by` (str): Field to sort by (`name`, `email`, `createdAt`, `lastLoginAt`)
- `order_direction` (str): Sort direction (`ASC` or `DESC`)
**Returns:** `List[User]`
**Raises:** `ValidationError`, `APIError`
#### `get(user_id)`
Get a specific user by ID.
**Parameters:**
- `user_id` (int): User ID
**Returns:** `User`
**Raises:** `ValidationError`, `APIError`
#### `create(user_data)`
Create a new user.
**Parameters:**
- `user_data` (UserCreate or dict): User creation data
**Returns:** `User`
**Raises:** `ValidationError`, `APIError`
#### `update(user_id, user_data)`
Update an existing user.
**Parameters:**
- `user_id` (int): User ID
- `user_data` (UserUpdate or dict): User update data
**Returns:** `User`
**Raises:** `ValidationError`, `APIError`
#### `delete(user_id)`
Delete a user.
**Parameters:**
- `user_id` (int): User ID
**Returns:** `bool` (True if successful)
**Raises:** `ValidationError`, `APIError`
#### `search(query, limit=None)`
Search for users by name or email.
**Parameters:**
- `query` (str): Search query
- `limit` (int, optional): Maximum number of results
**Returns:** `List[User]`
**Raises:** `ValidationError`, `APIError`
## Related Documentation
- [Async Usage Guide](async_usage.md)
- [Authentication Guide](../README.md#authentication)
- [API Reference](../README.md#api-documentation)
- [Examples](../examples/)
## Support
For issues and questions:
- GitHub Issues: [wikijs-python-sdk/issues](https://github.com/yourusername/wikijs-python-sdk/issues)
- Documentation: [Full Documentation](../README.md)

View File

@@ -0,0 +1,216 @@
"""Basic async usage examples for Wiki.js Python SDK.
This example demonstrates how to use the AsyncWikiJSClient for
high-performance concurrent operations with Wiki.js.
Requirements:
pip install wikijs-python-sdk[async]
"""
import asyncio
from typing import List
from wikijs.aio import AsyncWikiJSClient
from wikijs.models.page import Page, PageCreate, PageUpdate
async def basic_operations_example():
"""Demonstrate basic async CRUD operations."""
print("\n=== Basic Async Operations ===\n")
# Create client with async context manager (automatic cleanup)
async with AsyncWikiJSClient(
base_url="https://wiki.example.com", auth="your-api-key-here"
) as client:
# Test connection
try:
connected = await client.test_connection()
print(f"✓ Connected to Wiki.js: {connected}")
except Exception as e:
print(f"✗ Connection failed: {e}")
return
# List all pages
print("\nListing pages...")
pages = await client.pages.list(limit=5)
print(f"Found {len(pages)} pages:")
for page in pages:
print(f" - {page.title} ({page.path})")
# Get a specific page by ID
if pages:
page_id = pages[0].id
print(f"\nGetting page {page_id}...")
page = await client.pages.get(page_id)
print(f" Title: {page.title}")
print(f" Path: {page.path}")
print(f" Content length: {len(page.content)} chars")
# Search for pages
print("\nSearching for 'documentation'...")
results = await client.pages.search("documentation", limit=3)
print(f"Found {len(results)} matching pages")
async def concurrent_operations_example():
"""Demonstrate concurrent async operations for better performance."""
print("\n=== Concurrent Operations (High Performance) ===\n")
async with AsyncWikiJSClient(
base_url="https://wiki.example.com", auth="your-api-key-here"
) as client:
# Fetch multiple pages concurrently
page_ids = [1, 2, 3, 4, 5]
print(f"Fetching {len(page_ids)} pages concurrently...")
# Sequential approach (slow)
import time
start = time.time()
sequential_pages: List[Page] = []
for page_id in page_ids:
try:
page = await client.pages.get(page_id)
sequential_pages.append(page)
except Exception:
pass
sequential_time = time.time() - start
# Concurrent approach (fast!)
start = time.time()
tasks = [client.pages.get(page_id) for page_id in page_ids]
concurrent_pages = await asyncio.gather(*tasks, return_exceptions=True)
# Filter out exceptions
concurrent_pages = [p for p in concurrent_pages if isinstance(p, Page)]
concurrent_time = time.time() - start
print(f"\nSequential: {sequential_time:.2f}s")
print(f"Concurrent: {concurrent_time:.2f}s")
print(f"Speedup: {sequential_time / concurrent_time:.1f}x faster")
async def crud_operations_example():
"""Demonstrate Create, Read, Update, Delete operations."""
print("\n=== CRUD Operations ===\n")
async with AsyncWikiJSClient(
base_url="https://wiki.example.com", auth="your-api-key-here"
) as client:
# Create a new page
print("Creating new page...")
new_page_data = PageCreate(
title="Async SDK Example",
path="async-sdk-example",
content="# Async SDK Example\n\nCreated with async client!",
description="Example page created with async operations",
tags=["example", "async", "sdk"],
)
try:
created_page = await client.pages.create(new_page_data)
print(f"✓ Created page: {created_page.title} (ID: {created_page.id})")
# Update the page
print("\nUpdating page...")
update_data = PageUpdate(
title="Async SDK Example (Updated)",
content="# Async SDK Example\n\nUpdated content!",
tags=["example", "async", "sdk", "updated"],
)
updated_page = await client.pages.update(created_page.id, update_data)
print(f"✓ Updated page: {updated_page.title}")
# Read the updated page
print("\nReading updated page...")
fetched_page = await client.pages.get(created_page.id)
print(f"✓ Fetched page: {fetched_page.title}")
print(f" Tags: {', '.join(fetched_page.tags)}")
# Delete the page
print("\nDeleting page...")
deleted = await client.pages.delete(created_page.id)
print(f"✓ Deleted: {deleted}")
except Exception as e:
print(f"✗ Error: {e}")
async def error_handling_example():
"""Demonstrate proper error handling with async operations."""
print("\n=== Error Handling ===\n")
async with AsyncWikiJSClient(
base_url="https://wiki.example.com", auth="invalid-key"
) as client:
# Handle authentication errors
try:
await client.test_connection()
print("✓ Connection successful")
except Exception as e:
print(f"✗ Expected authentication error: {type(e).__name__}")
# Handle not found errors
async with AsyncWikiJSClient(
base_url="https://wiki.example.com", auth="your-api-key-here"
) as client:
try:
page = await client.pages.get(999999)
print(f"Found page: {page.title}")
except Exception as e:
print(f"✗ Expected not found error: {type(e).__name__}")
async def advanced_filtering_example():
"""Demonstrate advanced filtering and searching."""
print("\n=== Advanced Filtering ===\n")
async with AsyncWikiJSClient(
base_url="https://wiki.example.com", auth="your-api-key-here"
) as client:
# Filter by tags
print("Finding pages with specific tags...")
tagged_pages = await client.pages.get_by_tags(
tags=["documentation", "api"], match_all=True # Must have ALL tags
)
print(f"Found {len(tagged_pages)} pages with both tags")
# Search with locale
print("\nSearching in specific locale...")
results = await client.pages.search("guide", locale="en")
print(f"Found {len(results)} English pages")
# List with ordering
print("\nListing recent pages...")
recent_pages = await client.pages.list(
limit=5, order_by="updated_at", order_direction="DESC"
)
print("Most recently updated:")
for page in recent_pages:
print(f" - {page.title}")
async def main():
"""Run all examples."""
print("=" * 60)
print("Wiki.js Python SDK - Async Usage Examples")
print("=" * 60)
# Run examples
await basic_operations_example()
# Uncomment to run other examples:
# await concurrent_operations_example()
# await crud_operations_example()
# await error_handling_example()
# await advanced_filtering_example()
print("\n" + "=" * 60)
print("Examples complete!")
print("=" * 60)
if __name__ == "__main__":
# Run the async main function
asyncio.run(main())

398
examples/users_async.py Normal file
View File

@@ -0,0 +1,398 @@
"""Async users management example for wikijs-python-sdk.
This example demonstrates:
- Async user operations
- Concurrent user processing
- Bulk operations with asyncio.gather
- Performance comparison with sync operations
"""
import asyncio
import time
from typing import List
from wikijs.aio import AsyncWikiJSClient
from wikijs.exceptions import APIError, ValidationError
from wikijs.models import User, UserCreate, UserUpdate
async def basic_async_operations():
"""Demonstrate basic async user operations."""
print("=" * 60)
print("Async Users API - Basic Operations")
print("=" * 60)
# Initialize async client with context manager
async with AsyncWikiJSClient(
base_url="https://wiki.example.com", auth="your-api-key-here"
) as client:
# 1. List users
print("\n1. Listing all users...")
try:
users = await client.users.list()
print(f" Found {len(users)} users")
for user in users[:5]:
print(f" - {user.name} ({user.email})")
except APIError as e:
print(f" Error: {e}")
# 2. Search users
print("\n2. Searching for users...")
try:
results = await client.users.search("admin", limit=5)
print(f" Found {len(results)} matching users")
except APIError as e:
print(f" Error: {e}")
# 3. Create user
print("\n3. Creating a new user...")
try:
new_user_data = UserCreate(
email="asynctest@example.com",
name="Async Test User",
password_raw="SecurePassword123",
location="Remote",
job_title="Engineer",
)
created_user = await client.users.create(new_user_data)
print(f" ✓ Created: {created_user.name} (ID: {created_user.id})")
test_user_id = created_user.id
except (ValidationError, APIError) as e:
print(f" ✗ Error: {e}")
return
# 4. Get user
print(f"\n4. Getting user {test_user_id}...")
try:
user = await client.users.get(test_user_id)
print(f" User: {user.name}")
print(f" Email: {user.email}")
print(f" Location: {user.location}")
except APIError as e:
print(f" Error: {e}")
# 5. Update user
print(f"\n5. Updating user...")
try:
update_data = UserUpdate(
name="Updated Async User", location="San Francisco"
)
updated_user = await client.users.update(test_user_id, update_data)
print(f" ✓ Updated: {updated_user.name}")
print(f" Location: {updated_user.location}")
except APIError as e:
print(f" Error: {e}")
# 6. Delete user
print(f"\n6. Deleting test user...")
try:
await client.users.delete(test_user_id)
print(f" ✓ User deleted")
except APIError as e:
print(f" Error: {e}")
async def concurrent_user_fetch():
"""Demonstrate concurrent user fetching for better performance."""
print("\n" + "=" * 60)
print("Concurrent User Fetching")
print("=" * 60)
async with AsyncWikiJSClient(
base_url="https://wiki.example.com", auth="your-api-key-here"
) as client:
# Get list of user IDs
print("\n1. Getting list of users...")
users = await client.users.list(limit=10)
user_ids = [user.id for user in users]
print(f" Will fetch {len(user_ids)} users concurrently")
# Fetch all users concurrently
print("\n2. Fetching users concurrently...")
start_time = time.time()
tasks = [client.users.get(user_id) for user_id in user_ids]
fetched_users = await asyncio.gather(*tasks, return_exceptions=True)
elapsed = time.time() - start_time
# Process results
successful = [u for u in fetched_users if isinstance(u, User)]
failed = [u for u in fetched_users if isinstance(u, Exception)]
print(f" ✓ Fetched {len(successful)} users successfully")
print(f" ✗ Failed: {len(failed)}")
print(f" ⏱ Time: {elapsed:.2f}s")
print(f" 📊 Average: {elapsed/len(user_ids):.3f}s per user")
async def bulk_user_creation():
"""Demonstrate bulk user creation with concurrent operations."""
print("\n" + "=" * 60)
print("Bulk User Creation (Concurrent)")
print("=" * 60)
async with AsyncWikiJSClient(
base_url="https://wiki.example.com", auth="your-api-key-here"
) as client:
# Prepare user data
print("\n1. Preparing user data...")
users_to_create = [
UserCreate(
email=f"bulkuser{i}@example.com",
name=f"Bulk User {i}",
password_raw=f"SecurePass{i}123",
location="Test Location",
job_title="Test Engineer",
)
for i in range(1, 6)
]
print(f" Prepared {len(users_to_create)} users")
# Create all users concurrently
print("\n2. Creating users concurrently...")
start_time = time.time()
tasks = [client.users.create(user_data) for user_data in users_to_create]
results = await asyncio.gather(*tasks, return_exceptions=True)
elapsed = time.time() - start_time
# Process results
created_users = [r for r in results if isinstance(r, User)]
failed = [r for r in results if isinstance(r, Exception)]
print(f" ✓ Created: {len(created_users)} users")
print(f" ✗ Failed: {len(failed)}")
print(f" ⏱ Time: {elapsed:.2f}s")
# Show created users
for user in created_users:
print(f" - {user.name} (ID: {user.id})")
# Update all users concurrently
if created_users:
print("\n3. Updating all users concurrently...")
update_data = UserUpdate(location="Updated Location", is_verified=True)
tasks = [
client.users.update(user.id, update_data) for user in created_users
]
updated_users = await asyncio.gather(*tasks, return_exceptions=True)
successful_updates = [u for u in updated_users if isinstance(u, User)]
print(f" ✓ Updated: {len(successful_updates)} users")
# Delete all test users
if created_users:
print("\n4. Cleaning up (deleting test users)...")
tasks = [client.users.delete(user.id) for user in created_users]
results = await asyncio.gather(*tasks, return_exceptions=True)
successful_deletes = [r for r in results if r is True]
print(f" ✓ Deleted: {len(successful_deletes)} users")
async def performance_comparison():
"""Compare sync vs async performance."""
print("\n" + "=" * 60)
print("Performance Comparison: Sync vs Async")
print("=" * 60)
async with AsyncWikiJSClient(
base_url="https://wiki.example.com", auth="your-api-key-here"
) as async_client:
# Get list of user IDs
users = await async_client.users.list(limit=20)
user_ids = [user.id for user in users[:10]] # Use first 10
print(f"\nFetching {len(user_ids)} users...")
# Async concurrent fetching
print("\n1. Async (concurrent):")
start_time = time.time()
tasks = [async_client.users.get(user_id) for user_id in user_ids]
async_results = await asyncio.gather(*tasks, return_exceptions=True)
async_time = time.time() - start_time
async_successful = len([r for r in async_results if isinstance(r, User)])
print(f" Fetched: {async_successful} users")
print(f" Time: {async_time:.2f}s")
print(f" Rate: {len(user_ids)/async_time:.1f} users/sec")
# Async sequential fetching (for comparison)
print("\n2. Async (sequential):")
start_time = time.time()
sequential_results = []
for user_id in user_ids:
try:
user = await async_client.users.get(user_id)
sequential_results.append(user)
except Exception as e:
sequential_results.append(e)
sequential_time = time.time() - start_time
seq_successful = len([r for r in sequential_results if isinstance(r, User)])
print(f" Fetched: {seq_successful} users")
print(f" Time: {sequential_time:.2f}s")
print(f" Rate: {len(user_ids)/sequential_time:.1f} users/sec")
# Calculate speedup
speedup = sequential_time / async_time
print(f"\n📊 Performance Summary:")
print(f" Concurrent speedup: {speedup:.1f}x faster")
print(f" Time saved: {sequential_time - async_time:.2f}s")
async def batch_user_updates():
"""Demonstrate batch updates with progress tracking."""
print("\n" + "=" * 60)
print("Batch User Updates with Progress Tracking")
print("=" * 60)
async with AsyncWikiJSClient(
base_url="https://wiki.example.com", auth="your-api-key-here"
) as client:
# Get users to update
print("\n1. Finding users to update...")
users = await client.users.list(limit=10)
print(f" Found {len(users)} users")
# Update all users concurrently with progress
print("\n2. Updating users...")
update_data = UserUpdate(is_verified=True)
async def update_with_progress(user: User, index: int, total: int):
"""Update user and show progress."""
try:
updated = await client.users.update(user.id, update_data)
print(f" [{index}/{total}] ✓ Updated: {updated.name}")
return updated
except Exception as e:
print(f" [{index}/{total}] ✗ Failed: {user.name} - {e}")
return e
tasks = [
update_with_progress(user, i + 1, len(users))
for i, user in enumerate(users)
]
results = await asyncio.gather(*tasks)
# Summary
successful = len([r for r in results if isinstance(r, User)])
failed = len([r for r in results if isinstance(r, Exception)])
print(f"\n Summary:")
print(f" ✓ Successful: {successful}")
print(f" ✗ Failed: {failed}")
async def advanced_error_handling():
"""Demonstrate advanced error handling patterns."""
print("\n" + "=" * 60)
print("Advanced Error Handling")
print("=" * 60)
async with AsyncWikiJSClient(
base_url="https://wiki.example.com", auth="your-api-key-here"
) as client:
print("\n1. Individual error handling:")
# Try to create multiple users with mixed valid/invalid data
test_users = [
{
"email": "valid1@example.com",
"name": "Valid User 1",
"password_raw": "SecurePass123",
},
{
"email": "invalid-email",
"name": "Invalid Email",
"password_raw": "SecurePass123",
},
{
"email": "valid2@example.com",
"name": "Valid User 2",
"password_raw": "123",
}, # Weak password
{
"email": "valid3@example.com",
"name": "Valid User 3",
"password_raw": "SecurePass123",
},
]
async def create_user_safe(user_data: dict):
"""Create user with error handling."""
try:
validated_data = UserCreate(**user_data)
user = await client.users.create(validated_data)
print(f" ✓ Created: {user.name}")
return user
except ValidationError as e:
print(f" ✗ Validation error for {user_data.get('email')}: {e}")
return None
except APIError as e:
print(f" ✗ API error for {user_data.get('email')}: {e}")
return None
results = await asyncio.gather(*[create_user_safe(u) for u in test_users])
# Clean up created users
created = [r for r in results if r is not None]
if created:
print(f"\n2. Cleaning up {len(created)} created users...")
await asyncio.gather(*[client.users.delete(u.id) for u in created])
print(" ✓ Cleanup complete")
async def main():
"""Run all async examples."""
try:
# Basic operations
await basic_async_operations()
# Concurrent operations
await concurrent_user_fetch()
# Bulk operations
await bulk_user_creation()
# Performance comparison
await performance_comparison()
# Batch updates
await batch_user_updates()
# Error handling
await advanced_error_handling()
print("\n" + "=" * 60)
print("All examples completed!")
print("=" * 60)
except KeyboardInterrupt:
print("\n\nInterrupted by user")
except Exception as e:
print(f"\n\nUnexpected error: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
# Run all examples
asyncio.run(main())

301
examples/users_basic.py Normal file
View File

@@ -0,0 +1,301 @@
"""Basic users management example for wikijs-python-sdk.
This example demonstrates:
- Creating users
- Reading user information
- Updating users
- Deleting users
- Searching users
- Managing user groups
"""
from wikijs import WikiJSClient
from wikijs.exceptions import APIError, ValidationError
from wikijs.models import UserCreate, UserUpdate
def main():
"""Run basic user management operations."""
# Initialize client
client = WikiJSClient(
base_url="https://wiki.example.com",
auth="your-api-key-here", # Replace with your actual API key
)
print("=" * 60)
print("Wiki.js Users API - Basic Operations Example")
print("=" * 60)
# 1. List all users
print("\n1. Listing all users...")
try:
users = client.users.list()
print(f" Found {len(users)} users")
for user in users[:5]: # Show first 5
print(f" - {user.name} ({user.email}) - Active: {user.is_active}")
except APIError as e:
print(f" Error listing users: {e}")
# 2. List users with filtering
print("\n2. Listing users with pagination and ordering...")
try:
users = client.users.list(
limit=10, offset=0, order_by="email", order_direction="ASC"
)
print(f" Found {len(users)} users (first 10)")
except APIError as e:
print(f" Error: {e}")
# 3. Search for users
print("\n3. Searching for users...")
try:
search_term = "admin"
results = client.users.search(search_term, limit=5)
print(f" Found {len(results)} users matching '{search_term}'")
for user in results:
print(f" - {user.name} ({user.email})")
except APIError as e:
print(f" Error searching: {e}")
# 4. Create a new user
print("\n4. Creating a new user...")
try:
new_user_data = UserCreate(
email="testuser@example.com",
name="Test User",
password_raw="SecurePassword123",
groups=[1], # Assign to group with ID 1
location="San Francisco",
job_title="QA Engineer",
timezone="America/Los_Angeles",
send_welcome_email=False, # Don't send email for test user
must_change_password=True,
)
created_user = client.users.create(new_user_data)
print(f" ✓ Created user: {created_user.name}")
print(f" ID: {created_user.id}")
print(f" Email: {created_user.email}")
print(f" Active: {created_user.is_active}")
print(f" Verified: {created_user.is_verified}")
# Save user ID for later operations
test_user_id = created_user.id
except ValidationError as e:
print(f" ✗ Validation error: {e}")
return
except APIError as e:
print(f" ✗ API error: {e}")
if "already exists" in str(e).lower():
print(" Note: User might already exist from previous run")
return
# 5. Get specific user
print(f"\n5. Getting user by ID ({test_user_id})...")
try:
user = client.users.get(test_user_id)
print(f" User: {user.name}")
print(f" Email: {user.email}")
print(f" Location: {user.location}")
print(f" Job Title: {user.job_title}")
print(f" Groups: {[g.name for g in user.groups]}")
except APIError as e:
print(f" Error: {e}")
# 6. Update user information
print(f"\n6. Updating user...")
try:
update_data = UserUpdate(
name="Updated Test User",
location="New York",
job_title="Senior QA Engineer",
is_verified=True,
)
updated_user = client.users.update(test_user_id, update_data)
print(f" ✓ Updated user: {updated_user.name}")
print(f" New location: {updated_user.location}")
print(f" New job title: {updated_user.job_title}")
print(f" Verified: {updated_user.is_verified}")
except APIError as e:
print(f" Error: {e}")
# 7. Update user password
print(f"\n7. Updating user password...")
try:
password_update = UserUpdate(password_raw="NewSecurePassword456")
updated_user = client.users.update(test_user_id, password_update)
print(f" ✓ Password updated for user: {updated_user.name}")
except APIError as e:
print(f" Error: {e}")
# 8. Manage user groups
print(f"\n8. Managing user groups...")
try:
# Add user to multiple groups
group_update = UserUpdate(groups=[1, 2, 3])
updated_user = client.users.update(test_user_id, group_update)
print(f" ✓ User groups updated")
print(f" Groups: {[g.name for g in updated_user.groups]}")
except APIError as e:
print(f" Error: {e}")
# 9. Deactivate user
print(f"\n9. Deactivating user...")
try:
deactivate_update = UserUpdate(is_active=False)
updated_user = client.users.update(test_user_id, deactivate_update)
print(f" ✓ User deactivated: {updated_user.name}")
print(f" Active: {updated_user.is_active}")
except APIError as e:
print(f" Error: {e}")
# 10. Reactivate user
print(f"\n10. Reactivating user...")
try:
reactivate_update = UserUpdate(is_active=True)
updated_user = client.users.update(test_user_id, reactivate_update)
print(f" ✓ User reactivated: {updated_user.name}")
print(f" Active: {updated_user.is_active}")
except APIError as e:
print(f" Error: {e}")
# 11. Delete user
print(f"\n11. Deleting test user...")
try:
success = client.users.delete(test_user_id)
if success:
print(f" ✓ User deleted successfully")
except APIError as e:
print(f" Error: {e}")
if "system user" in str(e).lower():
print(" Note: Cannot delete system users")
# 12. Demonstrate error handling
print("\n12. Demonstrating error handling...")
# Try to create user with invalid email
print(" a) Invalid email validation:")
try:
invalid_user = UserCreate(
email="not-an-email", name="Test", password_raw="password123"
)
client.users.create(invalid_user)
except ValidationError as e:
print(f" ✓ Caught validation error: {e}")
# Try to create user with weak password
print(" b) Weak password validation:")
try:
weak_password_user = UserCreate(
email="test@example.com", name="Test User", password_raw="123" # Too short
)
client.users.create(weak_password_user)
except ValidationError as e:
print(f" ✓ Caught validation error: {e}")
# Try to get non-existent user
print(" c) Non-existent user:")
try:
user = client.users.get(99999)
except APIError as e:
print(f" ✓ Caught API error: {e}")
print("\n" + "=" * 60)
print("Example completed!")
print("=" * 60)
def demonstrate_bulk_operations():
"""Demonstrate bulk user operations."""
client = WikiJSClient(base_url="https://wiki.example.com", auth="your-api-key-here")
print("\n" + "=" * 60)
print("Bulk Operations Example")
print("=" * 60)
# Create multiple users
print("\n1. Creating multiple users...")
users_to_create = [
{
"email": f"user{i}@example.com",
"name": f"User {i}",
"password_raw": f"SecurePass{i}123",
"job_title": "Team Member",
}
for i in range(1, 4)
]
created_users = []
for user_data in users_to_create:
try:
user = client.users.create(UserCreate(**user_data))
created_users.append(user)
print(f" ✓ Created: {user.name}")
except (ValidationError, APIError) as e:
print(f" ✗ Failed to create {user_data['name']}: {e}")
# Update all created users
print("\n2. Updating all created users...")
update_data = UserUpdate(location="Team Location", is_verified=True)
for user in created_users:
try:
updated_user = client.users.update(user.id, update_data)
print(f" ✓ Updated: {updated_user.name}")
except APIError as e:
print(f" ✗ Failed to update {user.name}: {e}")
# Delete all created users
print("\n3. Cleaning up (deleting test users)...")
for user in created_users:
try:
client.users.delete(user.id)
print(f" ✓ Deleted: {user.name}")
except APIError as e:
print(f" ✗ Failed to delete {user.name}: {e}")
def demonstrate_pagination():
"""Demonstrate pagination for large user lists."""
client = WikiJSClient(base_url="https://wiki.example.com", auth="your-api-key-here")
print("\n" + "=" * 60)
print("Pagination Example")
print("=" * 60)
# Fetch all users in batches
print("\nFetching all users in batches of 50...")
all_users = []
offset = 0
batch_size = 50
while True:
try:
batch = client.users.list(
limit=batch_size, offset=offset, order_by="id", order_direction="ASC"
)
if not batch:
break
all_users.extend(batch)
offset += batch_size
print(f" Fetched batch: {len(batch)} users (total: {len(all_users)})")
except APIError as e:
print(f" Error fetching batch: {e}")
break
print(f"\nTotal users fetched: {len(all_users)}")
if __name__ == "__main__":
# Run main example
main()
# Uncomment to run additional examples:
# demonstrate_bulk_operations()
# demonstrate_pagination()

1
tests/aio/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Tests for async WikiJS client."""

View File

@@ -0,0 +1,307 @@
"""Tests for AsyncWikiJSClient."""
import json
from unittest.mock import AsyncMock, Mock, patch
import aiohttp
import pytest
from wikijs.aio import AsyncWikiJSClient
from wikijs.auth import APIKeyAuth
from wikijs.exceptions import (
APIError,
AuthenticationError,
ConfigurationError,
ConnectionError,
TimeoutError,
)
class TestAsyncWikiJSClientInit:
"""Test AsyncWikiJSClient initialization."""
def test_init_with_api_key_string(self):
"""Test initialization with API key string."""
client = AsyncWikiJSClient("https://wiki.example.com", auth="test-key")
assert client.base_url == "https://wiki.example.com"
assert isinstance(client._auth_handler, APIKeyAuth)
assert client.timeout == 30
assert client.verify_ssl is True
assert "wikijs-python-sdk" in client.user_agent
def test_init_with_auth_handler(self):
"""Test initialization with auth handler."""
auth_handler = APIKeyAuth("test-key")
client = AsyncWikiJSClient("https://wiki.example.com", auth=auth_handler)
assert client._auth_handler is auth_handler
def test_init_invalid_auth(self):
"""Test initialization with invalid auth parameter."""
with pytest.raises(ConfigurationError, match="Invalid auth parameter"):
AsyncWikiJSClient("https://wiki.example.com", auth=123)
def test_init_with_custom_settings(self):
"""Test initialization with custom settings."""
client = AsyncWikiJSClient(
"https://wiki.example.com",
auth="test-key",
timeout=60,
verify_ssl=False,
user_agent="Custom Agent",
)
assert client.timeout == 60
assert client.verify_ssl is False
assert client.user_agent == "Custom Agent"
def test_has_pages_endpoint(self):
"""Test that client has pages endpoint."""
client = AsyncWikiJSClient("https://wiki.example.com", auth="test-key")
assert hasattr(client, "pages")
assert client.pages._client is client
class TestAsyncWikiJSClientRequest:
"""Test AsyncWikiJSClient HTTP request methods."""
@pytest.fixture
def client(self):
"""Create test client."""
return AsyncWikiJSClient("https://wiki.example.com", auth="test-key")
@pytest.mark.asyncio
async def test_successful_request(self, client):
"""Test successful API request."""
mock_response = AsyncMock()
mock_response.status = 200
# Response returns full data structure
mock_response.json = AsyncMock(return_value={"data": {"result": "success"}})
# Create a context manager mock
mock_ctx_manager = AsyncMock()
mock_ctx_manager.__aenter__.return_value = mock_response
mock_ctx_manager.__aexit__.return_value = False
with patch.object(client, "_get_session") as mock_get_session:
mock_session = Mock()
mock_session.request = Mock(return_value=mock_ctx_manager)
mock_get_session.return_value = mock_session
result = await client._request("GET", "/test")
# parse_wiki_response returns full response if no errors
assert result == {"data": {"result": "success"}}
mock_session.request.assert_called_once()
@pytest.mark.asyncio
async def test_authentication_error(self, client):
"""Test 401 authentication error."""
mock_response = AsyncMock()
mock_response.status = 401
# Create a context manager mock
mock_ctx_manager = AsyncMock()
mock_ctx_manager.__aenter__.return_value = mock_response
mock_ctx_manager.__aexit__.return_value = False
with patch.object(client, "_get_session") as mock_get_session:
mock_session = Mock()
mock_session.request = Mock(return_value=mock_ctx_manager)
mock_get_session.return_value = mock_session
with pytest.raises(AuthenticationError, match="Authentication failed"):
await client._request("GET", "/test")
@pytest.mark.asyncio
async def test_api_error(self, client):
"""Test API error handling."""
mock_response = AsyncMock()
mock_response.status = 500
mock_response.text = AsyncMock(return_value="Internal Server Error")
# Create a context manager mock
mock_ctx_manager = AsyncMock()
mock_ctx_manager.__aenter__.return_value = mock_response
mock_ctx_manager.__aexit__.return_value = False
with patch.object(client, "_get_session") as mock_get_session:
mock_session = Mock()
mock_session.request = Mock(return_value=mock_ctx_manager)
mock_get_session.return_value = mock_session
with pytest.raises(APIError):
await client._request("GET", "/test")
@pytest.mark.asyncio
async def test_connection_error(self, client):
"""Test connection error handling."""
with patch.object(client, "_get_session") as mock_get_session:
mock_session = Mock()
mock_session.request = Mock(
side_effect=aiohttp.ClientConnectionError("Connection failed")
)
mock_get_session.return_value = mock_session
with pytest.raises(ConnectionError, match="Failed to connect"):
await client._request("GET", "/test")
@pytest.mark.asyncio
async def test_timeout_error(self, client):
"""Test timeout error handling."""
with patch.object(client, "_get_session") as mock_get_session:
mock_session = Mock()
mock_session.request = Mock(
side_effect=aiohttp.ServerTimeoutError("Timeout")
)
mock_get_session.return_value = mock_session
with pytest.raises(TimeoutError, match="timed out"):
await client._request("GET", "/test")
class TestAsyncWikiJSClientTestConnection:
"""Test AsyncWikiJSClient connection testing."""
@pytest.fixture
def client(self):
"""Create test client."""
return AsyncWikiJSClient("https://wiki.example.com", auth="test-key")
@pytest.mark.asyncio
async def test_successful_connection(self, client):
"""Test successful connection test."""
mock_response = {"data": {"site": {"title": "Test Wiki"}}}
with patch.object(client, "_request", new_callable=AsyncMock) as mock_request:
mock_request.return_value = mock_response
result = await client.test_connection()
assert result is True
mock_request.assert_called_once()
args, kwargs = mock_request.call_args
assert args[0] == "POST"
assert args[1] == "/graphql"
assert "query" in kwargs["json_data"]
@pytest.mark.asyncio
async def test_connection_graphql_error(self, client):
"""Test connection with GraphQL error."""
mock_response = {"errors": [{"message": "Unauthorized"}]}
with patch.object(client, "_request", new_callable=AsyncMock) as mock_request:
mock_request.return_value = mock_response
with pytest.raises(AuthenticationError, match="GraphQL query failed"):
await client.test_connection()
@pytest.mark.asyncio
async def test_connection_invalid_response(self, client):
"""Test connection with invalid response."""
mock_response = {"data": {}} # Missing 'site' key
with patch.object(client, "_request", new_callable=AsyncMock) as mock_request:
mock_request.return_value = mock_response
with pytest.raises(APIError, match="Unexpected response format"):
await client.test_connection()
@pytest.mark.asyncio
async def test_connection_no_base_url(self):
"""Test connection with no base URL."""
client = AsyncWikiJSClient("https://wiki.example.com", auth="test-key")
client.base_url = None
with pytest.raises(ConfigurationError, match="Base URL not configured"):
await client.test_connection()
class TestAsyncWikiJSClientContextManager:
"""Test AsyncWikiJSClient async context manager."""
@pytest.mark.asyncio
async def test_context_manager(self):
"""Test async context manager."""
client = AsyncWikiJSClient("https://wiki.example.com", auth="test-key")
# Mock the session
mock_session = AsyncMock()
mock_session.closed = False
with patch.object(client, "_create_session", return_value=mock_session):
async with client as ctx_client:
assert ctx_client is client
assert client._session is mock_session
# Check that close was called
mock_session.close.assert_called_once()
@pytest.mark.asyncio
async def test_manual_close(self):
"""Test manual close."""
client = AsyncWikiJSClient("https://wiki.example.com", auth="test-key")
# Mock the session
mock_session = AsyncMock()
mock_session.closed = False
client._session = mock_session
await client.close()
mock_session.close.assert_called_once()
class TestAsyncWikiJSClientSessionCreation:
"""Test AsyncWikiJSClient session creation."""
@pytest.mark.asyncio
async def test_create_session(self):
"""Test session creation."""
client = AsyncWikiJSClient("https://wiki.example.com", auth="test-key")
session = client._create_session()
assert isinstance(session, aiohttp.ClientSession)
assert "wikijs-python-sdk" in session.headers["User-Agent"]
assert session.headers["Accept"] == "application/json"
assert session.headers["Content-Type"] == "application/json"
# Clean up
await session.close()
if client._connector:
await client._connector.close()
@pytest.mark.asyncio
async def test_get_session_creates_if_none(self):
"""Test get_session creates session if none exists."""
client = AsyncWikiJSClient("https://wiki.example.com", auth="test-key")
assert client._session is None
session = client._get_session()
assert session is not None
assert isinstance(session, aiohttp.ClientSession)
# Clean up
await session.close()
if client._connector:
await client._connector.close()
@pytest.mark.asyncio
async def test_get_session_reuses_existing(self):
"""Test get_session reuses existing session."""
client = AsyncWikiJSClient("https://wiki.example.com", auth="test-key")
session1 = client._get_session()
session2 = client._get_session()
assert session1 is session2
# Clean up
await session1.close()
if client._connector:
await client._connector.close()

View File

@@ -0,0 +1,211 @@
"""Tests for async Groups endpoint."""
from unittest.mock import AsyncMock, Mock
import pytest
from wikijs.aio.endpoints import AsyncGroupsEndpoint
from wikijs.exceptions import APIError, ValidationError
from wikijs.models import Group, GroupCreate, GroupUpdate
class TestAsyncGroupsEndpoint:
"""Test AsyncGroupsEndpoint class."""
@pytest.fixture
def client(self):
"""Create mock async client."""
mock_client = Mock()
mock_client.base_url = "https://wiki.example.com"
mock_client._request = AsyncMock()
return mock_client
@pytest.fixture
def endpoint(self, client):
"""Create AsyncGroupsEndpoint instance."""
return AsyncGroupsEndpoint(client)
@pytest.mark.asyncio
async def test_list_groups(self, endpoint):
"""Test listing groups."""
mock_response = {
"data": {
"groups": {
"list": [
{
"id": 1,
"name": "Administrators",
"isSystem": False,
"redirectOnLogin": "/",
"permissions": ["manage:system"],
"pageRules": [],
"users": [],
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
}
]
}
}
}
endpoint._post = AsyncMock(return_value=mock_response)
groups = await endpoint.list()
assert len(groups) == 1
assert isinstance(groups[0], Group)
assert groups[0].name == "Administrators"
@pytest.mark.asyncio
async def test_get_group(self, endpoint):
"""Test getting a group."""
mock_response = {
"data": {
"groups": {
"single": {
"id": 1,
"name": "Administrators",
"isSystem": False,
"redirectOnLogin": "/",
"permissions": ["manage:system"],
"pageRules": [],
"users": [{"id": 1, "name": "Admin", "email": "admin@example.com"}],
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
}
}
}
}
endpoint._post = AsyncMock(return_value=mock_response)
group = await endpoint.get(1)
assert isinstance(group, Group)
assert group.id == 1
assert len(group.users) == 1
@pytest.mark.asyncio
async def test_create_group(self, endpoint):
"""Test creating a group."""
group_data = GroupCreate(name="Editors", permissions=["read:pages"])
mock_response = {
"data": {
"groups": {
"create": {
"responseResult": {"succeeded": True},
"group": {
"id": 2,
"name": "Editors",
"isSystem": False,
"redirectOnLogin": "/",
"permissions": ["read:pages"],
"pageRules": [],
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
},
}
}
}
}
endpoint._post = AsyncMock(return_value=mock_response)
group = await endpoint.create(group_data)
assert isinstance(group, Group)
assert group.name == "Editors"
@pytest.mark.asyncio
async def test_update_group(self, endpoint):
"""Test updating a group."""
update_data = GroupUpdate(name="Senior Editors")
mock_response = {
"data": {
"groups": {
"update": {
"responseResult": {"succeeded": True},
"group": {
"id": 1,
"name": "Senior Editors",
"isSystem": False,
"redirectOnLogin": "/",
"permissions": [],
"pageRules": [],
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-02T00:00:00Z",
},
}
}
}
}
endpoint._post = AsyncMock(return_value=mock_response)
group = await endpoint.update(1, update_data)
assert group.name == "Senior Editors"
@pytest.mark.asyncio
async def test_delete_group(self, endpoint):
"""Test deleting a group."""
mock_response = {
"data": {
"groups": {
"delete": {
"responseResult": {"succeeded": True}
}
}
}
}
endpoint._post = AsyncMock(return_value=mock_response)
result = await endpoint.delete(1)
assert result is True
@pytest.mark.asyncio
async def test_assign_user(self, endpoint):
"""Test assigning a user to a group."""
mock_response = {
"data": {
"groups": {
"assignUser": {
"responseResult": {"succeeded": True}
}
}
}
}
endpoint._post = AsyncMock(return_value=mock_response)
result = await endpoint.assign_user(group_id=1, user_id=5)
assert result is True
@pytest.mark.asyncio
async def test_unassign_user(self, endpoint):
"""Test removing a user from a group."""
mock_response = {
"data": {
"groups": {
"unassignUser": {
"responseResult": {"succeeded": True}
}
}
}
}
endpoint._post = AsyncMock(return_value=mock_response)
result = await endpoint.unassign_user(group_id=1, user_id=5)
assert result is True
@pytest.mark.asyncio
async def test_validation_errors(self, endpoint):
"""Test validation errors."""
with pytest.raises(ValidationError):
await endpoint.get(0)
with pytest.raises(ValidationError):
await endpoint.delete(-1)
with pytest.raises(ValidationError):
await endpoint.assign_user(0, 1)

View File

@@ -0,0 +1,359 @@
"""Tests for AsyncPagesEndpoint."""
from unittest.mock import AsyncMock, Mock
import pytest
from wikijs.aio import AsyncWikiJSClient
from wikijs.aio.endpoints.pages import AsyncPagesEndpoint
from wikijs.exceptions import APIError, ValidationError
from wikijs.models.page import Page, PageCreate, PageUpdate
class TestAsyncPagesEndpoint:
"""Test suite for AsyncPagesEndpoint."""
@pytest.fixture
def mock_client(self):
"""Create a mock async WikiJS client."""
client = Mock(spec=AsyncWikiJSClient)
return client
@pytest.fixture
def pages_endpoint(self, mock_client):
"""Create an AsyncPagesEndpoint instance with mock client."""
return AsyncPagesEndpoint(mock_client)
@pytest.fixture
def sample_page_data(self):
"""Sample page data from API."""
return {
"id": 123,
"title": "Test Page",
"path": "test-page",
"content": "# Test Page\n\nThis is test content.",
"description": "A test page",
"isPublished": True,
"isPrivate": False,
"tags": ["test", "example"],
"locale": "en",
"authorId": 1,
"authorName": "Test User",
"authorEmail": "test@example.com",
"editor": "markdown",
"createdAt": "2023-01-01T00:00:00Z",
"updatedAt": "2023-01-02T00:00:00Z",
}
@pytest.fixture
def sample_page_create(self):
"""Sample PageCreate object."""
return PageCreate(
title="New Page",
path="new-page",
content="# New Page\n\nContent here.",
description="A new page",
tags=["new", "test"],
)
@pytest.fixture
def sample_page_update(self):
"""Sample PageUpdate object."""
return PageUpdate(
title="Updated Page",
content="# Updated Page\n\nUpdated content.",
tags=["updated", "test"],
)
def test_init(self, mock_client):
"""Test AsyncPagesEndpoint initialization."""
endpoint = AsyncPagesEndpoint(mock_client)
assert endpoint._client is mock_client
@pytest.mark.asyncio
async def test_list_basic(self, pages_endpoint, sample_page_data):
"""Test basic page listing."""
# Mock the GraphQL response structure that matches Wiki.js schema
mock_response = {"data": {"pages": {"list": [sample_page_data]}}}
pages_endpoint._post = AsyncMock(return_value=mock_response)
# Call list method
pages = await pages_endpoint.list()
# Verify request
pages_endpoint._post.assert_called_once()
call_args = pages_endpoint._post.call_args
assert call_args[0][0] == "/graphql"
# Verify response
assert len(pages) == 1
assert isinstance(pages[0], Page)
assert pages[0].id == 123
assert pages[0].title == "Test Page"
assert pages[0].path == "test-page"
@pytest.mark.asyncio
async def test_list_with_parameters(self, pages_endpoint, sample_page_data):
"""Test page listing with filter parameters."""
mock_response = {"data": {"pages": {"list": [sample_page_data]}}}
pages_endpoint._post = AsyncMock(return_value=mock_response)
# Call with parameters
pages = await pages_endpoint.list(
limit=10, offset=0, search="test", locale="en", order_by="title"
)
# Verify request
call_args = pages_endpoint._post.call_args
json_data = call_args[1]["json_data"]
variables = json_data.get("variables", {})
assert variables["limit"] == 10
assert variables["offset"] == 0
assert variables["search"] == "test"
assert variables["locale"] == "en"
assert variables["orderBy"] == "title"
# Verify response
assert len(pages) == 1
@pytest.mark.asyncio
async def test_list_validation_error(self, pages_endpoint):
"""Test validation errors in list method."""
# Test invalid limit
with pytest.raises(ValidationError, match="limit must be greater than 0"):
await pages_endpoint.list(limit=0)
# Test invalid offset
with pytest.raises(ValidationError, match="offset must be non-negative"):
await pages_endpoint.list(offset=-1)
# Test invalid order_by
with pytest.raises(
ValidationError, match="order_by must be one of: title, created_at"
):
await pages_endpoint.list(order_by="invalid")
@pytest.mark.asyncio
async def test_get_by_id(self, pages_endpoint, sample_page_data):
"""Test getting a page by ID."""
mock_response = {"data": {"pages": {"single": sample_page_data}}}
pages_endpoint._post = AsyncMock(return_value=mock_response)
page = await pages_endpoint.get(123)
# Verify request
pages_endpoint._post.assert_called_once()
call_args = pages_endpoint._post.call_args
json_data = call_args[1]["json_data"]
assert json_data["variables"]["id"] == 123
# Verify response
assert isinstance(page, Page)
assert page.id == 123
assert page.title == "Test Page"
@pytest.mark.asyncio
async def test_get_validation_error(self, pages_endpoint):
"""Test validation error for invalid page ID."""
with pytest.raises(ValidationError, match="page_id must be a positive integer"):
await pages_endpoint.get(0)
with pytest.raises(ValidationError, match="page_id must be a positive integer"):
await pages_endpoint.get(-1)
@pytest.mark.asyncio
async def test_get_not_found(self, pages_endpoint):
"""Test getting a non-existent page."""
mock_response = {"data": {"pages": {"single": None}}}
pages_endpoint._post = AsyncMock(return_value=mock_response)
with pytest.raises(APIError, match="Page with ID 999 not found"):
await pages_endpoint.get(999)
@pytest.mark.asyncio
async def test_get_by_path(self, pages_endpoint, sample_page_data):
"""Test getting a page by path."""
mock_response = {"data": {"pageByPath": sample_page_data}}
pages_endpoint._post = AsyncMock(return_value=mock_response)
page = await pages_endpoint.get_by_path("test-page")
# Verify request
call_args = pages_endpoint._post.call_args
json_data = call_args[1]["json_data"]
variables = json_data["variables"]
assert variables["path"] == "test-page"
assert variables["locale"] == "en"
# Verify response
assert page.path == "test-page"
@pytest.mark.asyncio
async def test_create(self, pages_endpoint, sample_page_create, sample_page_data):
"""Test creating a new page."""
mock_response = {
"data": {
"pages": {
"create": {
"responseResult": {"succeeded": True},
"page": sample_page_data,
}
}
}
}
pages_endpoint._post = AsyncMock(return_value=mock_response)
page = await pages_endpoint.create(sample_page_create)
# Verify request
call_args = pages_endpoint._post.call_args
json_data = call_args[1]["json_data"]
variables = json_data["variables"]
assert variables["title"] == "New Page"
assert variables["path"] == "new-page"
assert variables["content"] == "# New Page\n\nContent here."
# Verify response
assert isinstance(page, Page)
assert page.id == 123
@pytest.mark.asyncio
async def test_create_failure(self, pages_endpoint, sample_page_create):
"""Test failed page creation."""
mock_response = {
"data": {
"pages": {
"create": {
"responseResult": {"succeeded": False, "message": "Error creating page"},
"page": None,
}
}
}
}
pages_endpoint._post = AsyncMock(return_value=mock_response)
with pytest.raises(APIError, match="Page creation failed"):
await pages_endpoint.create(sample_page_create)
@pytest.mark.asyncio
async def test_update(self, pages_endpoint, sample_page_update, sample_page_data):
"""Test updating an existing page."""
updated_data = sample_page_data.copy()
updated_data["title"] = "Updated Page"
mock_response = {"data": {"updatePage": updated_data}}
pages_endpoint._post = AsyncMock(return_value=mock_response)
page = await pages_endpoint.update(123, sample_page_update)
# Verify request
call_args = pages_endpoint._post.call_args
json_data = call_args[1]["json_data"]
variables = json_data["variables"]
assert variables["id"] == 123
assert variables["title"] == "Updated Page"
# Verify response
assert isinstance(page, Page)
assert page.id == 123
@pytest.mark.asyncio
async def test_delete(self, pages_endpoint):
"""Test deleting a page."""
mock_response = {"data": {"deletePage": {"success": True}}}
pages_endpoint._post = AsyncMock(return_value=mock_response)
result = await pages_endpoint.delete(123)
# Verify request
call_args = pages_endpoint._post.call_args
json_data = call_args[1]["json_data"]
assert json_data["variables"]["id"] == 123
# Verify response
assert result is True
@pytest.mark.asyncio
async def test_delete_failure(self, pages_endpoint):
"""Test failed page deletion."""
mock_response = {
"data": {"deletePage": {"success": False, "message": "Page not found"}}
}
pages_endpoint._post = AsyncMock(return_value=mock_response)
with pytest.raises(APIError, match="Page deletion failed"):
await pages_endpoint.delete(123)
@pytest.mark.asyncio
async def test_search(self, pages_endpoint, sample_page_data):
"""Test searching for pages."""
mock_response = {"data": {"pages": {"list": [sample_page_data]}}}
pages_endpoint._post = AsyncMock(return_value=mock_response)
pages = await pages_endpoint.search("test query", limit=10)
# Verify that search uses list method with search parameter
call_args = pages_endpoint._post.call_args
json_data = call_args[1]["json_data"]
variables = json_data.get("variables", {})
assert variables["search"] == "test query"
assert variables["limit"] == 10
assert len(pages) == 1
@pytest.mark.asyncio
async def test_get_by_tags(self, pages_endpoint, sample_page_data):
"""Test getting pages by tags."""
mock_response = {"data": {"pages": {"list": [sample_page_data]}}}
pages_endpoint._post = AsyncMock(return_value=mock_response)
pages = await pages_endpoint.get_by_tags(["test", "example"], match_all=True)
# Verify request
call_args = pages_endpoint._post.call_args
json_data = call_args[1]["json_data"]
variables = json_data.get("variables", {})
assert variables["tags"] == ["test", "example"]
assert len(pages) == 1
@pytest.mark.asyncio
async def test_graphql_error(self, pages_endpoint):
"""Test handling GraphQL errors."""
mock_response = {"errors": [{"message": "GraphQL Error"}]}
pages_endpoint._post = AsyncMock(return_value=mock_response)
with pytest.raises(APIError, match="GraphQL errors"):
await pages_endpoint.list()
def test_normalize_page_data(self, pages_endpoint, sample_page_data):
"""Test page data normalization."""
normalized = pages_endpoint._normalize_page_data(sample_page_data)
assert normalized["id"] == 123
assert normalized["title"] == "Test Page"
assert normalized["is_published"] is True
assert normalized["is_private"] is False
assert normalized["author_id"] == 1
assert normalized["author_name"] == "Test User"
assert normalized["tags"] == ["test", "example"]
def test_normalize_page_data_with_tag_objects(self, pages_endpoint):
"""Test normalizing page data with tag objects."""
page_data = {
"id": 123,
"title": "Test",
"tags": [{"tag": "test1"}, {"tag": "test2"}],
}
normalized = pages_endpoint._normalize_page_data(page_data)
assert normalized["tags"] == ["test1", "test2"]

View File

@@ -0,0 +1,659 @@
"""Tests for async Users endpoint."""
from unittest.mock import AsyncMock, Mock
import pytest
from wikijs.aio.endpoints import AsyncUsersEndpoint
from wikijs.exceptions import APIError, ValidationError
from wikijs.models import User, UserCreate, UserUpdate
class TestAsyncUsersEndpoint:
"""Test AsyncUsersEndpoint class."""
@pytest.fixture
def client(self):
"""Create mock async client."""
mock_client = Mock()
mock_client.base_url = "https://wiki.example.com"
mock_client._request = AsyncMock()
return mock_client
@pytest.fixture
def endpoint(self, client):
"""Create AsyncUsersEndpoint instance."""
return AsyncUsersEndpoint(client)
@pytest.mark.asyncio
async def test_list_users_minimal(self, endpoint):
"""Test listing users with minimal parameters."""
# Mock response
mock_response = {
"data": {
"users": {
"list": [
{
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"providerKey": "local",
"isSystem": False,
"isActive": True,
"isVerified": True,
"location": None,
"jobTitle": None,
"timezone": None,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"lastLoginAt": "2024-01-15T12:00:00Z",
}
]
}
}
}
endpoint._post = AsyncMock(return_value=mock_response)
# Call method
users = await endpoint.list()
# Verify
assert len(users) == 1
assert isinstance(users[0], User)
assert users[0].id == 1
assert users[0].name == "John Doe"
assert users[0].email == "john@example.com"
# Verify request
endpoint._post.assert_called_once()
@pytest.mark.asyncio
async def test_list_users_with_filters(self, endpoint):
"""Test listing users with filters."""
mock_response = {"data": {"users": {"list": []}}}
endpoint._post = AsyncMock(return_value=mock_response)
# Call with filters
users = await endpoint.list(
limit=10,
offset=5,
search="john",
order_by="email",
order_direction="DESC",
)
# Verify
assert users == []
endpoint._post.assert_called_once()
@pytest.mark.asyncio
async def test_list_users_pagination(self, endpoint):
"""Test client-side pagination."""
mock_response = {
"data": {
"users": {
"list": [
{
"id": i,
"name": f"User {i}",
"email": f"user{i}@example.com",
"providerKey": "local",
"isSystem": False,
"isActive": True,
"isVerified": True,
"location": None,
"jobTitle": None,
"timezone": None,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"lastLoginAt": None,
}
for i in range(1, 11)
]
}
}
}
endpoint._post = AsyncMock(return_value=mock_response)
# Test offset
users = await endpoint.list(offset=5)
assert len(users) == 5
assert users[0].id == 6
# Test limit
endpoint._post.reset_mock()
endpoint._post.return_value = mock_response
users = await endpoint.list(limit=3)
assert len(users) == 3
# Test both
endpoint._post.reset_mock()
endpoint._post.return_value = mock_response
users = await endpoint.list(offset=2, limit=3)
assert len(users) == 3
assert users[0].id == 3
@pytest.mark.asyncio
async def test_list_users_validation_errors(self, endpoint):
"""Test validation errors in list."""
# Invalid limit
with pytest.raises(ValidationError) as exc_info:
await endpoint.list(limit=0)
assert "greater than 0" in str(exc_info.value)
# Invalid offset
with pytest.raises(ValidationError) as exc_info:
await endpoint.list(offset=-1)
assert "non-negative" in str(exc_info.value)
# Invalid order_by
with pytest.raises(ValidationError) as exc_info:
await endpoint.list(order_by="invalid")
assert "must be one of" in str(exc_info.value)
# Invalid order_direction
with pytest.raises(ValidationError) as exc_info:
await endpoint.list(order_direction="INVALID")
assert "must be ASC or DESC" in str(exc_info.value)
@pytest.mark.asyncio
async def test_list_users_api_error(self, endpoint):
"""Test API error handling in list."""
mock_response = {"errors": [{"message": "GraphQL error"}]}
endpoint._post = AsyncMock(return_value=mock_response)
with pytest.raises(APIError) as exc_info:
await endpoint.list()
assert "GraphQL errors" in str(exc_info.value)
@pytest.mark.asyncio
async def test_get_user(self, endpoint):
"""Test getting a single user."""
mock_response = {
"data": {
"users": {
"single": {
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"providerKey": "local",
"isSystem": False,
"isActive": True,
"isVerified": True,
"location": "New York",
"jobTitle": "Developer",
"timezone": "America/New_York",
"groups": [
{"id": 1, "name": "Administrators"},
{"id": 2, "name": "Editors"},
],
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"lastLoginAt": "2024-01-15T12:00:00Z",
}
}
}
}
endpoint._post = AsyncMock(return_value=mock_response)
# Call method
user = await endpoint.get(1)
# Verify
assert isinstance(user, User)
assert user.id == 1
assert user.name == "John Doe"
assert user.email == "john@example.com"
assert user.location == "New York"
assert user.job_title == "Developer"
assert len(user.groups) == 2
assert user.groups[0].name == "Administrators"
@pytest.mark.asyncio
async def test_get_user_not_found(self, endpoint):
"""Test getting non-existent user."""
mock_response = {"data": {"users": {"single": None}}}
endpoint._post = AsyncMock(return_value=mock_response)
with pytest.raises(APIError) as exc_info:
await endpoint.get(999)
assert "not found" in str(exc_info.value)
@pytest.mark.asyncio
async def test_get_user_validation_error(self, endpoint):
"""Test validation error in get."""
with pytest.raises(ValidationError) as exc_info:
await endpoint.get(0)
assert "positive integer" in str(exc_info.value)
with pytest.raises(ValidationError) as exc_info:
await endpoint.get(-1)
assert "positive integer" in str(exc_info.value)
with pytest.raises(ValidationError) as exc_info:
await endpoint.get("not-an-int")
assert "positive integer" in str(exc_info.value)
@pytest.mark.asyncio
async def test_create_user_from_model(self, endpoint):
"""Test creating user from UserCreate model."""
user_data = UserCreate(
email="new@example.com",
name="New User",
password_raw="secret123",
groups=[1, 2],
)
mock_response = {
"data": {
"users": {
"create": {
"responseResult": {
"succeeded": True,
"errorCode": 0,
"slug": "ok",
"message": "User created successfully",
},
"user": {
"id": 2,
"name": "New User",
"email": "new@example.com",
"providerKey": "local",
"isSystem": False,
"isActive": True,
"isVerified": False,
"location": None,
"jobTitle": None,
"timezone": None,
"createdAt": "2024-01-20T00:00:00Z",
"updatedAt": "2024-01-20T00:00:00Z",
},
}
}
}
}
endpoint._post = AsyncMock(return_value=mock_response)
# Call method
user = await endpoint.create(user_data)
# Verify
assert isinstance(user, User)
assert user.id == 2
assert user.name == "New User"
assert user.email == "new@example.com"
# Verify request
endpoint._post.assert_called_once()
call_args = endpoint._post.call_args
assert call_args[1]["json_data"]["variables"]["email"] == "new@example.com"
assert call_args[1]["json_data"]["variables"]["groups"] == [1, 2]
@pytest.mark.asyncio
async def test_create_user_from_dict(self, endpoint):
"""Test creating user from dictionary."""
user_data = {
"email": "new@example.com",
"name": "New User",
"password_raw": "secret123",
}
mock_response = {
"data": {
"users": {
"create": {
"responseResult": {"succeeded": True},
"user": {
"id": 2,
"name": "New User",
"email": "new@example.com",
"providerKey": "local",
"isSystem": False,
"isActive": True,
"isVerified": False,
"location": None,
"jobTitle": None,
"timezone": None,
"createdAt": "2024-01-20T00:00:00Z",
"updatedAt": "2024-01-20T00:00:00Z",
},
}
}
}
}
endpoint._post = AsyncMock(return_value=mock_response)
# Call method
user = await endpoint.create(user_data)
# Verify
assert isinstance(user, User)
assert user.name == "New User"
@pytest.mark.asyncio
async def test_create_user_api_failure(self, endpoint):
"""Test API failure in create."""
user_data = UserCreate(
email="new@example.com", name="New User", password_raw="secret123"
)
mock_response = {
"data": {
"users": {
"create": {
"responseResult": {
"succeeded": False,
"message": "Email already exists",
}
}
}
}
}
endpoint._post = AsyncMock(return_value=mock_response)
with pytest.raises(APIError) as exc_info:
await endpoint.create(user_data)
assert "Email already exists" in str(exc_info.value)
@pytest.mark.asyncio
async def test_create_user_validation_error(self, endpoint):
"""Test validation error in create."""
# Invalid user data
with pytest.raises(ValidationError):
await endpoint.create({"email": "invalid"})
# Wrong type
with pytest.raises(ValidationError):
await endpoint.create("not-a-dict-or-model")
@pytest.mark.asyncio
async def test_update_user_from_model(self, endpoint):
"""Test updating user from UserUpdate model."""
user_data = UserUpdate(name="Updated Name", location="San Francisco")
mock_response = {
"data": {
"users": {
"update": {
"responseResult": {"succeeded": True},
"user": {
"id": 1,
"name": "Updated Name",
"email": "john@example.com",
"providerKey": "local",
"isSystem": False,
"isActive": True,
"isVerified": True,
"location": "San Francisco",
"jobTitle": None,
"timezone": None,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-20T00:00:00Z",
},
}
}
}
}
endpoint._post = AsyncMock(return_value=mock_response)
# Call method
user = await endpoint.update(1, user_data)
# Verify
assert isinstance(user, User)
assert user.name == "Updated Name"
assert user.location == "San Francisco"
# Verify only non-None fields were sent
call_args = endpoint._post.call_args
variables = call_args[1]["json_data"]["variables"]
assert "name" in variables
assert "location" in variables
assert "email" not in variables # Not updated
@pytest.mark.asyncio
async def test_update_user_from_dict(self, endpoint):
"""Test updating user from dictionary."""
user_data = {"name": "Updated Name"}
mock_response = {
"data": {
"users": {
"update": {
"responseResult": {"succeeded": True},
"user": {
"id": 1,
"name": "Updated Name",
"email": "john@example.com",
"providerKey": "local",
"isSystem": False,
"isActive": True,
"isVerified": True,
"location": None,
"jobTitle": None,
"timezone": None,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-20T00:00:00Z",
},
}
}
}
}
endpoint._post = AsyncMock(return_value=mock_response)
# Call method
user = await endpoint.update(1, user_data)
# Verify
assert user.name == "Updated Name"
@pytest.mark.asyncio
async def test_update_user_api_failure(self, endpoint):
"""Test API failure in update."""
user_data = UserUpdate(name="Updated Name")
mock_response = {
"data": {
"users": {
"update": {
"responseResult": {
"succeeded": False,
"message": "User not found",
}
}
}
}
}
endpoint._post = AsyncMock(return_value=mock_response)
with pytest.raises(APIError) as exc_info:
await endpoint.update(999, user_data)
assert "User not found" in str(exc_info.value)
@pytest.mark.asyncio
async def test_update_user_validation_error(self, endpoint):
"""Test validation error in update."""
# Invalid user ID
with pytest.raises(ValidationError):
await endpoint.update(0, UserUpdate(name="Test"))
# Invalid user data
with pytest.raises(ValidationError):
await endpoint.update(1, {"name": ""}) # Empty name
# Wrong type
with pytest.raises(ValidationError):
await endpoint.update(1, "not-a-dict-or-model")
@pytest.mark.asyncio
async def test_delete_user(self, endpoint):
"""Test deleting a user."""
mock_response = {
"data": {
"users": {
"delete": {
"responseResult": {
"succeeded": True,
"message": "User deleted successfully",
}
}
}
}
}
endpoint._post = AsyncMock(return_value=mock_response)
# Call method
result = await endpoint.delete(1)
# Verify
assert result is True
endpoint._post.assert_called_once()
@pytest.mark.asyncio
async def test_delete_user_api_failure(self, endpoint):
"""Test API failure in delete."""
mock_response = {
"data": {
"users": {
"delete": {
"responseResult": {
"succeeded": False,
"message": "Cannot delete system user",
}
}
}
}
}
endpoint._post = AsyncMock(return_value=mock_response)
with pytest.raises(APIError) as exc_info:
await endpoint.delete(1)
assert "Cannot delete system user" in str(exc_info.value)
@pytest.mark.asyncio
async def test_delete_user_validation_error(self, endpoint):
"""Test validation error in delete."""
with pytest.raises(ValidationError):
await endpoint.delete(0)
with pytest.raises(ValidationError):
await endpoint.delete(-1)
with pytest.raises(ValidationError):
await endpoint.delete("not-an-int")
@pytest.mark.asyncio
async def test_search_users(self, endpoint):
"""Test searching users."""
mock_response = {
"data": {
"users": {
"list": [
{
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"providerKey": "local",
"isSystem": False,
"isActive": True,
"isVerified": True,
"location": None,
"jobTitle": None,
"timezone": None,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"lastLoginAt": None,
}
]
}
}
}
endpoint._post = AsyncMock(return_value=mock_response)
# Call method
users = await endpoint.search("john")
# Verify
assert len(users) == 1
assert users[0].name == "John Doe"
@pytest.mark.asyncio
async def test_search_users_with_limit(self, endpoint):
"""Test searching users with limit."""
mock_response = {"data": {"users": {"list": []}}}
endpoint._post = AsyncMock(return_value=mock_response)
users = await endpoint.search("test", limit=5)
assert users == []
@pytest.mark.asyncio
async def test_search_users_validation_error(self, endpoint):
"""Test validation error in search."""
# Empty query
with pytest.raises(ValidationError):
await endpoint.search("")
# Non-string query
with pytest.raises(ValidationError):
await endpoint.search(123)
# Invalid limit
with pytest.raises(ValidationError):
await endpoint.search("test", limit=0)
@pytest.mark.asyncio
async def test_normalize_user_data(self, endpoint):
"""Test user data normalization."""
api_data = {
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"providerKey": "local",
"isSystem": False,
"isActive": True,
"isVerified": True,
"location": "New York",
"jobTitle": "Developer",
"timezone": "America/New_York",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"lastLoginAt": "2024-01-15T12:00:00Z",
"groups": [{"id": 1, "name": "Administrators"}],
}
normalized = endpoint._normalize_user_data(api_data)
# Verify snake_case conversion
assert normalized["id"] == 1
assert normalized["name"] == "John Doe"
assert normalized["email"] == "john@example.com"
assert normalized["provider_key"] == "local"
assert normalized["is_system"] is False
assert normalized["is_active"] is True
assert normalized["is_verified"] is True
assert normalized["job_title"] == "Developer"
assert normalized["last_login_at"] == "2024-01-15T12:00:00Z"
assert len(normalized["groups"]) == 1
assert normalized["groups"][0]["name"] == "Administrators"
@pytest.mark.asyncio
async def test_normalize_user_data_no_groups(self, endpoint):
"""Test normalization with no groups."""
api_data = {
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"providerKey": "local",
"isSystem": False,
"isActive": True,
"isVerified": True,
"location": None,
"jobTitle": None,
"timezone": None,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"lastLoginAt": None,
}
normalized = endpoint._normalize_user_data(api_data)
assert normalized["groups"] == []

View File

@@ -0,0 +1,163 @@
"""Tests for Assets endpoint."""
from unittest.mock import Mock
import pytest
from wikijs.endpoints import AssetsEndpoint
from wikijs.exceptions import APIError, ValidationError
from wikijs.models import Asset, AssetFolder
class TestAssetsEndpoint:
"""Test AssetsEndpoint class."""
@pytest.fixture
def client(self):
"""Create mock client."""
mock_client = Mock()
mock_client.base_url = "https://wiki.example.com"
return mock_client
@pytest.fixture
def endpoint(self, client):
"""Create AssetsEndpoint instance."""
return AssetsEndpoint(client)
def test_list_assets(self, endpoint):
"""Test listing assets."""
mock_response = {
"data": {
"assets": {
"list": [
{
"id": 1,
"filename": "test.png",
"ext": "png",
"kind": "image",
"mime": "image/png",
"fileSize": 1024,
"folderId": 0,
"folder": None,
"authorId": 1,
"authorName": "Admin",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
}
]
}
}
}
endpoint._post = Mock(return_value=mock_response)
assets = endpoint.list()
assert len(assets) == 1
assert isinstance(assets[0], Asset)
assert assets[0].filename == "test.png"
def test_get_asset(self, endpoint):
"""Test getting an asset."""
mock_response = {
"data": {
"assets": {
"single": {
"id": 1,
"filename": "test.png",
"ext": "png",
"kind": "image",
"mime": "image/png",
"fileSize": 1024,
"folderId": 0,
"folder": None,
"authorId": 1,
"authorName": "Admin",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
}
}
}
}
endpoint._post = Mock(return_value=mock_response)
asset = endpoint.get(1)
assert isinstance(asset, Asset)
assert asset.id == 1
def test_rename_asset(self, endpoint):
"""Test renaming an asset."""
mock_response = {
"data": {
"assets": {
"renameAsset": {
"responseResult": {"succeeded": True},
"asset": {
"id": 1,
"filename": "newname.png",
"ext": "png",
"kind": "image",
"mime": "image/png",
"fileSize": 1024,
"folderId": 0,
"authorId": 1,
"authorName": "Admin",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
},
}
}
}
}
endpoint._post = Mock(return_value=mock_response)
asset = endpoint.rename(1, "newname.png")
assert asset.filename == "newname.png"
def test_delete_asset(self, endpoint):
"""Test deleting an asset."""
mock_response = {
"data": {
"assets": {
"deleteAsset": {
"responseResult": {"succeeded": True}
}
}
}
}
endpoint._post = Mock(return_value=mock_response)
result = endpoint.delete(1)
assert result is True
def test_list_folders(self, endpoint):
"""Test listing folders."""
mock_response = {
"data": {
"assets": {
"folders": [
{"id": 1, "slug": "documents", "name": "Documents"}
]
}
}
}
endpoint._post = Mock(return_value=mock_response)
folders = endpoint.list_folders()
assert len(folders) == 1
assert isinstance(folders[0], AssetFolder)
assert folders[0].slug == "documents"
def test_validation_errors(self, endpoint):
"""Test validation errors."""
with pytest.raises(ValidationError):
endpoint.get(0)
with pytest.raises(ValidationError):
endpoint.delete(-1)
with pytest.raises(ValidationError):
endpoint.rename(1, "")

View File

@@ -0,0 +1,203 @@
"""Tests for Groups endpoint."""
from unittest.mock import Mock
import pytest
from wikijs.endpoints import GroupsEndpoint
from wikijs.exceptions import APIError, ValidationError
from wikijs.models import Group, GroupCreate, GroupUpdate
class TestGroupsEndpoint:
"""Test GroupsEndpoint class."""
@pytest.fixture
def client(self):
"""Create mock client."""
mock_client = Mock()
mock_client.base_url = "https://wiki.example.com"
mock_client._request = Mock()
return mock_client
@pytest.fixture
def endpoint(self, client):
"""Create GroupsEndpoint instance."""
return GroupsEndpoint(client)
def test_list_groups(self, endpoint):
"""Test listing groups."""
mock_response = {
"data": {
"groups": {
"list": [
{
"id": 1,
"name": "Administrators",
"isSystem": False,
"redirectOnLogin": "/",
"permissions": ["manage:system"],
"pageRules": [],
"users": [],
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
}
]
}
}
}
endpoint._post = Mock(return_value=mock_response)
groups = endpoint.list()
assert len(groups) == 1
assert isinstance(groups[0], Group)
assert groups[0].name == "Administrators"
def test_get_group(self, endpoint):
"""Test getting a group."""
mock_response = {
"data": {
"groups": {
"single": {
"id": 1,
"name": "Administrators",
"isSystem": False,
"redirectOnLogin": "/",
"permissions": ["manage:system"],
"pageRules": [],
"users": [{"id": 1, "name": "Admin", "email": "admin@example.com"}],
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
}
}
}
}
endpoint._post = Mock(return_value=mock_response)
group = endpoint.get(1)
assert isinstance(group, Group)
assert group.id == 1
assert len(group.users) == 1
def test_create_group(self, endpoint):
"""Test creating a group."""
group_data = GroupCreate(name="Editors", permissions=["read:pages"])
mock_response = {
"data": {
"groups": {
"create": {
"responseResult": {"succeeded": True},
"group": {
"id": 2,
"name": "Editors",
"isSystem": False,
"redirectOnLogin": "/",
"permissions": ["read:pages"],
"pageRules": [],
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
},
}
}
}
}
endpoint._post = Mock(return_value=mock_response)
group = endpoint.create(group_data)
assert isinstance(group, Group)
assert group.name == "Editors"
def test_update_group(self, endpoint):
"""Test updating a group."""
update_data = GroupUpdate(name="Senior Editors")
mock_response = {
"data": {
"groups": {
"update": {
"responseResult": {"succeeded": True},
"group": {
"id": 1,
"name": "Senior Editors",
"isSystem": False,
"redirectOnLogin": "/",
"permissions": [],
"pageRules": [],
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-02T00:00:00Z",
},
}
}
}
}
endpoint._post = Mock(return_value=mock_response)
group = endpoint.update(1, update_data)
assert group.name == "Senior Editors"
def test_delete_group(self, endpoint):
"""Test deleting a group."""
mock_response = {
"data": {
"groups": {
"delete": {
"responseResult": {"succeeded": True}
}
}
}
}
endpoint._post = Mock(return_value=mock_response)
result = endpoint.delete(1)
assert result is True
def test_assign_user(self, endpoint):
"""Test assigning a user to a group."""
mock_response = {
"data": {
"groups": {
"assignUser": {
"responseResult": {"succeeded": True}
}
}
}
}
endpoint._post = Mock(return_value=mock_response)
result = endpoint.assign_user(group_id=1, user_id=5)
assert result is True
def test_unassign_user(self, endpoint):
"""Test removing a user from a group."""
mock_response = {
"data": {
"groups": {
"unassignUser": {
"responseResult": {"succeeded": True}
}
}
}
}
endpoint._post = Mock(return_value=mock_response)
result = endpoint.unassign_user(group_id=1, user_id=5)
assert result is True
def test_validation_errors(self, endpoint):
"""Test validation errors."""
with pytest.raises(ValidationError):
endpoint.get(0)
with pytest.raises(ValidationError):
endpoint.delete(-1)
with pytest.raises(ValidationError):
endpoint.assign_user(0, 1)

View File

@@ -0,0 +1,640 @@
"""Tests for Users endpoint."""
from unittest.mock import Mock, patch
import pytest
from wikijs.endpoints import UsersEndpoint
from wikijs.exceptions import APIError, ValidationError
from wikijs.models import User, UserCreate, UserUpdate
class TestUsersEndpoint:
"""Test UsersEndpoint class."""
@pytest.fixture
def client(self):
"""Create mock client."""
mock_client = Mock()
mock_client.base_url = "https://wiki.example.com"
mock_client._request = Mock()
return mock_client
@pytest.fixture
def endpoint(self, client):
"""Create UsersEndpoint instance."""
return UsersEndpoint(client)
def test_list_users_minimal(self, endpoint):
"""Test listing users with minimal parameters."""
# Mock response
mock_response = {
"data": {
"users": {
"list": [
{
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"providerKey": "local",
"isSystem": False,
"isActive": True,
"isVerified": True,
"location": None,
"jobTitle": None,
"timezone": None,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"lastLoginAt": "2024-01-15T12:00:00Z",
}
]
}
}
}
endpoint._post = Mock(return_value=mock_response)
# Call method
users = endpoint.list()
# Verify
assert len(users) == 1
assert isinstance(users[0], User)
assert users[0].id == 1
assert users[0].name == "John Doe"
assert users[0].email == "john@example.com"
# Verify request
endpoint._post.assert_called_once()
call_args = endpoint._post.call_args
assert "/graphql" in str(call_args)
def test_list_users_with_filters(self, endpoint):
"""Test listing users with filters."""
mock_response = {"data": {"users": {"list": []}}}
endpoint._post = Mock(return_value=mock_response)
# Call with filters
users = endpoint.list(
limit=10,
offset=5,
search="john",
order_by="email",
order_direction="DESC",
)
# Verify
assert users == []
endpoint._post.assert_called_once()
def test_list_users_pagination(self, endpoint):
"""Test client-side pagination."""
mock_response = {
"data": {
"users": {
"list": [
{
"id": i,
"name": f"User {i}",
"email": f"user{i}@example.com",
"providerKey": "local",
"isSystem": False,
"isActive": True,
"isVerified": True,
"location": None,
"jobTitle": None,
"timezone": None,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"lastLoginAt": None,
}
for i in range(1, 11)
]
}
}
}
endpoint._post = Mock(return_value=mock_response)
# Test offset
users = endpoint.list(offset=5)
assert len(users) == 5
assert users[0].id == 6
# Test limit
endpoint._post.reset_mock()
endpoint._post.return_value = mock_response
users = endpoint.list(limit=3)
assert len(users) == 3
# Test both
endpoint._post.reset_mock()
endpoint._post.return_value = mock_response
users = endpoint.list(offset=2, limit=3)
assert len(users) == 3
assert users[0].id == 3
def test_list_users_validation_errors(self, endpoint):
"""Test validation errors in list."""
# Invalid limit
with pytest.raises(ValidationError) as exc_info:
endpoint.list(limit=0)
assert "greater than 0" in str(exc_info.value)
# Invalid offset
with pytest.raises(ValidationError) as exc_info:
endpoint.list(offset=-1)
assert "non-negative" in str(exc_info.value)
# Invalid order_by
with pytest.raises(ValidationError) as exc_info:
endpoint.list(order_by="invalid")
assert "must be one of" in str(exc_info.value)
# Invalid order_direction
with pytest.raises(ValidationError) as exc_info:
endpoint.list(order_direction="INVALID")
assert "must be ASC or DESC" in str(exc_info.value)
def test_list_users_api_error(self, endpoint):
"""Test API error handling in list."""
mock_response = {"errors": [{"message": "GraphQL error"}]}
endpoint._post = Mock(return_value=mock_response)
with pytest.raises(APIError) as exc_info:
endpoint.list()
assert "GraphQL errors" in str(exc_info.value)
def test_get_user(self, endpoint):
"""Test getting a single user."""
mock_response = {
"data": {
"users": {
"single": {
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"providerKey": "local",
"isSystem": False,
"isActive": True,
"isVerified": True,
"location": "New York",
"jobTitle": "Developer",
"timezone": "America/New_York",
"groups": [
{"id": 1, "name": "Administrators"},
{"id": 2, "name": "Editors"},
],
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"lastLoginAt": "2024-01-15T12:00:00Z",
}
}
}
}
endpoint._post = Mock(return_value=mock_response)
# Call method
user = endpoint.get(1)
# Verify
assert isinstance(user, User)
assert user.id == 1
assert user.name == "John Doe"
assert user.email == "john@example.com"
assert user.location == "New York"
assert user.job_title == "Developer"
assert len(user.groups) == 2
assert user.groups[0].name == "Administrators"
# Verify request
endpoint._post.assert_called_once()
def test_get_user_not_found(self, endpoint):
"""Test getting non-existent user."""
mock_response = {"data": {"users": {"single": None}}}
endpoint._post = Mock(return_value=mock_response)
with pytest.raises(APIError) as exc_info:
endpoint.get(999)
assert "not found" in str(exc_info.value)
def test_get_user_validation_error(self, endpoint):
"""Test validation error in get."""
with pytest.raises(ValidationError) as exc_info:
endpoint.get(0)
assert "positive integer" in str(exc_info.value)
with pytest.raises(ValidationError) as exc_info:
endpoint.get(-1)
assert "positive integer" in str(exc_info.value)
with pytest.raises(ValidationError) as exc_info:
endpoint.get("not-an-int")
assert "positive integer" in str(exc_info.value)
def test_create_user_from_model(self, endpoint):
"""Test creating user from UserCreate model."""
user_data = UserCreate(
email="new@example.com",
name="New User",
password_raw="secret123",
groups=[1, 2],
)
mock_response = {
"data": {
"users": {
"create": {
"responseResult": {
"succeeded": True,
"errorCode": 0,
"slug": "ok",
"message": "User created successfully",
},
"user": {
"id": 2,
"name": "New User",
"email": "new@example.com",
"providerKey": "local",
"isSystem": False,
"isActive": True,
"isVerified": False,
"location": None,
"jobTitle": None,
"timezone": None,
"createdAt": "2024-01-20T00:00:00Z",
"updatedAt": "2024-01-20T00:00:00Z",
},
}
}
}
}
endpoint._post = Mock(return_value=mock_response)
# Call method
user = endpoint.create(user_data)
# Verify
assert isinstance(user, User)
assert user.id == 2
assert user.name == "New User"
assert user.email == "new@example.com"
# Verify request
endpoint._post.assert_called_once()
call_args = endpoint._post.call_args
assert call_args[1]["json_data"]["variables"]["email"] == "new@example.com"
assert call_args[1]["json_data"]["variables"]["groups"] == [1, 2]
def test_create_user_from_dict(self, endpoint):
"""Test creating user from dictionary."""
user_data = {
"email": "new@example.com",
"name": "New User",
"password_raw": "secret123",
}
mock_response = {
"data": {
"users": {
"create": {
"responseResult": {"succeeded": True},
"user": {
"id": 2,
"name": "New User",
"email": "new@example.com",
"providerKey": "local",
"isSystem": False,
"isActive": True,
"isVerified": False,
"location": None,
"jobTitle": None,
"timezone": None,
"createdAt": "2024-01-20T00:00:00Z",
"updatedAt": "2024-01-20T00:00:00Z",
},
}
}
}
}
endpoint._post = Mock(return_value=mock_response)
# Call method
user = endpoint.create(user_data)
# Verify
assert isinstance(user, User)
assert user.name == "New User"
def test_create_user_api_failure(self, endpoint):
"""Test API failure in create."""
user_data = UserCreate(
email="new@example.com", name="New User", password_raw="secret123"
)
mock_response = {
"data": {
"users": {
"create": {
"responseResult": {
"succeeded": False,
"message": "Email already exists",
}
}
}
}
}
endpoint._post = Mock(return_value=mock_response)
with pytest.raises(APIError) as exc_info:
endpoint.create(user_data)
assert "Email already exists" in str(exc_info.value)
def test_create_user_validation_error(self, endpoint):
"""Test validation error in create."""
# Invalid user data
with pytest.raises(ValidationError):
endpoint.create({"email": "invalid"})
# Wrong type
with pytest.raises(ValidationError):
endpoint.create("not-a-dict-or-model")
def test_update_user_from_model(self, endpoint):
"""Test updating user from UserUpdate model."""
user_data = UserUpdate(name="Updated Name", location="San Francisco")
mock_response = {
"data": {
"users": {
"update": {
"responseResult": {"succeeded": True},
"user": {
"id": 1,
"name": "Updated Name",
"email": "john@example.com",
"providerKey": "local",
"isSystem": False,
"isActive": True,
"isVerified": True,
"location": "San Francisco",
"jobTitle": None,
"timezone": None,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-20T00:00:00Z",
},
}
}
}
}
endpoint._post = Mock(return_value=mock_response)
# Call method
user = endpoint.update(1, user_data)
# Verify
assert isinstance(user, User)
assert user.name == "Updated Name"
assert user.location == "San Francisco"
# Verify only non-None fields were sent
call_args = endpoint._post.call_args
variables = call_args[1]["json_data"]["variables"]
assert "name" in variables
assert "location" in variables
assert "email" not in variables # Not updated
def test_update_user_from_dict(self, endpoint):
"""Test updating user from dictionary."""
user_data = {"name": "Updated Name"}
mock_response = {
"data": {
"users": {
"update": {
"responseResult": {"succeeded": True},
"user": {
"id": 1,
"name": "Updated Name",
"email": "john@example.com",
"providerKey": "local",
"isSystem": False,
"isActive": True,
"isVerified": True,
"location": None,
"jobTitle": None,
"timezone": None,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-20T00:00:00Z",
},
}
}
}
}
endpoint._post = Mock(return_value=mock_response)
# Call method
user = endpoint.update(1, user_data)
# Verify
assert user.name == "Updated Name"
def test_update_user_api_failure(self, endpoint):
"""Test API failure in update."""
user_data = UserUpdate(name="Updated Name")
mock_response = {
"data": {
"users": {
"update": {
"responseResult": {
"succeeded": False,
"message": "User not found",
}
}
}
}
}
endpoint._post = Mock(return_value=mock_response)
with pytest.raises(APIError) as exc_info:
endpoint.update(999, user_data)
assert "User not found" in str(exc_info.value)
def test_update_user_validation_error(self, endpoint):
"""Test validation error in update."""
# Invalid user ID
with pytest.raises(ValidationError):
endpoint.update(0, UserUpdate(name="Test"))
# Invalid user data
with pytest.raises(ValidationError):
endpoint.update(1, {"name": ""}) # Empty name
# Wrong type
with pytest.raises(ValidationError):
endpoint.update(1, "not-a-dict-or-model")
def test_delete_user(self, endpoint):
"""Test deleting a user."""
mock_response = {
"data": {
"users": {
"delete": {
"responseResult": {
"succeeded": True,
"message": "User deleted successfully",
}
}
}
}
}
endpoint._post = Mock(return_value=mock_response)
# Call method
result = endpoint.delete(1)
# Verify
assert result is True
endpoint._post.assert_called_once()
def test_delete_user_api_failure(self, endpoint):
"""Test API failure in delete."""
mock_response = {
"data": {
"users": {
"delete": {
"responseResult": {
"succeeded": False,
"message": "Cannot delete system user",
}
}
}
}
}
endpoint._post = Mock(return_value=mock_response)
with pytest.raises(APIError) as exc_info:
endpoint.delete(1)
assert "Cannot delete system user" in str(exc_info.value)
def test_delete_user_validation_error(self, endpoint):
"""Test validation error in delete."""
with pytest.raises(ValidationError):
endpoint.delete(0)
with pytest.raises(ValidationError):
endpoint.delete(-1)
with pytest.raises(ValidationError):
endpoint.delete("not-an-int")
def test_search_users(self, endpoint):
"""Test searching users."""
mock_response = {
"data": {
"users": {
"list": [
{
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"providerKey": "local",
"isSystem": False,
"isActive": True,
"isVerified": True,
"location": None,
"jobTitle": None,
"timezone": None,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"lastLoginAt": None,
}
]
}
}
}
endpoint._post = Mock(return_value=mock_response)
# Call method
users = endpoint.search("john")
# Verify
assert len(users) == 1
assert users[0].name == "John Doe"
def test_search_users_with_limit(self, endpoint):
"""Test searching users with limit."""
mock_response = {"data": {"users": {"list": []}}}
endpoint._post = Mock(return_value=mock_response)
users = endpoint.search("test", limit=5)
assert users == []
def test_search_users_validation_error(self, endpoint):
"""Test validation error in search."""
# Empty query
with pytest.raises(ValidationError):
endpoint.search("")
# Non-string query
with pytest.raises(ValidationError):
endpoint.search(123)
# Invalid limit
with pytest.raises(ValidationError):
endpoint.search("test", limit=0)
def test_normalize_user_data(self, endpoint):
"""Test user data normalization."""
api_data = {
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"providerKey": "local",
"isSystem": False,
"isActive": True,
"isVerified": True,
"location": "New York",
"jobTitle": "Developer",
"timezone": "America/New_York",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"lastLoginAt": "2024-01-15T12:00:00Z",
"groups": [{"id": 1, "name": "Administrators"}],
}
normalized = endpoint._normalize_user_data(api_data)
# Verify snake_case conversion
assert normalized["id"] == 1
assert normalized["name"] == "John Doe"
assert normalized["email"] == "john@example.com"
assert normalized["provider_key"] == "local"
assert normalized["is_system"] is False
assert normalized["is_active"] is True
assert normalized["is_verified"] is True
assert normalized["job_title"] == "Developer"
assert normalized["last_login_at"] == "2024-01-15T12:00:00Z"
assert len(normalized["groups"]) == 1
assert normalized["groups"][0]["name"] == "Administrators"
def test_normalize_user_data_no_groups(self, endpoint):
"""Test normalization with no groups."""
api_data = {
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"providerKey": "local",
"isSystem": False,
"isActive": True,
"isVerified": True,
"location": None,
"jobTitle": None,
"timezone": None,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"lastLoginAt": None,
}
normalized = endpoint._normalize_user_data(api_data)
assert normalized["groups"] == []

View File

@@ -0,0 +1,96 @@
"""Tests for Asset data models."""
import pytest
from pydantic import ValidationError
from wikijs.models import Asset, AssetFolder, AssetRename, AssetMove, FolderCreate
class TestAsset:
"""Test Asset model."""
def test_asset_creation_minimal(self):
"""Test creating an asset with minimal fields."""
asset = Asset(
id=1,
filename="test.png",
ext="png",
kind="image",
mime="image/png",
file_size=1024,
created_at="2024-01-01T00:00:00Z",
updated_at="2024-01-01T00:00:00Z",
)
assert asset.id == 1
assert asset.filename == "test.png"
assert asset.file_size == 1024
def test_asset_size_helpers(self):
"""Test size helper methods."""
asset = Asset(
id=1,
filename="test.png",
ext="png",
kind="image",
mime="image/png",
file_size=1048576, # 1 MB
created_at="2024-01-01T00:00:00Z",
updated_at="2024-01-01T00:00:00Z",
)
assert asset.size_mb == 1.0
assert asset.size_kb == 1024.0
def test_asset_filename_validation(self):
"""Test filename validation."""
with pytest.raises(ValidationError):
Asset(
id=1,
filename="",
ext="png",
kind="image",
mime="image/png",
file_size=1024,
created_at="2024-01-01T00:00:00Z",
updated_at="2024-01-01T00:00:00Z",
)
class TestAssetRename:
"""Test AssetRename model."""
def test_asset_rename_valid(self):
"""Test valid asset rename."""
rename = AssetRename(asset_id=1, new_filename="newname.png")
assert rename.asset_id == 1
assert rename.new_filename == "newname.png"
def test_asset_rename_validation(self):
"""Test validation."""
with pytest.raises(ValidationError):
AssetRename(asset_id=0, new_filename="test.png")
with pytest.raises(ValidationError):
AssetRename(asset_id=1, new_filename="")
class TestFolderCreate:
"""Test FolderCreate model."""
def test_folder_create_valid(self):
"""Test valid folder creation."""
folder = FolderCreate(slug="documents", name="Documents")
assert folder.slug == "documents"
assert folder.name == "Documents"
def test_folder_create_slug_validation(self):
"""Test slug validation."""
with pytest.raises(ValidationError):
FolderCreate(slug="")
with pytest.raises(ValidationError):
FolderCreate(slug="///")
def test_folder_create_slug_normalization(self):
"""Test slug normalization."""
folder = FolderCreate(slug="/documents/", name="Documents")
assert folder.slug == "documents"

109
tests/models/test_group.py Normal file
View File

@@ -0,0 +1,109 @@
"""Tests for Group data models."""
import pytest
from pydantic import ValidationError
from wikijs.models import Group, GroupCreate, GroupUpdate
class TestGroup:
"""Test Group model."""
def test_group_creation_minimal(self):
"""Test creating a group with minimal fields."""
group = Group(
id=1,
name="Administrators",
created_at="2024-01-01T00:00:00Z",
updated_at="2024-01-01T00:00:00Z",
)
assert group.id == 1
assert group.name == "Administrators"
assert group.is_system is False
assert group.permissions == []
assert group.page_rules == []
assert group.users == []
def test_group_creation_full(self):
"""Test creating a group with all fields."""
group = Group(
id=1,
name="Editors",
is_system=False,
redirect_on_login="/dashboard",
permissions=["read:pages", "write:pages"],
page_rules=[
{"id": "1", "path": "/docs/*", "roles": ["write"], "match": "START"}
],
users=[{"id": 1, "name": "John Doe", "email": "john@example.com"}],
created_at="2024-01-01T00:00:00Z",
updated_at="2024-01-01T00:00:00Z",
)
assert group.name == "Editors"
assert group.redirect_on_login == "/dashboard"
assert len(group.permissions) == 2
assert len(group.page_rules) == 1
assert len(group.users) == 1
def test_group_name_validation(self):
"""Test name validation."""
# Too short
with pytest.raises(ValidationError):
Group(
id=1,
name="",
created_at="2024-01-01T00:00:00Z",
updated_at="2024-01-01T00:00:00Z",
)
# Too long
with pytest.raises(ValidationError):
Group(
id=1,
name="x" * 256,
created_at="2024-01-01T00:00:00Z",
updated_at="2024-01-01T00:00:00Z",
)
class TestGroupCreate:
"""Test GroupCreate model."""
def test_group_create_minimal(self):
"""Test creating group with minimal fields."""
group_data = GroupCreate(name="Test Group")
assert group_data.name == "Test Group"
assert group_data.permissions == []
assert group_data.page_rules == []
def test_group_create_full(self):
"""Test creating group with all fields."""
group_data = GroupCreate(
name="Test Group",
redirect_on_login="/home",
permissions=["read:pages"],
page_rules=[{"path": "/*", "roles": ["read"]}],
)
assert group_data.redirect_on_login == "/home"
assert len(group_data.permissions) == 1
def test_group_create_name_validation(self):
"""Test name validation."""
with pytest.raises(ValidationError):
GroupCreate(name="")
class TestGroupUpdate:
"""Test GroupUpdate model."""
def test_group_update_empty(self):
"""Test empty update."""
update_data = GroupUpdate()
assert update_data.name is None
assert update_data.permissions is None
def test_group_update_partial(self):
"""Test partial update."""
update_data = GroupUpdate(name="Updated Name")
assert update_data.name == "Updated Name"
assert update_data.permissions is None

403
tests/models/test_user.py Normal file
View File

@@ -0,0 +1,403 @@
"""Tests for User data models."""
import pytest
from pydantic import ValidationError
from wikijs.models import User, UserCreate, UserGroup, UserUpdate
class TestUserGroup:
"""Test UserGroup model."""
def test_user_group_creation(self):
"""Test creating a valid user group."""
group = UserGroup(id=1, name="Administrators")
assert group.id == 1
assert group.name == "Administrators"
def test_user_group_required_fields(self):
"""Test that required fields are enforced."""
with pytest.raises(ValidationError) as exc_info:
UserGroup(id=1)
assert "name" in str(exc_info.value)
with pytest.raises(ValidationError) as exc_info:
UserGroup(name="Administrators")
assert "id" in str(exc_info.value)
class TestUser:
"""Test User model."""
def test_user_creation_minimal(self):
"""Test creating a user with minimal required fields."""
user = User(
id=1,
name="John Doe",
email="john@example.com",
created_at="2024-01-01T00:00:00Z",
updated_at="2024-01-01T00:00:00Z",
)
assert user.id == 1
assert user.name == "John Doe"
assert user.email == "john@example.com"
assert user.is_active is True
assert user.is_system is False
assert user.is_verified is False
assert user.groups == []
def test_user_creation_full(self):
"""Test creating a user with all fields."""
groups = [
UserGroup(id=1, name="Administrators"),
UserGroup(id=2, name="Editors"),
]
user = User(
id=1,
name="John Doe",
email="john@example.com",
provider_key="local",
is_system=False,
is_active=True,
is_verified=True,
location="New York",
job_title="Senior Developer",
timezone="America/New_York",
groups=groups,
created_at="2024-01-01T00:00:00Z",
updated_at="2024-01-01T00:00:00Z",
last_login_at="2024-01-15T12:00:00Z",
)
assert user.id == 1
assert user.name == "John Doe"
assert user.email == "john@example.com"
assert user.provider_key == "local"
assert user.is_system is False
assert user.is_active is True
assert user.is_verified is True
assert user.location == "New York"
assert user.job_title == "Senior Developer"
assert user.timezone == "America/New_York"
assert len(user.groups) == 2
assert user.groups[0].name == "Administrators"
assert user.last_login_at == "2024-01-15T12:00:00Z"
def test_user_camel_case_alias(self):
"""Test that camelCase aliases work."""
user = User(
id=1,
name="John Doe",
email="john@example.com",
providerKey="local",
isSystem=False,
isActive=True,
isVerified=True,
jobTitle="Developer",
createdAt="2024-01-01T00:00:00Z",
updatedAt="2024-01-01T00:00:00Z",
lastLoginAt="2024-01-15T12:00:00Z",
)
assert user.provider_key == "local"
assert user.is_system is False
assert user.is_active is True
assert user.is_verified is True
assert user.job_title == "Developer"
assert user.last_login_at == "2024-01-15T12:00:00Z"
def test_user_required_fields(self):
"""Test that required fields are enforced."""
with pytest.raises(ValidationError) as exc_info:
User(name="John Doe", email="john@example.com")
assert "id" in str(exc_info.value)
with pytest.raises(ValidationError) as exc_info:
User(id=1, email="john@example.com")
assert "name" in str(exc_info.value)
with pytest.raises(ValidationError) as exc_info:
User(id=1, name="John Doe")
assert "email" in str(exc_info.value)
def test_user_email_validation(self):
"""Test email validation."""
# Valid email
user = User(
id=1,
name="John Doe",
email="john@example.com",
created_at="2024-01-01T00:00:00Z",
updated_at="2024-01-01T00:00:00Z",
)
assert user.email == "john@example.com"
# Invalid email
with pytest.raises(ValidationError) as exc_info:
User(
id=1,
name="John Doe",
email="not-an-email",
created_at="2024-01-01T00:00:00Z",
updated_at="2024-01-01T00:00:00Z",
)
assert "email" in str(exc_info.value).lower()
def test_user_name_validation(self):
"""Test name validation."""
# Too short
with pytest.raises(ValidationError) as exc_info:
User(
id=1,
name="J",
email="john@example.com",
created_at="2024-01-01T00:00:00Z",
updated_at="2024-01-01T00:00:00Z",
)
assert "at least 2 characters" in str(exc_info.value)
# Too long
with pytest.raises(ValidationError) as exc_info:
User(
id=1,
name="x" * 256,
email="john@example.com",
created_at="2024-01-01T00:00:00Z",
updated_at="2024-01-01T00:00:00Z",
)
assert "cannot exceed 255 characters" in str(exc_info.value)
# Empty
with pytest.raises(ValidationError) as exc_info:
User(
id=1,
name="",
email="john@example.com",
created_at="2024-01-01T00:00:00Z",
updated_at="2024-01-01T00:00:00Z",
)
assert "cannot be empty" in str(exc_info.value)
# Whitespace trimming
user = User(
id=1,
name=" John Doe ",
email="john@example.com",
created_at="2024-01-01T00:00:00Z",
updated_at="2024-01-01T00:00:00Z",
)
assert user.name == "John Doe"
class TestUserCreate:
"""Test UserCreate model."""
def test_user_create_minimal(self):
"""Test creating user with minimal required fields."""
user_data = UserCreate(
email="john@example.com", name="John Doe", password_raw="secret123"
)
assert user_data.email == "john@example.com"
assert user_data.name == "John Doe"
assert user_data.password_raw == "secret123"
assert user_data.provider_key == "local"
assert user_data.groups == []
assert user_data.must_change_password is False
assert user_data.send_welcome_email is True
def test_user_create_full(self):
"""Test creating user with all fields."""
user_data = UserCreate(
email="john@example.com",
name="John Doe",
password_raw="secret123",
provider_key="ldap",
groups=[1, 2, 3],
must_change_password=True,
send_welcome_email=False,
location="New York",
job_title="Developer",
timezone="America/New_York",
)
assert user_data.email == "john@example.com"
assert user_data.name == "John Doe"
assert user_data.password_raw == "secret123"
assert user_data.provider_key == "ldap"
assert user_data.groups == [1, 2, 3]
assert user_data.must_change_password is True
assert user_data.send_welcome_email is False
assert user_data.location == "New York"
assert user_data.job_title == "Developer"
assert user_data.timezone == "America/New_York"
def test_user_create_camel_case_alias(self):
"""Test that camelCase aliases work."""
user_data = UserCreate(
email="john@example.com",
name="John Doe",
passwordRaw="secret123",
providerKey="ldap",
mustChangePassword=True,
sendWelcomeEmail=False,
jobTitle="Developer",
)
assert user_data.password_raw == "secret123"
assert user_data.provider_key == "ldap"
assert user_data.must_change_password is True
assert user_data.send_welcome_email is False
assert user_data.job_title == "Developer"
def test_user_create_required_fields(self):
"""Test that required fields are enforced."""
with pytest.raises(ValidationError) as exc_info:
UserCreate(name="John Doe", password_raw="secret123")
assert "email" in str(exc_info.value)
with pytest.raises(ValidationError) as exc_info:
UserCreate(email="john@example.com", password_raw="secret123")
assert "name" in str(exc_info.value)
with pytest.raises(ValidationError) as exc_info:
UserCreate(email="john@example.com", name="John Doe")
# Pydantic uses the field alias in error messages
assert "passwordRaw" in str(exc_info.value) or "password_raw" in str(
exc_info.value
)
def test_user_create_email_validation(self):
"""Test email validation."""
with pytest.raises(ValidationError) as exc_info:
UserCreate(email="not-an-email", name="John Doe", password_raw="secret123")
assert "email" in str(exc_info.value).lower()
def test_user_create_name_validation(self):
"""Test name validation."""
# Too short
with pytest.raises(ValidationError) as exc_info:
UserCreate(email="john@example.com", name="J", password_raw="secret123")
assert "at least 2 characters" in str(exc_info.value)
# Too long
with pytest.raises(ValidationError) as exc_info:
UserCreate(
email="john@example.com", name="x" * 256, password_raw="secret123"
)
assert "cannot exceed 255 characters" in str(exc_info.value)
# Empty
with pytest.raises(ValidationError) as exc_info:
UserCreate(email="john@example.com", name="", password_raw="secret123")
assert "cannot be empty" in str(exc_info.value)
def test_user_create_password_validation(self):
"""Test password validation."""
# Too short
with pytest.raises(ValidationError) as exc_info:
UserCreate(email="john@example.com", name="John Doe", password_raw="123")
assert "at least 6 characters" in str(exc_info.value)
# Too long
with pytest.raises(ValidationError) as exc_info:
UserCreate(
email="john@example.com", name="John Doe", password_raw="x" * 256
)
assert "cannot exceed 255 characters" in str(exc_info.value)
# Empty
with pytest.raises(ValidationError) as exc_info:
UserCreate(email="john@example.com", name="John Doe", password_raw="")
assert "cannot be empty" in str(exc_info.value)
class TestUserUpdate:
"""Test UserUpdate model."""
def test_user_update_all_none(self):
"""Test creating empty update."""
user_data = UserUpdate()
assert user_data.name is None
assert user_data.email is None
assert user_data.password_raw is None
assert user_data.location is None
assert user_data.job_title is None
assert user_data.timezone is None
assert user_data.groups is None
assert user_data.is_active is None
assert user_data.is_verified is None
def test_user_update_partial(self):
"""Test partial updates."""
user_data = UserUpdate(name="Jane Doe", email="jane@example.com")
assert user_data.name == "Jane Doe"
assert user_data.email == "jane@example.com"
assert user_data.password_raw is None
assert user_data.location is None
def test_user_update_full(self):
"""Test full update."""
user_data = UserUpdate(
name="Jane Doe",
email="jane@example.com",
password_raw="newsecret123",
location="San Francisco",
job_title="Senior Developer",
timezone="America/Los_Angeles",
groups=[1, 2],
is_active=False,
is_verified=True,
)
assert user_data.name == "Jane Doe"
assert user_data.email == "jane@example.com"
assert user_data.password_raw == "newsecret123"
assert user_data.location == "San Francisco"
assert user_data.job_title == "Senior Developer"
assert user_data.timezone == "America/Los_Angeles"
assert user_data.groups == [1, 2]
assert user_data.is_active is False
assert user_data.is_verified is True
def test_user_update_camel_case_alias(self):
"""Test that camelCase aliases work."""
user_data = UserUpdate(
passwordRaw="newsecret123",
jobTitle="Senior Developer",
isActive=False,
isVerified=True,
)
assert user_data.password_raw == "newsecret123"
assert user_data.job_title == "Senior Developer"
assert user_data.is_active is False
assert user_data.is_verified is True
def test_user_update_email_validation(self):
"""Test email validation."""
with pytest.raises(ValidationError) as exc_info:
UserUpdate(email="not-an-email")
assert "email" in str(exc_info.value).lower()
def test_user_update_name_validation(self):
"""Test name validation."""
# Too short
with pytest.raises(ValidationError) as exc_info:
UserUpdate(name="J")
assert "at least 2 characters" in str(exc_info.value)
# Too long
with pytest.raises(ValidationError) as exc_info:
UserUpdate(name="x" * 256)
assert "cannot exceed 255 characters" in str(exc_info.value)
# Empty
with pytest.raises(ValidationError) as exc_info:
UserUpdate(name="")
assert "cannot be empty" in str(exc_info.value)
def test_user_update_password_validation(self):
"""Test password validation."""
# Too short
with pytest.raises(ValidationError) as exc_info:
UserUpdate(password_raw="123")
assert "at least 6 characters" in str(exc_info.value)
# Too long
with pytest.raises(ValidationError) as exc_info:
UserUpdate(password_raw="x" * 256)
assert "cannot exceed 255 characters" in str(exc_info.value)

192
tests/test_pagination.py Normal file
View File

@@ -0,0 +1,192 @@
"""Tests for auto-pagination iterators."""
from unittest.mock import AsyncMock, Mock
import pytest
from wikijs.aio.endpoints import AsyncPagesEndpoint, AsyncUsersEndpoint
from wikijs.endpoints import PagesEndpoint, UsersEndpoint
from wikijs.models import Page, User
class TestPagesIterator:
"""Test Pages iterator."""
@pytest.fixture
def client(self):
"""Create mock client."""
return Mock(base_url="https://wiki.example.com")
@pytest.fixture
def endpoint(self, client):
"""Create PagesEndpoint."""
return PagesEndpoint(client)
def test_iter_all_single_batch(self, endpoint):
"""Test iteration with single batch."""
# Mock list to return 3 pages (less than batch size)
pages_data = [
Page(id=i, title=f"Page {i}", path=f"/page{i}", content="test",
created_at="2024-01-01T00:00:00Z", updated_at="2024-01-01T00:00:00Z")
for i in range(1, 4)
]
endpoint.list = Mock(return_value=pages_data)
# Iterate
result = list(endpoint.iter_all(batch_size=50))
# Should fetch once and return all 3
assert len(result) == 3
assert endpoint.list.call_count == 1
def test_iter_all_multiple_batches(self, endpoint):
"""Test iteration with multiple batches."""
# Mock list to return different batches
batch1 = [
Page(id=i, title=f"Page {i}", path=f"/page{i}", content="test",
created_at="2024-01-01T00:00:00Z", updated_at="2024-01-01T00:00:00Z")
for i in range(1, 3)
]
batch2 = [
Page(id=3, title="Page 3", path="/page3", content="test",
created_at="2024-01-01T00:00:00Z", updated_at="2024-01-01T00:00:00Z")
]
endpoint.list = Mock(side_effect=[batch1, batch2])
# Iterate with batch_size=2
result = list(endpoint.iter_all(batch_size=2))
# Should fetch twice and return all 3
assert len(result) == 3
assert endpoint.list.call_count == 2
def test_iter_all_empty(self, endpoint):
"""Test iteration with no results."""
endpoint.list = Mock(return_value=[])
result = list(endpoint.iter_all())
assert len(result) == 0
assert endpoint.list.call_count == 1
class TestUsersIterator:
"""Test Users iterator."""
@pytest.fixture
def client(self):
"""Create mock client."""
return Mock(base_url="https://wiki.example.com")
@pytest.fixture
def endpoint(self, client):
"""Create UsersEndpoint."""
return UsersEndpoint(client)
def test_iter_all_pagination(self, endpoint):
"""Test pagination with users."""
# Create 5 users, batch size 2
all_users = [
User(id=i, name=f"User {i}", email=f"user{i}@example.com",
created_at="2024-01-01T00:00:00Z", updated_at="2024-01-01T00:00:00Z")
for i in range(1, 6)
]
# Mock to return batches
endpoint.list = Mock(side_effect=[
all_users[0:2], # First batch
all_users[2:4], # Second batch
all_users[4:5], # Third batch (last, < batch_size)
])
result = list(endpoint.iter_all(batch_size=2))
assert len(result) == 5
assert endpoint.list.call_count == 3
class TestAsyncPagesIterator:
"""Test async Pages iterator."""
@pytest.fixture
def client(self):
"""Create mock async client."""
return Mock(base_url="https://wiki.example.com")
@pytest.fixture
def endpoint(self, client):
"""Create AsyncPagesEndpoint."""
return AsyncPagesEndpoint(client)
@pytest.mark.asyncio
async def test_iter_all_async(self, endpoint):
"""Test async iteration."""
pages_data = [
Page(id=i, title=f"Page {i}", path=f"/page{i}", content="test",
created_at="2024-01-01T00:00:00Z", updated_at="2024-01-01T00:00:00Z")
for i in range(1, 4)
]
endpoint.list = AsyncMock(return_value=pages_data)
result = []
async for page in endpoint.iter_all():
result.append(page)
assert len(result) == 3
assert endpoint.list.call_count == 1
@pytest.mark.asyncio
async def test_iter_all_multiple_batches_async(self, endpoint):
"""Test async iteration with multiple batches."""
batch1 = [
Page(id=i, title=f"Page {i}", path=f"/page{i}", content="test",
created_at="2024-01-01T00:00:00Z", updated_at="2024-01-01T00:00:00Z")
for i in range(1, 3)
]
batch2 = [
Page(id=3, title="Page 3", path="/page3", content="test",
created_at="2024-01-01T00:00:00Z", updated_at="2024-01-01T00:00:00Z")
]
endpoint.list = AsyncMock(side_effect=[batch1, batch2])
result = []
async for page in endpoint.iter_all(batch_size=2):
result.append(page)
assert len(result) == 3
assert endpoint.list.call_count == 2
class TestAsyncUsersIterator:
"""Test async Users iterator."""
@pytest.fixture
def client(self):
"""Create mock async client."""
return Mock(base_url="https://wiki.example.com")
@pytest.fixture
def endpoint(self, client):
"""Create AsyncUsersEndpoint."""
return AsyncUsersEndpoint(client)
@pytest.mark.asyncio
async def test_iter_all_async_pagination(self, endpoint):
"""Test async pagination."""
all_users = [
User(id=i, name=f"User {i}", email=f"user{i}@example.com",
created_at="2024-01-01T00:00:00Z", updated_at="2024-01-01T00:00:00Z")
for i in range(1, 4)
]
endpoint.list = AsyncMock(side_effect=[
all_users[0:2],
all_users[2:3],
])
result = []
async for user in endpoint.iter_all(batch_size=2):
result.append(user)
assert len(result) == 3
assert endpoint.list.call_count == 2

View File

@@ -4,18 +4,26 @@ This package provides a comprehensive Python SDK for interacting with Wiki.js
instances, including support for pages, users, groups, and system management.
Example:
Basic usage:
Synchronous usage:
>>> from wikijs import WikiJSClient
>>> client = WikiJSClient('https://wiki.example.com', auth='your-api-key')
>>> # API endpoints will be available as development progresses
>>> pages = client.pages.list()
Asynchronous usage (requires aiohttp):
>>> from wikijs.aio import AsyncWikiJSClient
>>> async with AsyncWikiJSClient('https://wiki.example.com', auth='key') as client:
... pages = await client.pages.list()
Features:
- Synchronous and asynchronous clients
- Type-safe data models with validation
- Comprehensive error handling
- Automatic retry logic with exponential backoff
- Professional logging and debugging support
- Context manager support for resource cleanup
- High-performance async operations with connection pooling
"""
from .auth import APIKeyAuth, AuthHandler, JWTAuth, NoAuth

30
wikijs/aio/__init__.py Normal file
View File

@@ -0,0 +1,30 @@
"""Async support for Wiki.js Python SDK.
This module provides asynchronous versions of the Wiki.js client and endpoints
using aiohttp for improved performance with concurrent requests.
Example:
Basic async usage:
>>> from wikijs.aio import AsyncWikiJSClient
>>> async with AsyncWikiJSClient('https://wiki.example.com', auth='key') as client:
... page = await client.pages.get(123)
... pages = await client.pages.list()
Features:
- Async/await support with aiohttp
- Connection pooling and resource management
- Context manager support for automatic cleanup
- Same interface as sync client
- Significantly improved performance for concurrent requests
Performance:
The async client can achieve >3x throughput compared to the sync client
when making multiple concurrent requests (100+ requests).
"""
from .client import AsyncWikiJSClient
__all__ = [
"AsyncWikiJSClient",
]

370
wikijs/aio/client.py Normal file
View File

@@ -0,0 +1,370 @@
"""Async WikiJS client for wikijs-python-sdk."""
import json
from typing import Any, Dict, Optional, Union
try:
import aiohttp
except ImportError:
raise ImportError(
"aiohttp is required for async support. "
"Install it with: pip install wikijs-python-sdk[async]"
)
from ..auth import APIKeyAuth, AuthHandler
from ..exceptions import (
APIError,
AuthenticationError,
ConfigurationError,
ConnectionError,
TimeoutError,
create_api_error,
)
from ..utils import (
build_api_url,
extract_error_message,
normalize_url,
parse_wiki_response,
)
from ..version import __version__
from .endpoints import AsyncAssetsEndpoint, AsyncGroupsEndpoint, AsyncPagesEndpoint, AsyncUsersEndpoint
class AsyncWikiJSClient:
"""Async client for interacting with Wiki.js API.
This async client provides high-performance concurrent access to all Wiki.js
API operations using aiohttp. It maintains the same interface as the sync
client but with async/await support.
Args:
base_url: The base URL of your Wiki.js instance
auth: Authentication (API key string or auth handler)
timeout: Request timeout in seconds (default: 30)
verify_ssl: Whether to verify SSL certificates (default: True)
user_agent: Custom User-Agent header
connector: Optional aiohttp connector for connection pooling
Example:
Basic async usage:
>>> async with AsyncWikiJSClient('https://wiki.example.com', auth='key') as client:
... pages = await client.pages.list()
... page = await client.pages.get(123)
Manual resource management:
>>> client = AsyncWikiJSClient('https://wiki.example.com', auth='key')
>>> try:
... page = await client.pages.get(123)
... finally:
... await client.close()
Attributes:
base_url: The normalized base URL
timeout: Request timeout setting
verify_ssl: SSL verification setting
"""
def __init__(
self,
base_url: str,
auth: Union[str, AuthHandler],
timeout: int = 30,
verify_ssl: bool = True,
user_agent: Optional[str] = None,
connector: Optional[aiohttp.BaseConnector] = None,
):
# Instance variable declarations
self._auth_handler: AuthHandler
self._session: Optional[aiohttp.ClientSession] = None
self._connector = connector
self._owned_connector = connector is None
# Validate and normalize base URL
self.base_url = normalize_url(base_url)
# Store authentication
if isinstance(auth, str):
# Convert string API key to APIKeyAuth handler
self._auth_handler = APIKeyAuth(auth)
elif isinstance(auth, AuthHandler):
# Use provided auth handler
self._auth_handler = auth
else:
raise ConfigurationError(
f"Invalid auth parameter: expected str or AuthHandler, got {type(auth)}"
)
# Request configuration
self.timeout = timeout
self.verify_ssl = verify_ssl
self.user_agent = user_agent or f"wikijs-python-sdk/{__version__}"
# Endpoint handlers (will be initialized when session is created)
self.pages = AsyncPagesEndpoint(self)
self.users = AsyncUsersEndpoint(self)
self.groups = AsyncGroupsEndpoint(self)
self.assets = AsyncAssetsEndpoint(self)
def _get_session(self) -> aiohttp.ClientSession:
"""Get or create aiohttp session.
Returns:
Configured aiohttp session
Raises:
ConfigurationError: If session cannot be created
"""
if self._session is None or self._session.closed:
self._session = self._create_session()
return self._session
def _create_session(self) -> aiohttp.ClientSession:
"""Create configured aiohttp session with connection pooling.
Returns:
Configured aiohttp session
"""
# Create connector if not provided
if self._connector is None and self._owned_connector:
self._connector = aiohttp.TCPConnector(
limit=100, # Maximum number of connections
limit_per_host=30, # Maximum per host
ttl_dns_cache=300, # DNS cache TTL
ssl=self.verify_ssl,
)
# Set timeout
timeout_obj = aiohttp.ClientTimeout(total=self.timeout)
# Build headers
headers = {
"User-Agent": self.user_agent,
"Accept": "application/json",
"Content-Type": "application/json",
}
# Add authentication headers
if self._auth_handler:
self._auth_handler.validate_credentials()
auth_headers = self._auth_handler.get_headers()
headers.update(auth_headers)
# Create session
session = aiohttp.ClientSession(
connector=self._connector,
timeout=timeout_obj,
headers=headers,
raise_for_status=False, # We'll handle status codes manually
)
return session
async def _request(
self,
method: str,
endpoint: str,
params: Optional[Dict[str, Any]] = None,
json_data: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> Any:
"""Make async HTTP request to Wiki.js API.
Args:
method: HTTP method (GET, POST, PUT, DELETE)
endpoint: API endpoint path
params: Query parameters
json_data: JSON data for request body
**kwargs: Additional request parameters
Returns:
Parsed response data
Raises:
AuthenticationError: If authentication fails
APIError: If API returns an error
ConnectionError: If connection fails
TimeoutError: If request times out
"""
# Build full URL
url = build_api_url(self.base_url, endpoint)
# Get session
session = self._get_session()
# Prepare request arguments
request_kwargs: Dict[str, Any] = {
"params": params,
"ssl": self.verify_ssl,
**kwargs,
}
# Add JSON data if provided
if json_data is not None:
request_kwargs["json"] = json_data
try:
# Make async request
async with session.request(method, url, **request_kwargs) as response:
# Handle response
return await self._handle_response(response)
except aiohttp.ServerTimeoutError as e:
raise TimeoutError(f"Request timed out after {self.timeout} seconds") from e
except asyncio.TimeoutError as e:
raise TimeoutError(f"Request timed out after {self.timeout} seconds") from e
except aiohttp.ClientConnectionError as e:
raise ConnectionError(f"Failed to connect to {self.base_url}") from e
except aiohttp.ClientError as e:
raise APIError(f"Request failed: {str(e)}") from e
async def _handle_response(self, response: aiohttp.ClientResponse) -> Any:
"""Handle async HTTP response and extract data.
Args:
response: aiohttp response object
Returns:
Parsed response data
Raises:
AuthenticationError: If authentication fails (401)
APIError: If API returns an error
"""
# Handle authentication errors
if response.status == 401:
raise AuthenticationError("Authentication failed - check your API key")
# Handle other HTTP errors
if response.status >= 400:
# Try to read response text for error message
try:
response_text = await response.text()
# Create a mock response object for extract_error_message
class MockResponse:
def __init__(self, status, text):
self.status_code = status
self.text = text
try:
self._json = json.loads(text) if text else {}
except json.JSONDecodeError:
self._json = {}
def json(self):
return self._json
mock_resp = MockResponse(response.status, response_text)
error_message = extract_error_message(mock_resp)
except Exception:
error_message = f"HTTP {response.status}"
raise create_api_error(response.status, error_message, None)
# Parse JSON response
try:
data = await response.json()
except json.JSONDecodeError as e:
response_text = await response.text()
raise APIError(
f"Invalid JSON response: {str(e)}. Response: {response_text[:200]}"
) from e
# Parse Wiki.js specific response format
return parse_wiki_response(data)
async def test_connection(self) -> bool:
"""Test connection to Wiki.js instance.
This method validates the connection by making an actual GraphQL query
to the Wiki.js API, ensuring both connectivity and authentication work.
Returns:
True if connection successful
Raises:
ConfigurationError: If client is not properly configured
ConnectionError: If cannot connect to server
AuthenticationError: If authentication fails
TimeoutError: If connection test times out
"""
if not self.base_url:
raise ConfigurationError("Base URL not configured")
if not self._auth_handler:
raise ConfigurationError("Authentication not configured")
try:
# Test with minimal GraphQL query to validate API access
query = """
query {
site {
title
}
}
"""
response = await self._request(
"POST", "/graphql", json_data={"query": query}
)
# Check for GraphQL errors
if "errors" in response:
error_msg = response["errors"][0].get("message", "Unknown error")
raise AuthenticationError(f"GraphQL query failed: {error_msg}")
# Verify we got expected data structure
if "data" not in response or "site" not in response["data"]:
raise APIError("Unexpected response format from Wiki.js API")
return True
except AuthenticationError:
# Re-raise authentication errors as-is
raise
except TimeoutError:
# Re-raise timeout errors as-is
raise
except ConnectionError:
# Re-raise connection errors as-is
raise
except APIError:
# Re-raise API errors as-is
raise
except Exception as e:
raise ConnectionError(f"Connection test failed: {str(e)}")
async def __aenter__(self) -> "AsyncWikiJSClient":
"""Async context manager entry."""
# Ensure session is created
self._get_session()
return self
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
"""Async context manager exit - close session."""
await self.close()
async def close(self) -> None:
"""Close the aiohttp session and clean up resources."""
if self._session and not self._session.closed:
await self._session.close()
# Close connector if we own it
if self._owned_connector and self._connector and not self._connector.closed:
await self._connector.close()
def __repr__(self) -> str:
"""String representation of client."""
return f"AsyncWikiJSClient(base_url='{self.base_url}')"
# Need to import asyncio for timeout handling
import asyncio # noqa: E402

View File

@@ -0,0 +1,15 @@
"""Async endpoint handlers for Wiki.js API."""
from .assets import AsyncAssetsEndpoint
from .base import AsyncBaseEndpoint
from .groups import AsyncGroupsEndpoint
from .pages import AsyncPagesEndpoint
from .users import AsyncUsersEndpoint
__all__ = [
"AsyncAssetsEndpoint",
"AsyncBaseEndpoint",
"AsyncGroupsEndpoint",
"AsyncPagesEndpoint",
"AsyncUsersEndpoint",
]

View File

@@ -0,0 +1,342 @@
"""Async assets endpoint for Wiki.js API."""
import os
from typing import Dict, List, Optional
from ...exceptions import APIError, ValidationError
from ...models import Asset, AssetFolder
from .base import AsyncBaseEndpoint
class AsyncAssetsEndpoint(AsyncBaseEndpoint):
"""Async endpoint for managing Wiki.js assets."""
async def list(
self, folder_id: Optional[int] = None, kind: Optional[str] = None
) -> List[Asset]:
"""List all assets asynchronously."""
if folder_id is not None and folder_id < 0:
raise ValidationError("folder_id must be non-negative")
query = """
query ($folderId: Int, $kind: AssetKind) {
assets {
list(folderId: $folderId, kind: $kind) {
id filename ext kind mime fileSize folderId
folder { id slug name }
authorId authorName createdAt updatedAt
}
}
}
"""
variables = {}
if folder_id is not None:
variables["folderId"] = folder_id
if kind is not None:
variables["kind"] = kind.upper()
response = await self._post(
"/graphql", json_data={"query": query, "variables": variables}
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
assets_data = response.get("data", {}).get("assets", {}).get("list", [])
return [Asset(**self._normalize_asset_data(a)) for a in assets_data]
async def get(self, asset_id: int) -> Asset:
"""Get a specific asset by ID asynchronously."""
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
query = """
query ($id: Int!) {
assets {
single(id: $id) {
id filename ext kind mime fileSize folderId
folder { id slug name }
authorId authorName createdAt updatedAt
}
}
}
"""
response = await self._post(
"/graphql", json_data={"query": query, "variables": {"id": asset_id}}
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
asset_data = response.get("data", {}).get("assets", {}).get("single")
if not asset_data:
raise APIError(f"Asset with ID {asset_id} not found")
return Asset(**self._normalize_asset_data(asset_data))
async def rename(self, asset_id: int, new_filename: str) -> Asset:
"""Rename an asset asynchronously."""
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
if not new_filename or not new_filename.strip():
raise ValidationError("new_filename cannot be empty")
mutation = """
mutation ($id: Int!, $filename: String!) {
assets {
renameAsset(id: $id, filename: $filename) {
responseResult { succeeded errorCode slug message }
asset {
id filename ext kind mime fileSize folderId
authorId authorName createdAt updatedAt
}
}
}
}
"""
response = await self._post(
"/graphql",
json_data={
"query": mutation,
"variables": {"id": asset_id, "filename": new_filename.strip()},
},
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
result = response.get("data", {}).get("assets", {}).get("renameAsset", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to rename asset: {error_msg}")
asset_data = result.get("asset")
if not asset_data:
raise APIError("Asset renamed but no data returned")
return Asset(**self._normalize_asset_data(asset_data))
async def move(self, asset_id: int, folder_id: int) -> Asset:
"""Move an asset to a different folder asynchronously."""
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
if not isinstance(folder_id, int) or folder_id < 0:
raise ValidationError("folder_id must be non-negative")
mutation = """
mutation ($id: Int!, $folderId: Int!) {
assets {
moveAsset(id: $id, folderId: $folderId) {
responseResult { succeeded errorCode slug message }
asset {
id filename ext kind mime fileSize folderId
folder { id slug name }
authorId authorName createdAt updatedAt
}
}
}
}
"""
response = await self._post(
"/graphql",
json_data={
"query": mutation,
"variables": {"id": asset_id, "folderId": folder_id},
},
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
result = response.get("data", {}).get("assets", {}).get("moveAsset", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to move asset: {error_msg}")
asset_data = result.get("asset")
if not asset_data:
raise APIError("Asset moved but no data returned")
return Asset(**self._normalize_asset_data(asset_data))
async def delete(self, asset_id: int) -> bool:
"""Delete an asset asynchronously."""
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
mutation = """
mutation ($id: Int!) {
assets {
deleteAsset(id: $id) {
responseResult { succeeded errorCode slug message }
}
}
}
"""
response = await self._post(
"/graphql", json_data={"query": mutation, "variables": {"id": asset_id}}
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
result = response.get("data", {}).get("assets", {}).get("deleteAsset", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to delete asset: {error_msg}")
return True
async def list_folders(self) -> List[AssetFolder]:
"""List all asset folders asynchronously."""
query = """
query {
assets {
folders {
id slug name
}
}
}
"""
response = await self._post("/graphql", json_data={"query": query})
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
folders_data = response.get("data", {}).get("assets", {}).get("folders", [])
return [AssetFolder(**folder) for folder in folders_data]
async def create_folder(self, slug: str, name: Optional[str] = None) -> AssetFolder:
"""Create a new asset folder asynchronously."""
if not slug or not slug.strip():
raise ValidationError("slug cannot be empty")
slug = slug.strip().strip("/")
if not slug:
raise ValidationError("slug cannot be just slashes")
mutation = """
mutation ($slug: String!, $name: String) {
assets {
createFolder(slug: $slug, name: $name) {
responseResult { succeeded errorCode slug message }
folder { id slug name }
}
}
}
"""
variables = {"slug": slug}
if name:
variables["name"] = name
response = await self._post(
"/graphql", json_data={"query": mutation, "variables": variables}
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
result = response.get("data", {}).get("assets", {}).get("createFolder", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to create folder: {error_msg}")
folder_data = result.get("folder")
if not folder_data:
raise APIError("Folder created but no data returned")
return AssetFolder(**folder_data)
async def delete_folder(self, folder_id: int) -> bool:
"""Delete an asset folder asynchronously."""
if not isinstance(folder_id, int) or folder_id <= 0:
raise ValidationError("folder_id must be a positive integer")
mutation = """
mutation ($id: Int!) {
assets {
deleteFolder(id: $id) {
responseResult { succeeded errorCode slug message }
}
}
}
"""
response = await self._post(
"/graphql", json_data={"query": mutation, "variables": {"id": folder_id}}
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
result = response.get("data", {}).get("assets", {}).get("deleteFolder", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to delete folder: {error_msg}")
return True
def _normalize_asset_data(self, data: Dict) -> Dict:
"""Normalize asset data from API response."""
return {
"id": data.get("id"),
"filename": data.get("filename"),
"ext": data.get("ext"),
"kind": data.get("kind"),
"mime": data.get("mime"),
"file_size": data.get("fileSize"),
"folder_id": data.get("folderId"),
"folder": data.get("folder"),
"author_id": data.get("authorId"),
"author_name": data.get("authorName"),
"created_at": data.get("createdAt"),
"updated_at": data.get("updatedAt"),
}
async def iter_all(
self,
batch_size: int = 50,
folder_id: Optional[int] = None,
kind: Optional[str] = None,
):
"""Iterate over all assets asynchronously with automatic pagination.
Args:
batch_size: Batch size for iteration (default: 50)
folder_id: Filter by folder ID
kind: Filter by asset kind
Yields:
Asset objects one at a time
Example:
>>> async for asset in client.assets.iter_all(kind="image"):
... print(f"{asset.filename}: {asset.size_mb:.2f} MB")
"""
assets = await self.list(folder_id=folder_id, kind=kind)
# Yield in batches to limit memory usage
for i in range(0, len(assets), batch_size):
batch = assets[i : i + batch_size]
for asset in batch:
yield asset

View File

@@ -0,0 +1,140 @@
"""Base async endpoint class for wikijs-python-sdk."""
from typing import TYPE_CHECKING, Any, Dict, Optional
if TYPE_CHECKING:
from ..client import AsyncWikiJSClient
class AsyncBaseEndpoint:
"""Base class for all async API endpoints.
This class provides common functionality for making async API requests
and handling responses across all endpoint implementations.
Args:
client: The async WikiJS client instance
"""
def __init__(self, client: "AsyncWikiJSClient"):
"""Initialize endpoint with client reference.
Args:
client: Async WikiJS client instance
"""
self._client = client
async def _request(
self,
method: str,
endpoint: str,
params: Optional[Dict[str, Any]] = None,
json_data: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> Any:
"""Make async HTTP request through the client.
Args:
method: HTTP method (GET, POST, PUT, DELETE)
endpoint: API endpoint path
params: Query parameters
json_data: JSON data for request body
**kwargs: Additional request parameters
Returns:
Parsed response data
"""
return await self._client._request(
method=method,
endpoint=endpoint,
params=params,
json_data=json_data,
**kwargs,
)
async def _get(
self, endpoint: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any
) -> Any:
"""Make async GET request.
Args:
endpoint: API endpoint path
params: Query parameters
**kwargs: Additional request parameters
Returns:
Parsed response data
"""
return await self._request("GET", endpoint, params=params, **kwargs)
async def _post(
self,
endpoint: str,
json_data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> Any:
"""Make async POST request.
Args:
endpoint: API endpoint path
json_data: JSON data for request body
params: Query parameters
**kwargs: Additional request parameters
Returns:
Parsed response data
"""
return await self._request(
"POST", endpoint, params=params, json_data=json_data, **kwargs
)
async def _put(
self,
endpoint: str,
json_data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> Any:
"""Make async PUT request.
Args:
endpoint: API endpoint path
json_data: JSON data for request body
params: Query parameters
**kwargs: Additional request parameters
Returns:
Parsed response data
"""
return await self._request(
"PUT", endpoint, params=params, json_data=json_data, **kwargs
)
async def _delete(
self, endpoint: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any
) -> Any:
"""Make async DELETE request.
Args:
endpoint: API endpoint path
params: Query parameters
**kwargs: Additional request parameters
Returns:
Parsed response data
"""
return await self._request("DELETE", endpoint, params=params, **kwargs)
def _build_endpoint(self, *parts: str) -> str:
"""Build endpoint path from parts.
Args:
*parts: Path components
Returns:
Formatted endpoint path
"""
# Remove empty parts and join with /
clean_parts = [str(part).strip("/") for part in parts if part]
return "/" + "/".join(clean_parts)

View File

@@ -0,0 +1,572 @@
"""Async groups endpoint for Wiki.js API."""
from typing import Dict, List, Union
from ...exceptions import APIError, ValidationError
from ...models import Group, GroupCreate, GroupUpdate
from .base import AsyncBaseEndpoint
class AsyncGroupsEndpoint(AsyncBaseEndpoint):
"""Async endpoint for managing Wiki.js groups.
Provides async methods to:
- List all groups
- Get a specific group by ID
- Create new groups
- Update existing groups
- Delete groups
- Assign users to groups
- Remove users from groups
"""
async def list(self) -> List[Group]:
"""List all groups asynchronously.
Returns:
List of Group objects
Raises:
APIError: If the API request fails
Example:
>>> async with AsyncWikiJSClient(...) as client:
... groups = await client.groups.list()
... for group in groups:
... print(f"{group.name}: {len(group.users)} users")
"""
query = """
query {
groups {
list {
id
name
isSystem
redirectOnLogin
permissions
pageRules {
id
path
roles
match
deny
locales
}
users {
id
name
email
}
createdAt
updatedAt
}
}
}
"""
response = await self._post("/graphql", json_data={"query": query})
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Extract and normalize groups
groups_data = response.get("data", {}).get("groups", {}).get("list", [])
return [Group(**self._normalize_group_data(g)) for g in groups_data]
async def get(self, group_id: int) -> Group:
"""Get a specific group by ID asynchronously.
Args:
group_id: The group ID
Returns:
Group object with user list
Raises:
ValidationError: If group_id is invalid
APIError: If the group is not found or API request fails
Example:
>>> async with AsyncWikiJSClient(...) as client:
... group = await client.groups.get(1)
... print(f"{group.name}: {group.permissions}")
"""
# Validate group_id
if not isinstance(group_id, int) or group_id <= 0:
raise ValidationError("group_id must be a positive integer")
query = """
query ($id: Int!) {
groups {
single(id: $id) {
id
name
isSystem
redirectOnLogin
permissions
pageRules {
id
path
roles
match
deny
locales
}
users {
id
name
email
}
createdAt
updatedAt
}
}
}
"""
response = await self._post(
"/graphql", json_data={"query": query, "variables": {"id": group_id}}
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Extract group data
group_data = response.get("data", {}).get("groups", {}).get("single")
if not group_data:
raise APIError(f"Group with ID {group_id} not found")
return Group(**self._normalize_group_data(group_data))
async def create(self, group_data: Union[GroupCreate, Dict]) -> Group:
"""Create a new group asynchronously.
Args:
group_data: GroupCreate object or dict with group data
Returns:
Created Group object
Raises:
ValidationError: If group data is invalid
APIError: If the API request fails
Example:
>>> from wikijs.models import GroupCreate
>>> async with AsyncWikiJSClient(...) as client:
... group_data = GroupCreate(
... name="Editors",
... permissions=["read:pages", "write:pages"]
... )
... group = await client.groups.create(group_data)
"""
# Validate and convert to dict
if isinstance(group_data, dict):
try:
group_data = GroupCreate(**group_data)
except Exception as e:
raise ValidationError(f"Invalid group data: {e}")
elif not isinstance(group_data, GroupCreate):
raise ValidationError("group_data must be a GroupCreate object or dict")
# Build mutation
mutation = """
mutation ($name: String!, $redirectOnLogin: String, $permissions: [String]!, $pageRules: [PageRuleInput]!) {
groups {
create(
name: $name
redirectOnLogin: $redirectOnLogin
permissions: $permissions
pageRules: $pageRules
) {
responseResult {
succeeded
errorCode
slug
message
}
group {
id
name
isSystem
redirectOnLogin
permissions
pageRules {
id
path
roles
match
deny
locales
}
createdAt
updatedAt
}
}
}
}
"""
variables = {
"name": group_data.name,
"redirectOnLogin": group_data.redirect_on_login or "/",
"permissions": group_data.permissions,
"pageRules": group_data.page_rules,
}
response = await self._post(
"/graphql", json_data={"query": mutation, "variables": variables}
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Check response result
result = response.get("data", {}).get("groups", {}).get("create", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to create group: {error_msg}")
# Extract and return created group
group_data = result.get("group")
if not group_data:
raise APIError("Group created but no data returned")
return Group(**self._normalize_group_data(group_data))
async def update(
self, group_id: int, group_data: Union[GroupUpdate, Dict]
) -> Group:
"""Update an existing group asynchronously.
Args:
group_id: The group ID
group_data: GroupUpdate object or dict with fields to update
Returns:
Updated Group object
Raises:
ValidationError: If group_id or group_data is invalid
APIError: If the API request fails
Example:
>>> from wikijs.models import GroupUpdate
>>> async with AsyncWikiJSClient(...) as client:
... update_data = GroupUpdate(
... name="Senior Editors",
... permissions=["read:pages", "write:pages", "delete:pages"]
... )
... group = await client.groups.update(1, update_data)
"""
# Validate group_id
if not isinstance(group_id, int) or group_id <= 0:
raise ValidationError("group_id must be a positive integer")
# Validate and convert to dict
if isinstance(group_data, dict):
try:
group_data = GroupUpdate(**group_data)
except Exception as e:
raise ValidationError(f"Invalid group data: {e}")
elif not isinstance(group_data, GroupUpdate):
raise ValidationError("group_data must be a GroupUpdate object or dict")
# Build mutation with only non-None fields
mutation = """
mutation ($id: Int!, $name: String, $redirectOnLogin: String, $permissions: [String], $pageRules: [PageRuleInput]) {
groups {
update(
id: $id
name: $name
redirectOnLogin: $redirectOnLogin
permissions: $permissions
pageRules: $pageRules
) {
responseResult {
succeeded
errorCode
slug
message
}
group {
id
name
isSystem
redirectOnLogin
permissions
pageRules {
id
path
roles
match
deny
locales
}
createdAt
updatedAt
}
}
}
}
"""
variables = {"id": group_id}
# Add only non-None fields to variables
if group_data.name is not None:
variables["name"] = group_data.name
if group_data.redirect_on_login is not None:
variables["redirectOnLogin"] = group_data.redirect_on_login
if group_data.permissions is not None:
variables["permissions"] = group_data.permissions
if group_data.page_rules is not None:
variables["pageRules"] = group_data.page_rules
response = await self._post(
"/graphql", json_data={"query": mutation, "variables": variables}
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Check response result
result = response.get("data", {}).get("groups", {}).get("update", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to update group: {error_msg}")
# Extract and return updated group
group_data_response = result.get("group")
if not group_data_response:
raise APIError("Group updated but no data returned")
return Group(**self._normalize_group_data(group_data_response))
async def delete(self, group_id: int) -> bool:
"""Delete a group asynchronously.
Args:
group_id: The group ID
Returns:
True if deletion was successful
Raises:
ValidationError: If group_id is invalid
APIError: If the API request fails
Example:
>>> async with AsyncWikiJSClient(...) as client:
... success = await client.groups.delete(5)
... if success:
... print("Group deleted")
"""
# Validate group_id
if not isinstance(group_id, int) or group_id <= 0:
raise ValidationError("group_id must be a positive integer")
mutation = """
mutation ($id: Int!) {
groups {
delete(id: $id) {
responseResult {
succeeded
errorCode
slug
message
}
}
}
}
"""
response = await self._post(
"/graphql", json_data={"query": mutation, "variables": {"id": group_id}}
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Check response result
result = response.get("data", {}).get("groups", {}).get("delete", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to delete group: {error_msg}")
return True
async def assign_user(self, group_id: int, user_id: int) -> bool:
"""Assign a user to a group asynchronously.
Args:
group_id: The group ID
user_id: The user ID
Returns:
True if assignment was successful
Raises:
ValidationError: If group_id or user_id is invalid
APIError: If the API request fails
Example:
>>> async with AsyncWikiJSClient(...) as client:
... success = await client.groups.assign_user(group_id=1, user_id=5)
... if success:
... print("User assigned to group")
"""
# Validate IDs
if not isinstance(group_id, int) or group_id <= 0:
raise ValidationError("group_id must be a positive integer")
if not isinstance(user_id, int) or user_id <= 0:
raise ValidationError("user_id must be a positive integer")
mutation = """
mutation ($groupId: Int!, $userId: Int!) {
groups {
assignUser(groupId: $groupId, userId: $userId) {
responseResult {
succeeded
errorCode
slug
message
}
}
}
}
"""
response = await self._post(
"/graphql",
json_data={
"query": mutation,
"variables": {"groupId": group_id, "userId": user_id},
},
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Check response result
result = response.get("data", {}).get("groups", {}).get("assignUser", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to assign user to group: {error_msg}")
return True
async def unassign_user(self, group_id: int, user_id: int) -> bool:
"""Remove a user from a group asynchronously.
Args:
group_id: The group ID
user_id: The user ID
Returns:
True if removal was successful
Raises:
ValidationError: If group_id or user_id is invalid
APIError: If the API request fails
Example:
>>> async with AsyncWikiJSClient(...) as client:
... success = await client.groups.unassign_user(group_id=1, user_id=5)
... if success:
... print("User removed from group")
"""
# Validate IDs
if not isinstance(group_id, int) or group_id <= 0:
raise ValidationError("group_id must be a positive integer")
if not isinstance(user_id, int) or user_id <= 0:
raise ValidationError("user_id must be a positive integer")
mutation = """
mutation ($groupId: Int!, $userId: Int!) {
groups {
unassignUser(groupId: $groupId, userId: $userId) {
responseResult {
succeeded
errorCode
slug
message
}
}
}
}
"""
response = await self._post(
"/graphql",
json_data={
"query": mutation,
"variables": {"groupId": group_id, "userId": user_id},
},
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Check response result
result = response.get("data", {}).get("groups", {}).get("unassignUser", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to remove user from group: {error_msg}")
return True
def _normalize_group_data(self, data: Dict) -> Dict:
"""Normalize group data from API response to Python naming convention.
Args:
data: Raw group data from API
Returns:
Normalized group data with snake_case field names
"""
normalized = {
"id": data.get("id"),
"name": data.get("name"),
"is_system": data.get("isSystem", False),
"redirect_on_login": data.get("redirectOnLogin"),
"permissions": data.get("permissions", []),
"page_rules": data.get("pageRules", []),
"users": data.get("users", []),
"created_at": data.get("createdAt"),
"updated_at": data.get("updatedAt"),
}
return normalized
async def iter_all(self):
"""Iterate over all groups asynchronously.
Yields:
Group objects one at a time
Example:
>>> async for group in client.groups.iter_all():
... print(f"{group.name}: {len(group.users)} users")
"""
groups = await self.list()
for group in groups:
yield group

View File

@@ -0,0 +1,730 @@
"""Async Pages API endpoint for wikijs-python-sdk."""
from typing import Any, Dict, List, Optional, Union
from ...exceptions import APIError, ValidationError
from ...models.page import Page, PageCreate, PageUpdate
from .base import AsyncBaseEndpoint
class AsyncPagesEndpoint(AsyncBaseEndpoint):
"""Async endpoint for Wiki.js Pages API operations.
This endpoint provides async methods for creating, reading, updating, and
deleting wiki pages through the Wiki.js GraphQL API.
Example:
>>> async with AsyncWikiJSClient('https://wiki.example.com', auth='key') as client:
... pages = client.pages
...
... # List all pages
... all_pages = await pages.list()
...
... # Get a specific page
... page = await pages.get(123)
...
... # Create a new page
... new_page_data = PageCreate(
... title="Getting Started",
... path="getting-started",
... content="# Welcome\\n\\nThis is your first page!"
... )
... created_page = await pages.create(new_page_data)
...
... # Update an existing page
... update_data = PageUpdate(title="Updated Title")
... updated_page = await pages.update(123, update_data)
...
... # Delete a page
... await pages.delete(123)
"""
async def list(
self,
limit: Optional[int] = None,
offset: Optional[int] = None,
search: Optional[str] = None,
tags: Optional[List[str]] = None,
locale: Optional[str] = None,
author_id: Optional[int] = None,
order_by: str = "title",
order_direction: str = "ASC",
) -> List[Page]:
"""List pages with optional filtering.
Args:
limit: Maximum number of pages to return
offset: Number of pages to skip
search: Search term to filter pages
tags: List of tags to filter by (pages must have ALL tags)
locale: Locale to filter by
author_id: Author ID to filter by
order_by: Field to order by (title, created_at, updated_at)
order_direction: Order direction (ASC or DESC)
Returns:
List of Page objects
Raises:
APIError: If the API request fails
ValidationError: If parameters are invalid
"""
# Validate parameters
if limit is not None and limit < 1:
raise ValidationError("limit must be greater than 0")
if offset is not None and offset < 0:
raise ValidationError("offset must be non-negative")
if order_by not in ["title", "created_at", "updated_at", "path"]:
raise ValidationError(
"order_by must be one of: title, created_at, updated_at, path"
)
if order_direction not in ["ASC", "DESC"]:
raise ValidationError("order_direction must be ASC or DESC")
# Build GraphQL query with variables using actual Wiki.js schema
query = """
query($limit: Int, $offset: Int, $search: String, $tags: [String], $locale: String, $authorId: Int, $orderBy: String, $orderDirection: String) {
pages {
list(limit: $limit, offset: $offset, search: $search, tags: $tags, locale: $locale, authorId: $authorId, orderBy: $orderBy, orderDirection: $orderDirection) {
id
title
path
content
description
isPublished
isPrivate
tags
locale
authorId
authorName
authorEmail
editor
createdAt
updatedAt
}
}
}
"""
# Build variables object
variables: Dict[str, Any] = {}
if limit is not None:
variables["limit"] = limit
if offset is not None:
variables["offset"] = offset
if search is not None:
variables["search"] = search
if tags is not None:
variables["tags"] = tags
if locale is not None:
variables["locale"] = locale
if author_id is not None:
variables["authorId"] = author_id
if order_by is not None:
variables["orderBy"] = order_by
if order_direction is not None:
variables["orderDirection"] = order_direction
# Make request with query and variables
json_data: Dict[str, Any] = {"query": query}
if variables:
json_data["variables"] = variables
response = await self._post("/graphql", json_data=json_data)
# Parse response
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
pages_data = response.get("data", {}).get("pages", {}).get("list", [])
# Convert to Page objects
pages = []
for page_data in pages_data:
try:
# Convert API field names to model field names
normalized_data = self._normalize_page_data(page_data)
page = Page(**normalized_data)
pages.append(page)
except Exception as e:
raise APIError(f"Failed to parse page data: {str(e)}") from e
return pages
async def get(self, page_id: int) -> Page:
"""Get a specific page by ID.
Args:
page_id: The page ID
Returns:
Page object
Raises:
APIError: If the page is not found or request fails
ValidationError: If page_id is invalid
"""
if not isinstance(page_id, int) or page_id < 1:
raise ValidationError("page_id must be a positive integer")
# Build GraphQL query using actual Wiki.js schema
query = """
query($id: Int!) {
pages {
single(id: $id) {
id
title
path
content
description
isPublished
isPrivate
tags {
tag
}
locale
authorId
authorName
authorEmail
editor
createdAt
updatedAt
}
}
}
"""
# Make request
response = await self._post(
"/graphql",
json_data={"query": query, "variables": {"id": page_id}},
)
# Parse response
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
page_data = response.get("data", {}).get("pages", {}).get("single")
if not page_data:
raise APIError(f"Page with ID {page_id} not found")
# Convert to Page object
try:
normalized_data = self._normalize_page_data(page_data)
return Page(**normalized_data)
except Exception as e:
raise APIError(f"Failed to parse page data: {str(e)}") from e
async def get_by_path(self, path: str, locale: str = "en") -> Page:
"""Get a page by its path.
Args:
path: The page path (e.g., "getting-started")
locale: The page locale (default: "en")
Returns:
Page object
Raises:
APIError: If the page is not found or request fails
ValidationError: If path is invalid
"""
if not path or not isinstance(path, str):
raise ValidationError("path must be a non-empty string")
# Normalize path
path = path.strip("/")
# Build GraphQL query
query = """
query($path: String!, $locale: String!) {
pageByPath(path: $path, locale: $locale) {
id
title
path
content
description
isPublished
isPrivate
tags
locale
authorId
authorName
authorEmail
editor
createdAt
updatedAt
}
}
"""
# Make request
response = await self._post(
"/graphql",
json_data={
"query": query,
"variables": {"path": path, "locale": locale},
},
)
# Parse response
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
page_data = response.get("data", {}).get("pageByPath")
if not page_data:
raise APIError(f"Page with path '{path}' not found")
# Convert to Page object
try:
normalized_data = self._normalize_page_data(page_data)
return Page(**normalized_data)
except Exception as e:
raise APIError(f"Failed to parse page data: {str(e)}") from e
async def create(self, page_data: Union[PageCreate, Dict[str, Any]]) -> Page:
"""Create a new page.
Args:
page_data: Page creation data (PageCreate object or dict)
Returns:
Created Page object
Raises:
APIError: If page creation fails
ValidationError: If page data is invalid
"""
# Convert to PageCreate if needed
if isinstance(page_data, dict):
try:
page_data = PageCreate(**page_data)
except Exception as e:
raise ValidationError(f"Invalid page data: {str(e)}") from e
elif not isinstance(page_data, PageCreate):
raise ValidationError("page_data must be PageCreate object or dict")
# Build GraphQL mutation using actual Wiki.js schema
mutation = """
mutation(
$content: String!,
$description: String!,
$editor: String!,
$isPublished: Boolean!,
$isPrivate: Boolean!,
$locale: String!,
$path: String!,
$tags: [String]!,
$title: String!
) {
pages {
create(
content: $content,
description: $description,
editor: $editor,
isPublished: $isPublished,
isPrivate: $isPrivate,
locale: $locale,
path: $path,
tags: $tags,
title: $title
) {
responseResult {
succeeded
errorCode
slug
message
}
page {
id
title
path
content
description
isPublished
isPrivate
tags {
tag
}
locale
authorId
authorName
authorEmail
editor
createdAt
updatedAt
}
}
}
}
"""
# Build variables from page data
variables = {
"title": page_data.title,
"path": page_data.path,
"content": page_data.content,
"description": page_data.description
or f"Created via SDK: {page_data.title}",
"isPublished": page_data.is_published,
"isPrivate": page_data.is_private,
"tags": page_data.tags,
"locale": page_data.locale,
"editor": page_data.editor,
}
# Make request
response = await self._post(
"/graphql", json_data={"query": mutation, "variables": variables}
)
# Parse response
if "errors" in response:
raise APIError(f"Failed to create page: {response['errors']}")
create_result = response.get("data", {}).get("pages", {}).get("create", {})
response_result = create_result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Page creation failed: {error_msg}")
created_page_data = create_result.get("page")
if not created_page_data:
raise APIError("Page creation failed - no page data returned")
# Convert to Page object
try:
normalized_data = self._normalize_page_data(created_page_data)
return Page(**normalized_data)
except Exception as e:
raise APIError(f"Failed to parse created page data: {str(e)}") from e
async def update(
self, page_id: int, page_data: Union[PageUpdate, Dict[str, Any]]
) -> Page:
"""Update an existing page.
Args:
page_id: The page ID
page_data: Page update data (PageUpdate object or dict)
Returns:
Updated Page object
Raises:
APIError: If page update fails
ValidationError: If parameters are invalid
"""
if not isinstance(page_id, int) or page_id < 1:
raise ValidationError("page_id must be a positive integer")
# Convert to PageUpdate if needed
if isinstance(page_data, dict):
try:
page_data = PageUpdate(**page_data)
except Exception as e:
raise ValidationError(f"Invalid page data: {str(e)}") from e
elif not isinstance(page_data, PageUpdate):
raise ValidationError("page_data must be PageUpdate object or dict")
# Build GraphQL mutation
mutation = """
mutation(
$id: Int!,
$title: String,
$content: String,
$description: String,
$isPublished: Boolean,
$isPrivate: Boolean,
$tags: [String]
) {
updatePage(
id: $id,
title: $title,
content: $content,
description: $description,
isPublished: $isPublished,
isPrivate: $isPrivate,
tags: $tags
) {
id
title
path
content
description
isPublished
isPrivate
tags
locale
authorId
authorName
authorEmail
editor
createdAt
updatedAt
}
}
"""
# Build variables (only include non-None values)
variables: Dict[str, Any] = {"id": page_id}
if page_data.title is not None:
variables["title"] = page_data.title
if page_data.content is not None:
variables["content"] = page_data.content
if page_data.description is not None:
variables["description"] = page_data.description
if page_data.is_published is not None:
variables["isPublished"] = page_data.is_published
if page_data.is_private is not None:
variables["isPrivate"] = page_data.is_private
if page_data.tags is not None:
variables["tags"] = page_data.tags
# Make request
response = await self._post(
"/graphql", json_data={"query": mutation, "variables": variables}
)
# Parse response
if "errors" in response:
raise APIError(f"Failed to update page: {response['errors']}")
updated_page_data = response.get("data", {}).get("updatePage")
if not updated_page_data:
raise APIError("Page update failed - no data returned")
# Convert to Page object
try:
normalized_data = self._normalize_page_data(updated_page_data)
return Page(**normalized_data)
except Exception as e:
raise APIError(f"Failed to parse updated page data: {str(e)}") from e
async def delete(self, page_id: int) -> bool:
"""Delete a page.
Args:
page_id: The page ID
Returns:
True if deletion was successful
Raises:
APIError: If page deletion fails
ValidationError: If page_id is invalid
"""
if not isinstance(page_id, int) or page_id < 1:
raise ValidationError("page_id must be a positive integer")
# Build GraphQL mutation
mutation = """
mutation($id: Int!) {
deletePage(id: $id) {
success
message
}
}
"""
# Make request
response = await self._post(
"/graphql",
json_data={"query": mutation, "variables": {"id": page_id}},
)
# Parse response
if "errors" in response:
raise APIError(f"Failed to delete page: {response['errors']}")
delete_result = response.get("data", {}).get("deletePage", {})
success = delete_result.get("success", False)
if not success:
message = delete_result.get("message", "Unknown error")
raise APIError(f"Page deletion failed: {message}")
return True
async def search(
self,
query: str,
limit: Optional[int] = None,
locale: Optional[str] = None,
) -> List[Page]:
"""Search for pages by content and title.
Args:
query: Search query string
limit: Maximum number of results to return
locale: Locale to search in
Returns:
List of matching Page objects
Raises:
APIError: If search fails
ValidationError: If parameters are invalid
"""
if not query or not isinstance(query, str):
raise ValidationError("query must be a non-empty string")
if limit is not None and limit < 1:
raise ValidationError("limit must be greater than 0")
# Use the list method with search parameter
return await self.list(search=query, limit=limit, locale=locale)
async def get_by_tags(
self,
tags: List[str],
match_all: bool = True,
limit: Optional[int] = None,
) -> List[Page]:
"""Get pages by tags.
Args:
tags: List of tags to search for
match_all: If True, pages must have ALL tags. If False, ANY tag matches
limit: Maximum number of results to return
Returns:
List of matching Page objects
Raises:
APIError: If request fails
ValidationError: If parameters are invalid
"""
if not tags or not isinstance(tags, list):
raise ValidationError("tags must be a non-empty list")
if limit is not None and limit < 1:
raise ValidationError("limit must be greater than 0")
# For match_all=True, use the tags parameter directly
if match_all:
return await self.list(tags=tags, limit=limit)
# For match_all=False, we need a more complex query
# This would require a custom GraphQL query or multiple requests
# For now, implement a simple approach
all_pages = await self.list(
limit=limit * 2 if limit else None
) # Get more pages to filter
matching_pages = []
for page in all_pages:
if any(tag.lower() in [t.lower() for t in page.tags] for tag in tags):
matching_pages.append(page)
if limit and len(matching_pages) >= limit:
break
return matching_pages
def _normalize_page_data(self, page_data: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize page data from API response to model format.
Args:
page_data: Raw page data from API
Returns:
Normalized data for Page model
"""
normalized = {}
# Map API field names to model field names
field_mapping = {
"id": "id",
"title": "title",
"path": "path",
"content": "content",
"description": "description",
"isPublished": "is_published",
"isPrivate": "is_private",
"locale": "locale",
"authorId": "author_id",
"authorName": "author_name",
"authorEmail": "author_email",
"editor": "editor",
"createdAt": "created_at",
"updatedAt": "updated_at",
}
for api_field, model_field in field_mapping.items():
if api_field in page_data:
normalized[model_field] = page_data[api_field]
# Handle tags - convert from Wiki.js format
if "tags" in page_data:
if isinstance(page_data["tags"], list):
# Handle both formats: ["tag1", "tag2"] or [{"tag": "tag1"}]
tags = []
for tag in page_data["tags"]:
if isinstance(tag, dict) and "tag" in tag:
tags.append(tag["tag"])
elif isinstance(tag, str):
tags.append(tag)
normalized["tags"] = tags
else:
normalized["tags"] = []
else:
normalized["tags"] = []
return normalized
async def iter_all(
self,
batch_size: int = 50,
search: Optional[str] = None,
tags: Optional[List[str]] = None,
locale: Optional[str] = None,
author_id: Optional[int] = None,
order_by: str = "title",
order_direction: str = "ASC",
):
"""Iterate over all pages asynchronously with automatic pagination.
Args:
batch_size: Number of pages to fetch per request (default: 50)
search: Search term to filter pages
tags: Filter by tags
locale: Filter by locale
author_id: Filter by author ID
order_by: Field to sort by
order_direction: Sort direction (ASC or DESC)
Yields:
Page objects one at a time
Example:
>>> async for page in client.pages.iter_all():
... print(f"{page.title}: {page.path}")
"""
offset = 0
while True:
batch = await self.list(
limit=batch_size,
offset=offset,
search=search,
tags=tags,
locale=locale,
author_id=author_id,
order_by=order_by,
order_direction=order_direction,
)
if not batch:
break
for page in batch:
yield page
if len(batch) < batch_size:
break
offset += batch_size

View File

@@ -0,0 +1,617 @@
"""Async Users API endpoint for wikijs-python-sdk."""
from typing import Any, Dict, List, Optional, Union
from ...exceptions import APIError, ValidationError
from ...models.user import User, UserCreate, UserUpdate
from .base import AsyncBaseEndpoint
class AsyncUsersEndpoint(AsyncBaseEndpoint):
"""Async endpoint for Wiki.js Users API operations.
This endpoint provides async methods for creating, reading, updating, and
deleting users through the Wiki.js GraphQL API.
Example:
>>> async with AsyncWikiJSClient('https://wiki.example.com', auth='key') as client:
... users = client.users
...
... # List all users
... all_users = await users.list()
...
... # Get a specific user
... user = await users.get(123)
...
... # Create a new user
... new_user_data = UserCreate(
... email="user@example.com",
... name="John Doe",
... password_raw="secure_password"
... )
... created_user = await users.create(new_user_data)
...
... # Update an existing user
... update_data = UserUpdate(name="Jane Doe")
... updated_user = await users.update(123, update_data)
...
... # Delete a user
... await users.delete(123)
"""
async def list(
self,
limit: Optional[int] = None,
offset: Optional[int] = None,
search: Optional[str] = None,
order_by: str = "name",
order_direction: str = "ASC",
) -> List[User]:
"""List users with optional filtering.
Args:
limit: Maximum number of users to return
offset: Number of users to skip
search: Search term to filter users
order_by: Field to order by (name, email, createdAt)
order_direction: Order direction (ASC or DESC)
Returns:
List of User objects
Raises:
APIError: If the API request fails
ValidationError: If parameters are invalid
"""
# Validate parameters
if limit is not None and limit < 1:
raise ValidationError("limit must be greater than 0")
if offset is not None and offset < 0:
raise ValidationError("offset must be non-negative")
if order_by not in ["name", "email", "createdAt", "lastLoginAt"]:
raise ValidationError(
"order_by must be one of: name, email, createdAt, lastLoginAt"
)
if order_direction not in ["ASC", "DESC"]:
raise ValidationError("order_direction must be ASC or DESC")
# Build GraphQL query
query = """
query($filter: String, $orderBy: String) {
users {
list(filter: $filter, orderBy: $orderBy) {
id
name
email
providerKey
isSystem
isActive
isVerified
location
jobTitle
timezone
createdAt
updatedAt
lastLoginAt
}
}
}
"""
# Build variables
variables: Dict[str, Any] = {}
if search:
variables["filter"] = search
if order_by:
# Wiki.js expects format like "name ASC"
variables["orderBy"] = f"{order_by} {order_direction}"
# Make request
response = await self._post(
"/graphql",
json_data=(
{"query": query, "variables": variables}
if variables
else {"query": query}
),
)
# Parse response
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
users_data = response.get("data", {}).get("users", {}).get("list", [])
# Apply client-side pagination if needed
if offset:
users_data = users_data[offset:]
if limit:
users_data = users_data[:limit]
# Convert to User objects
users = []
for user_data in users_data:
try:
normalized_data = self._normalize_user_data(user_data)
user = User(**normalized_data)
users.append(user)
except Exception as e:
raise APIError(f"Failed to parse user data: {str(e)}") from e
return users
async def get(self, user_id: int) -> User:
"""Get a specific user by ID.
Args:
user_id: The user ID
Returns:
User object
Raises:
APIError: If the user is not found or request fails
ValidationError: If user_id is invalid
"""
if not isinstance(user_id, int) or user_id < 1:
raise ValidationError("user_id must be a positive integer")
# Build GraphQL query
query = """
query($id: Int!) {
users {
single(id: $id) {
id
name
email
providerKey
isSystem
isActive
isVerified
location
jobTitle
timezone
groups {
id
name
}
createdAt
updatedAt
lastLoginAt
}
}
}
"""
# Make request
response = await self._post(
"/graphql",
json_data={"query": query, "variables": {"id": user_id}},
)
# Parse response
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
user_data = response.get("data", {}).get("users", {}).get("single")
if not user_data:
raise APIError(f"User with ID {user_id} not found")
# Convert to User object
try:
normalized_data = self._normalize_user_data(user_data)
return User(**normalized_data)
except Exception as e:
raise APIError(f"Failed to parse user data: {str(e)}") from e
async def create(self, user_data: Union[UserCreate, Dict[str, Any]]) -> User:
"""Create a new user.
Args:
user_data: User creation data (UserCreate object or dict)
Returns:
Created User object
Raises:
APIError: If user creation fails
ValidationError: If user data is invalid
"""
# Convert to UserCreate if needed
if isinstance(user_data, dict):
try:
user_data = UserCreate(**user_data)
except Exception as e:
raise ValidationError(f"Invalid user data: {str(e)}") from e
elif not isinstance(user_data, UserCreate):
raise ValidationError("user_data must be UserCreate object or dict")
# Build GraphQL mutation
mutation = """
mutation(
$email: String!,
$name: String!,
$passwordRaw: String!,
$providerKey: String!,
$groups: [Int]!,
$mustChangePassword: Boolean!,
$sendWelcomeEmail: Boolean!,
$location: String,
$jobTitle: String,
$timezone: String
) {
users {
create(
email: $email,
name: $name,
passwordRaw: $passwordRaw,
providerKey: $providerKey,
groups: $groups,
mustChangePassword: $mustChangePassword,
sendWelcomeEmail: $sendWelcomeEmail,
location: $location,
jobTitle: $jobTitle,
timezone: $timezone
) {
responseResult {
succeeded
errorCode
slug
message
}
user {
id
name
email
providerKey
isSystem
isActive
isVerified
location
jobTitle
timezone
createdAt
updatedAt
}
}
}
}
"""
# Build variables
variables = {
"email": user_data.email,
"name": user_data.name,
"passwordRaw": user_data.password_raw,
"providerKey": user_data.provider_key,
"groups": user_data.groups,
"mustChangePassword": user_data.must_change_password,
"sendWelcomeEmail": user_data.send_welcome_email,
"location": user_data.location,
"jobTitle": user_data.job_title,
"timezone": user_data.timezone,
}
# Make request
response = await self._post(
"/graphql", json_data={"query": mutation, "variables": variables}
)
# Parse response
if "errors" in response:
raise APIError(f"Failed to create user: {response['errors']}")
create_result = response.get("data", {}).get("users", {}).get("create", {})
response_result = create_result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"User creation failed: {error_msg}")
created_user_data = create_result.get("user")
if not created_user_data:
raise APIError("User creation failed - no user data returned")
# Convert to User object
try:
normalized_data = self._normalize_user_data(created_user_data)
return User(**normalized_data)
except Exception as e:
raise APIError(f"Failed to parse created user data: {str(e)}") from e
async def update(
self, user_id: int, user_data: Union[UserUpdate, Dict[str, Any]]
) -> User:
"""Update an existing user.
Args:
user_id: The user ID
user_data: User update data (UserUpdate object or dict)
Returns:
Updated User object
Raises:
APIError: If user update fails
ValidationError: If parameters are invalid
"""
if not isinstance(user_id, int) or user_id < 1:
raise ValidationError("user_id must be a positive integer")
# Convert to UserUpdate if needed
if isinstance(user_data, dict):
try:
user_data = UserUpdate(**user_data)
except Exception as e:
raise ValidationError(f"Invalid user data: {str(e)}") from e
elif not isinstance(user_data, UserUpdate):
raise ValidationError("user_data must be UserUpdate object or dict")
# Build GraphQL mutation
mutation = """
mutation(
$id: Int!,
$email: String,
$name: String,
$passwordRaw: String,
$location: String,
$jobTitle: String,
$timezone: String,
$groups: [Int],
$isActive: Boolean,
$isVerified: Boolean
) {
users {
update(
id: $id,
email: $email,
name: $name,
passwordRaw: $passwordRaw,
location: $location,
jobTitle: $jobTitle,
timezone: $timezone,
groups: $groups,
isActive: $isActive,
isVerified: $isVerified
) {
responseResult {
succeeded
errorCode
slug
message
}
user {
id
name
email
providerKey
isSystem
isActive
isVerified
location
jobTitle
timezone
createdAt
updatedAt
}
}
}
}
"""
# Build variables (only include non-None values)
variables: Dict[str, Any] = {"id": user_id}
if user_data.name is not None:
variables["name"] = user_data.name
if user_data.email is not None:
variables["email"] = str(user_data.email)
if user_data.password_raw is not None:
variables["passwordRaw"] = user_data.password_raw
if user_data.location is not None:
variables["location"] = user_data.location
if user_data.job_title is not None:
variables["jobTitle"] = user_data.job_title
if user_data.timezone is not None:
variables["timezone"] = user_data.timezone
if user_data.groups is not None:
variables["groups"] = user_data.groups
if user_data.is_active is not None:
variables["isActive"] = user_data.is_active
if user_data.is_verified is not None:
variables["isVerified"] = user_data.is_verified
# Make request
response = await self._post(
"/graphql", json_data={"query": mutation, "variables": variables}
)
# Parse response
if "errors" in response:
raise APIError(f"Failed to update user: {response['errors']}")
update_result = response.get("data", {}).get("users", {}).get("update", {})
response_result = update_result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"User update failed: {error_msg}")
updated_user_data = update_result.get("user")
if not updated_user_data:
raise APIError("User update failed - no user data returned")
# Convert to User object
try:
normalized_data = self._normalize_user_data(updated_user_data)
return User(**normalized_data)
except Exception as e:
raise APIError(f"Failed to parse updated user data: {str(e)}") from e
async def delete(self, user_id: int) -> bool:
"""Delete a user.
Args:
user_id: The user ID
Returns:
True if deletion was successful
Raises:
APIError: If user deletion fails
ValidationError: If user_id is invalid
"""
if not isinstance(user_id, int) or user_id < 1:
raise ValidationError("user_id must be a positive integer")
# Build GraphQL mutation
mutation = """
mutation($id: Int!) {
users {
delete(id: $id) {
responseResult {
succeeded
errorCode
slug
message
}
}
}
}
"""
# Make request
response = await self._post(
"/graphql",
json_data={"query": mutation, "variables": {"id": user_id}},
)
# Parse response
if "errors" in response:
raise APIError(f"Failed to delete user: {response['errors']}")
delete_result = response.get("data", {}).get("users", {}).get("delete", {})
response_result = delete_result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"User deletion failed: {error_msg}")
return True
async def search(self, query: str, limit: Optional[int] = None) -> List[User]:
"""Search for users by name or email.
Args:
query: Search query string
limit: Maximum number of results to return
Returns:
List of matching User objects
Raises:
APIError: If search fails
ValidationError: If parameters are invalid
"""
if not query or not isinstance(query, str):
raise ValidationError("query must be a non-empty string")
if limit is not None and limit < 1:
raise ValidationError("limit must be greater than 0")
# Use the list method with search parameter
return await self.list(search=query, limit=limit)
def _normalize_user_data(self, user_data: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize user data from API response to model format.
Args:
user_data: Raw user data from API
Returns:
Normalized data for User model
"""
normalized = {}
# Map API field names to model field names
field_mapping = {
"id": "id",
"name": "name",
"email": "email",
"providerKey": "provider_key",
"isSystem": "is_system",
"isActive": "is_active",
"isVerified": "is_verified",
"location": "location",
"jobTitle": "job_title",
"timezone": "timezone",
"createdAt": "created_at",
"updatedAt": "updated_at",
"lastLoginAt": "last_login_at",
}
for api_field, model_field in field_mapping.items():
if api_field in user_data:
normalized[model_field] = user_data[api_field]
# Handle groups - convert from API format
if "groups" in user_data:
if isinstance(user_data["groups"], list):
# Convert each group dict to proper format
normalized["groups"] = [
{"id": g["id"], "name": g["name"]}
for g in user_data["groups"]
if isinstance(g, dict)
]
else:
normalized["groups"] = []
else:
normalized["groups"] = []
return normalized
async def iter_all(
self,
batch_size: int = 50,
search: Optional[str] = None,
order_by: str = "name",
order_direction: str = "ASC",
):
"""Iterate over all users asynchronously with automatic pagination.
Args:
batch_size: Number of users to fetch per request (default: 50)
search: Search term to filter users
order_by: Field to sort by
order_direction: Sort direction (ASC or DESC)
Yields:
User objects one at a time
Example:
>>> async for user in client.users.iter_all():
... print(f"{user.name} ({user.email})")
"""
offset = 0
while True:
batch = await self.list(
limit=batch_size,
offset=offset,
search=search,
order_by=order_by,
order_direction=order_direction,
)
if not batch:
break
for user in batch:
yield user
if len(batch) < batch_size:
break
offset += batch_size

View File

@@ -8,7 +8,7 @@ from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from .auth import APIKeyAuth, AuthHandler
from .endpoints import PagesEndpoint
from .endpoints import AssetsEndpoint, GroupsEndpoint, PagesEndpoint, UsersEndpoint
from .exceptions import (
APIError,
AuthenticationError,
@@ -90,9 +90,9 @@ class WikiJSClient:
# Endpoint handlers
self.pages = PagesEndpoint(self)
# Future endpoints:
# self.users = UsersEndpoint(self)
# self.groups = GroupsEndpoint(self)
self.users = UsersEndpoint(self)
self.groups = GroupsEndpoint(self)
self.assets = AssetsEndpoint(self)
def _create_session(self) -> requests.Session:
"""Create configured HTTP session with retry strategy.

View File

@@ -5,18 +5,24 @@ Wiki.js API endpoints.
Implemented:
- Pages API (CRUD operations) ✅
- Users API (user management) ✅
- Groups API (group management) ✅
- Assets API (file/asset management) ✅
Future implementations:
- Users API (user management)
- Groups API (group management)
- Assets API (file management)
- System API (system information)
"""
from .assets import AssetsEndpoint
from .base import BaseEndpoint
from .groups import GroupsEndpoint
from .pages import PagesEndpoint
from .users import UsersEndpoint
__all__ = [
"AssetsEndpoint",
"BaseEndpoint",
"GroupsEndpoint",
"PagesEndpoint",
"UsersEndpoint",
]

699
wikijs/endpoints/assets.py Normal file
View File

@@ -0,0 +1,699 @@
"""Assets endpoint for Wiki.js API."""
import os
from typing import BinaryIO, Dict, List, Optional, Union
from ..exceptions import APIError, ValidationError
from ..models import Asset, AssetFolder, AssetMove, AssetRename, FolderCreate
from .base import BaseEndpoint
class AssetsEndpoint(BaseEndpoint):
"""Endpoint for managing Wiki.js assets.
Provides methods to:
- List assets
- Get asset details
- Upload files
- Download files
- Rename assets
- Move assets between folders
- Delete assets
- Manage folders
"""
def list(
self, folder_id: Optional[int] = None, kind: Optional[str] = None
) -> List[Asset]:
"""List all assets, optionally filtered by folder or kind.
Args:
folder_id: Filter by folder ID (None for all folders)
kind: Filter by asset kind (image, binary, etc.)
Returns:
List of Asset objects
Raises:
ValidationError: If parameters are invalid
APIError: If the API request fails
Example:
>>> assets = client.assets.list()
>>> images = client.assets.list(kind="image")
>>> folder_assets = client.assets.list(folder_id=1)
"""
# Validate folder_id
if folder_id is not None and folder_id < 0:
raise ValidationError("folder_id must be non-negative")
query = """
query ($folderId: Int, $kind: AssetKind) {
assets {
list(folderId: $folderId, kind: $kind) {
id
filename
ext
kind
mime
fileSize
folderId
folder {
id
slug
name
}
authorId
authorName
createdAt
updatedAt
}
}
}
"""
variables = {}
if folder_id is not None:
variables["folderId"] = folder_id
if kind is not None:
variables["kind"] = kind.upper()
response = self._post(
"/graphql", json_data={"query": query, "variables": variables}
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Extract and normalize assets
assets_data = response.get("data", {}).get("assets", {}).get("list", [])
return [Asset(**self._normalize_asset_data(a)) for a in assets_data]
def get(self, asset_id: int) -> Asset:
"""Get a specific asset by ID.
Args:
asset_id: The asset ID
Returns:
Asset object
Raises:
ValidationError: If asset_id is invalid
APIError: If the asset is not found or API request fails
Example:
>>> asset = client.assets.get(123)
>>> print(f"{asset.filename}: {asset.size_mb:.2f} MB")
"""
# Validate asset_id
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
query = """
query ($id: Int!) {
assets {
single(id: $id) {
id
filename
ext
kind
mime
fileSize
folderId
folder {
id
slug
name
}
authorId
authorName
createdAt
updatedAt
}
}
}
"""
response = self._post(
"/graphql", json_data={"query": query, "variables": {"id": asset_id}}
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Extract asset data
asset_data = response.get("data", {}).get("assets", {}).get("single")
if not asset_data:
raise APIError(f"Asset with ID {asset_id} not found")
return Asset(**self._normalize_asset_data(asset_data))
def upload(
self,
file_path: str,
folder_id: int = 0,
filename: Optional[str] = None,
) -> Asset:
"""Upload a file as an asset.
Args:
file_path: Path to local file to upload
folder_id: Target folder ID (default: 0 for root)
filename: Optional custom filename (uses original if not provided)
Returns:
Created Asset object
Raises:
ValidationError: If file_path is invalid
APIError: If the upload fails
FileNotFoundError: If file doesn't exist
Example:
>>> asset = client.assets.upload("/path/to/image.png", folder_id=1)
>>> print(f"Uploaded: {asset.filename}")
"""
# Validate file path
if not file_path or not file_path.strip():
raise ValidationError("file_path cannot be empty")
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
if not os.path.isfile(file_path):
raise ValidationError(f"Path is not a file: {file_path}")
# Validate folder_id
if folder_id < 0:
raise ValidationError("folder_id must be non-negative")
# Get filename
if filename is None:
filename = os.path.basename(file_path)
# For now, use GraphQL mutation
# Note: Wiki.js may require multipart form upload which would need special handling
mutation = """
mutation ($folderId: Int!, $file: Upload!) {
assets {
createFile(folderId: $folderId, file: $file) {
responseResult {
succeeded
errorCode
slug
message
}
asset {
id
filename
ext
kind
mime
fileSize
folderId
authorId
authorName
createdAt
updatedAt
}
}
}
}
"""
# Note: Actual file upload would require multipart/form-data
# This is a simplified version
raise NotImplementedError(
"File upload requires multipart form support. "
"Use the Wiki.js web interface or REST API directly for file uploads."
)
def download(self, asset_id: int, output_path: str) -> bool:
"""Download an asset to a local file.
Args:
asset_id: The asset ID
output_path: Local path to save the file
Returns:
True if download successful
Raises:
ValidationError: If parameters are invalid
APIError: If the download fails
Example:
>>> client.assets.download(123, "/path/to/save/file.png")
"""
# Validate asset_id
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
if not output_path or not output_path.strip():
raise ValidationError("output_path cannot be empty")
# Note: Downloading requires REST API endpoint, not GraphQL
raise NotImplementedError(
"File download requires REST API support. "
"Use the Wiki.js REST API directly: GET /a/{assetId}"
)
def rename(self, asset_id: int, new_filename: str) -> Asset:
"""Rename an asset.
Args:
asset_id: The asset ID
new_filename: New filename
Returns:
Updated Asset object
Raises:
ValidationError: If parameters are invalid
APIError: If the rename fails
Example:
>>> asset = client.assets.rename(123, "new-name.png")
"""
# Validate
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
if not new_filename or not new_filename.strip():
raise ValidationError("new_filename cannot be empty")
mutation = """
mutation ($id: Int!, $filename: String!) {
assets {
renameAsset(id: $id, filename: $filename) {
responseResult {
succeeded
errorCode
slug
message
}
asset {
id
filename
ext
kind
mime
fileSize
folderId
authorId
authorName
createdAt
updatedAt
}
}
}
}
"""
response = self._post(
"/graphql",
json_data={
"query": mutation,
"variables": {"id": asset_id, "filename": new_filename.strip()},
},
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Check response result
result = response.get("data", {}).get("assets", {}).get("renameAsset", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to rename asset: {error_msg}")
# Extract and return updated asset
asset_data = result.get("asset")
if not asset_data:
raise APIError("Asset renamed but no data returned")
return Asset(**self._normalize_asset_data(asset_data))
def move(self, asset_id: int, folder_id: int) -> Asset:
"""Move an asset to a different folder.
Args:
asset_id: The asset ID
folder_id: Target folder ID
Returns:
Updated Asset object
Raises:
ValidationError: If parameters are invalid
APIError: If the move fails
Example:
>>> asset = client.assets.move(123, folder_id=2)
"""
# Validate
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
if not isinstance(folder_id, int) or folder_id < 0:
raise ValidationError("folder_id must be non-negative")
mutation = """
mutation ($id: Int!, $folderId: Int!) {
assets {
moveAsset(id: $id, folderId: $folderId) {
responseResult {
succeeded
errorCode
slug
message
}
asset {
id
filename
ext
kind
mime
fileSize
folderId
folder {
id
slug
name
}
authorId
authorName
createdAt
updatedAt
}
}
}
}
"""
response = self._post(
"/graphql",
json_data={
"query": mutation,
"variables": {"id": asset_id, "folderId": folder_id},
},
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Check response result
result = response.get("data", {}).get("assets", {}).get("moveAsset", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to move asset: {error_msg}")
# Extract and return updated asset
asset_data = result.get("asset")
if not asset_data:
raise APIError("Asset moved but no data returned")
return Asset(**self._normalize_asset_data(asset_data))
def delete(self, asset_id: int) -> bool:
"""Delete an asset.
Args:
asset_id: The asset ID
Returns:
True if deletion was successful
Raises:
ValidationError: If asset_id is invalid
APIError: If the deletion fails
Example:
>>> success = client.assets.delete(123)
"""
# Validate asset_id
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
mutation = """
mutation ($id: Int!) {
assets {
deleteAsset(id: $id) {
responseResult {
succeeded
errorCode
slug
message
}
}
}
}
"""
response = self._post(
"/graphql", json_data={"query": mutation, "variables": {"id": asset_id}}
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Check response result
result = response.get("data", {}).get("assets", {}).get("deleteAsset", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to delete asset: {error_msg}")
return True
def list_folders(self) -> List[AssetFolder]:
"""List all asset folders.
Returns:
List of AssetFolder objects
Raises:
APIError: If the API request fails
Example:
>>> folders = client.assets.list_folders()
>>> for folder in folders:
... print(f"{folder.name}: {folder.slug}")
"""
query = """
query {
assets {
folders {
id
slug
name
}
}
}
"""
response = self._post("/graphql", json_data={"query": query})
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Extract folders
folders_data = response.get("data", {}).get("assets", {}).get("folders", [])
return [AssetFolder(**folder) for folder in folders_data]
def create_folder(self, slug: str, name: Optional[str] = None) -> AssetFolder:
"""Create a new asset folder.
Args:
slug: Folder slug/path
name: Optional folder name
Returns:
Created AssetFolder object
Raises:
ValidationError: If slug is invalid
APIError: If folder creation fails
Example:
>>> folder = client.assets.create_folder("documents", "Documents")
"""
# Validate
if not slug or not slug.strip():
raise ValidationError("slug cannot be empty")
# Clean slug
slug = slug.strip().strip("/")
if not slug:
raise ValidationError("slug cannot be just slashes")
mutation = """
mutation ($slug: String!, $name: String) {
assets {
createFolder(slug: $slug, name: $name) {
responseResult {
succeeded
errorCode
slug
message
}
folder {
id
slug
name
}
}
}
}
"""
variables = {"slug": slug}
if name:
variables["name"] = name
response = self._post(
"/graphql", json_data={"query": mutation, "variables": variables}
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Check response result
result = response.get("data", {}).get("assets", {}).get("createFolder", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to create folder: {error_msg}")
# Extract and return folder
folder_data = result.get("folder")
if not folder_data:
raise APIError("Folder created but no data returned")
return AssetFolder(**folder_data)
def delete_folder(self, folder_id: int) -> bool:
"""Delete an asset folder.
Args:
folder_id: The folder ID
Returns:
True if deletion was successful
Raises:
ValidationError: If folder_id is invalid
APIError: If the deletion fails
Example:
>>> success = client.assets.delete_folder(5)
"""
# Validate folder_id
if not isinstance(folder_id, int) or folder_id <= 0:
raise ValidationError("folder_id must be a positive integer")
mutation = """
mutation ($id: Int!) {
assets {
deleteFolder(id: $id) {
responseResult {
succeeded
errorCode
slug
message
}
}
}
}
"""
response = self._post(
"/graphql", json_data={"query": mutation, "variables": {"id": folder_id}}
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Check response result
result = response.get("data", {}).get("assets", {}).get("deleteFolder", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to delete folder: {error_msg}")
return True
def _normalize_asset_data(self, data: Dict) -> Dict:
"""Normalize asset data from API response to Python naming convention.
Args:
data: Raw asset data from API
Returns:
Normalized asset data with snake_case field names
"""
normalized = {
"id": data.get("id"),
"filename": data.get("filename"),
"ext": data.get("ext"),
"kind": data.get("kind"),
"mime": data.get("mime"),
"file_size": data.get("fileSize"),
"folder_id": data.get("folderId"),
"folder": data.get("folder"),
"author_id": data.get("authorId"),
"author_name": data.get("authorName"),
"created_at": data.get("createdAt"),
"updated_at": data.get("updatedAt"),
}
return normalized
def iter_all(
self,
batch_size: int = 50,
folder_id: Optional[int] = None,
kind: Optional[str] = None,
):
"""Iterate over all assets with automatic pagination.
Note: Assets API returns all matching assets at once, but this
method provides a consistent interface and can limit memory usage
for very large asset collections.
Args:
batch_size: Batch size for iteration (default: 50)
folder_id: Filter by folder ID
kind: Filter by asset kind
Yields:
Asset objects one at a time
Example:
>>> for asset in client.assets.iter_all(kind="image"):
... print(f"{asset.filename}: {asset.size_mb:.2f} MB")
"""
assets = self.list(folder_id=folder_id, kind=kind)
# Yield in batches to limit memory usage
for i in range(0, len(assets), batch_size):
batch = assets[i : i + batch_size]
for asset in batch:
yield asset

565
wikijs/endpoints/groups.py Normal file
View File

@@ -0,0 +1,565 @@
"""Groups endpoint for Wiki.js API."""
from typing import Dict, List, Union
from ..exceptions import APIError, ValidationError
from ..models import Group, GroupCreate, GroupUpdate
from .base import BaseEndpoint
class GroupsEndpoint(BaseEndpoint):
"""Endpoint for managing Wiki.js groups.
Provides methods to:
- List all groups
- Get a specific group by ID
- Create new groups
- Update existing groups
- Delete groups
- Assign users to groups
- Remove users from groups
"""
def list(self) -> List[Group]:
"""List all groups.
Returns:
List of Group objects
Raises:
APIError: If the API request fails
Example:
>>> groups = client.groups.list()
>>> for group in groups:
... print(f"{group.name}: {len(group.users)} users")
"""
query = """
query {
groups {
list {
id
name
isSystem
redirectOnLogin
permissions
pageRules {
id
path
roles
match
deny
locales
}
users {
id
name
email
}
createdAt
updatedAt
}
}
}
"""
response = self._post("/graphql", json_data={"query": query})
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Extract and normalize groups
groups_data = response.get("data", {}).get("groups", {}).get("list", [])
return [Group(**self._normalize_group_data(g)) for g in groups_data]
def get(self, group_id: int) -> Group:
"""Get a specific group by ID.
Args:
group_id: The group ID
Returns:
Group object with user list
Raises:
ValidationError: If group_id is invalid
APIError: If the group is not found or API request fails
Example:
>>> group = client.groups.get(1)
>>> print(f"{group.name}: {group.permissions}")
"""
# Validate group_id
if not isinstance(group_id, int) or group_id <= 0:
raise ValidationError("group_id must be a positive integer")
query = """
query ($id: Int!) {
groups {
single(id: $id) {
id
name
isSystem
redirectOnLogin
permissions
pageRules {
id
path
roles
match
deny
locales
}
users {
id
name
email
}
createdAt
updatedAt
}
}
}
"""
response = self._post(
"/graphql", json_data={"query": query, "variables": {"id": group_id}}
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Extract group data
group_data = response.get("data", {}).get("groups", {}).get("single")
if not group_data:
raise APIError(f"Group with ID {group_id} not found")
return Group(**self._normalize_group_data(group_data))
def create(self, group_data: Union[GroupCreate, Dict]) -> Group:
"""Create a new group.
Args:
group_data: GroupCreate object or dict with group data
Returns:
Created Group object
Raises:
ValidationError: If group data is invalid
APIError: If the API request fails
Example:
>>> from wikijs.models import GroupCreate
>>> group_data = GroupCreate(
... name="Editors",
... permissions=["read:pages", "write:pages"]
... )
>>> group = client.groups.create(group_data)
"""
# Validate and convert to dict
if isinstance(group_data, dict):
try:
group_data = GroupCreate(**group_data)
except Exception as e:
raise ValidationError(f"Invalid group data: {e}")
elif not isinstance(group_data, GroupCreate):
raise ValidationError("group_data must be a GroupCreate object or dict")
# Build mutation
mutation = """
mutation ($name: String!, $redirectOnLogin: String, $permissions: [String]!, $pageRules: [PageRuleInput]!) {
groups {
create(
name: $name
redirectOnLogin: $redirectOnLogin
permissions: $permissions
pageRules: $pageRules
) {
responseResult {
succeeded
errorCode
slug
message
}
group {
id
name
isSystem
redirectOnLogin
permissions
pageRules {
id
path
roles
match
deny
locales
}
createdAt
updatedAt
}
}
}
}
"""
variables = {
"name": group_data.name,
"redirectOnLogin": group_data.redirect_on_login or "/",
"permissions": group_data.permissions,
"pageRules": group_data.page_rules,
}
response = self._post(
"/graphql", json_data={"query": mutation, "variables": variables}
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Check response result
result = response.get("data", {}).get("groups", {}).get("create", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to create group: {error_msg}")
# Extract and return created group
group_data = result.get("group")
if not group_data:
raise APIError("Group created but no data returned")
return Group(**self._normalize_group_data(group_data))
def update(self, group_id: int, group_data: Union[GroupUpdate, Dict]) -> Group:
"""Update an existing group.
Args:
group_id: The group ID
group_data: GroupUpdate object or dict with fields to update
Returns:
Updated Group object
Raises:
ValidationError: If group_id or group_data is invalid
APIError: If the API request fails
Example:
>>> from wikijs.models import GroupUpdate
>>> update_data = GroupUpdate(
... name="Senior Editors",
... permissions=["read:pages", "write:pages", "delete:pages"]
... )
>>> group = client.groups.update(1, update_data)
"""
# Validate group_id
if not isinstance(group_id, int) or group_id <= 0:
raise ValidationError("group_id must be a positive integer")
# Validate and convert to dict
if isinstance(group_data, dict):
try:
group_data = GroupUpdate(**group_data)
except Exception as e:
raise ValidationError(f"Invalid group data: {e}")
elif not isinstance(group_data, GroupUpdate):
raise ValidationError("group_data must be a GroupUpdate object or dict")
# Build mutation with only non-None fields
mutation = """
mutation ($id: Int!, $name: String, $redirectOnLogin: String, $permissions: [String], $pageRules: [PageRuleInput]) {
groups {
update(
id: $id
name: $name
redirectOnLogin: $redirectOnLogin
permissions: $permissions
pageRules: $pageRules
) {
responseResult {
succeeded
errorCode
slug
message
}
group {
id
name
isSystem
redirectOnLogin
permissions
pageRules {
id
path
roles
match
deny
locales
}
createdAt
updatedAt
}
}
}
}
"""
variables = {"id": group_id}
# Add only non-None fields to variables
if group_data.name is not None:
variables["name"] = group_data.name
if group_data.redirect_on_login is not None:
variables["redirectOnLogin"] = group_data.redirect_on_login
if group_data.permissions is not None:
variables["permissions"] = group_data.permissions
if group_data.page_rules is not None:
variables["pageRules"] = group_data.page_rules
response = self._post(
"/graphql", json_data={"query": mutation, "variables": variables}
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Check response result
result = response.get("data", {}).get("groups", {}).get("update", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to update group: {error_msg}")
# Extract and return updated group
group_data_response = result.get("group")
if not group_data_response:
raise APIError("Group updated but no data returned")
return Group(**self._normalize_group_data(group_data_response))
def delete(self, group_id: int) -> bool:
"""Delete a group.
Args:
group_id: The group ID
Returns:
True if deletion was successful
Raises:
ValidationError: If group_id is invalid
APIError: If the API request fails
Example:
>>> success = client.groups.delete(5)
>>> if success:
... print("Group deleted")
"""
# Validate group_id
if not isinstance(group_id, int) or group_id <= 0:
raise ValidationError("group_id must be a positive integer")
mutation = """
mutation ($id: Int!) {
groups {
delete(id: $id) {
responseResult {
succeeded
errorCode
slug
message
}
}
}
}
"""
response = self._post(
"/graphql", json_data={"query": mutation, "variables": {"id": group_id}}
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Check response result
result = response.get("data", {}).get("groups", {}).get("delete", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to delete group: {error_msg}")
return True
def assign_user(self, group_id: int, user_id: int) -> bool:
"""Assign a user to a group.
Args:
group_id: The group ID
user_id: The user ID
Returns:
True if assignment was successful
Raises:
ValidationError: If group_id or user_id is invalid
APIError: If the API request fails
Example:
>>> success = client.groups.assign_user(group_id=1, user_id=5)
>>> if success:
... print("User assigned to group")
"""
# Validate IDs
if not isinstance(group_id, int) or group_id <= 0:
raise ValidationError("group_id must be a positive integer")
if not isinstance(user_id, int) or user_id <= 0:
raise ValidationError("user_id must be a positive integer")
mutation = """
mutation ($groupId: Int!, $userId: Int!) {
groups {
assignUser(groupId: $groupId, userId: $userId) {
responseResult {
succeeded
errorCode
slug
message
}
}
}
}
"""
response = self._post(
"/graphql",
json_data={
"query": mutation,
"variables": {"groupId": group_id, "userId": user_id},
},
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Check response result
result = response.get("data", {}).get("groups", {}).get("assignUser", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to assign user to group: {error_msg}")
return True
def unassign_user(self, group_id: int, user_id: int) -> bool:
"""Remove a user from a group.
Args:
group_id: The group ID
user_id: The user ID
Returns:
True if removal was successful
Raises:
ValidationError: If group_id or user_id is invalid
APIError: If the API request fails
Example:
>>> success = client.groups.unassign_user(group_id=1, user_id=5)
>>> if success:
... print("User removed from group")
"""
# Validate IDs
if not isinstance(group_id, int) or group_id <= 0:
raise ValidationError("group_id must be a positive integer")
if not isinstance(user_id, int) or user_id <= 0:
raise ValidationError("user_id must be a positive integer")
mutation = """
mutation ($groupId: Int!, $userId: Int!) {
groups {
unassignUser(groupId: $groupId, userId: $userId) {
responseResult {
succeeded
errorCode
slug
message
}
}
}
}
"""
response = self._post(
"/graphql",
json_data={
"query": mutation,
"variables": {"groupId": group_id, "userId": user_id},
},
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Check response result
result = response.get("data", {}).get("groups", {}).get("unassignUser", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to remove user from group: {error_msg}")
return True
def _normalize_group_data(self, data: Dict) -> Dict:
"""Normalize group data from API response to Python naming convention.
Args:
data: Raw group data from API
Returns:
Normalized group data with snake_case field names
"""
normalized = {
"id": data.get("id"),
"name": data.get("name"),
"is_system": data.get("isSystem", False),
"redirect_on_login": data.get("redirectOnLogin"),
"permissions": data.get("permissions", []),
"page_rules": data.get("pageRules", []),
"users": data.get("users", []),
"created_at": data.get("createdAt"),
"updated_at": data.get("updatedAt"),
}
return normalized
def iter_all(self):
"""Iterate over all groups.
Note: Groups API returns all groups at once, so this is equivalent
to iterating over list().
Yields:
Group objects one at a time
Example:
>>> for group in client.groups.iter_all():
... print(f"{group.name}: {len(group.users)} users")
"""
for group in self.list():
yield group

View File

@@ -676,3 +676,62 @@ class PagesEndpoint(BaseEndpoint):
normalized["tags"] = []
return normalized
def iter_all(
self,
batch_size: int = 50,
search: Optional[str] = None,
tags: Optional[List[str]] = None,
locale: Optional[str] = None,
author_id: Optional[int] = None,
order_by: str = "title",
order_direction: str = "ASC",
):
"""Iterate over all pages with automatic pagination.
This method automatically handles pagination, fetching pages in batches
and yielding them one at a time.
Args:
batch_size: Number of pages to fetch per request (default: 50)
search: Search term to filter pages
tags: Filter by tags
locale: Filter by locale
author_id: Filter by author ID
order_by: Field to sort by
order_direction: Sort direction (ASC or DESC)
Yields:
Page objects one at a time
Example:
>>> for page in client.pages.iter_all():
... print(f"{page.title}: {page.path}")
>>>
>>> # With filtering
>>> for page in client.pages.iter_all(search="api", batch_size=100):
... print(page.title)
"""
offset = 0
while True:
batch = self.list(
limit=batch_size,
offset=offset,
search=search,
tags=tags,
locale=locale,
author_id=author_id,
order_by=order_by,
order_direction=order_direction,
)
if not batch:
break
for page in batch:
yield page
if len(batch) < batch_size:
break
offset += batch_size

617
wikijs/endpoints/users.py Normal file
View File

@@ -0,0 +1,617 @@
"""Users API endpoint for wikijs-python-sdk."""
from typing import Any, Dict, List, Optional, Union
from ..exceptions import APIError, ValidationError
from ..models.user import User, UserCreate, UserUpdate
from .base import BaseEndpoint
class UsersEndpoint(BaseEndpoint):
"""Endpoint for Wiki.js Users API operations.
This endpoint provides methods for creating, reading, updating, and deleting
users through the Wiki.js GraphQL API.
Example:
>>> client = WikiJSClient('https://wiki.example.com', auth='api-key')
>>> users = client.users
>>>
>>> # List all users
>>> all_users = users.list()
>>>
>>> # Get a specific user
>>> user = users.get(123)
>>>
>>> # Create a new user
>>> new_user_data = UserCreate(
... email="user@example.com",
... name="John Doe",
... password_raw="secure_password"
... )
>>> created_user = users.create(new_user_data)
>>>
>>> # Update an existing user
>>> update_data = UserUpdate(name="Jane Doe")
>>> updated_user = users.update(123, update_data)
>>>
>>> # Delete a user
>>> users.delete(123)
"""
def list(
self,
limit: Optional[int] = None,
offset: Optional[int] = None,
search: Optional[str] = None,
order_by: str = "name",
order_direction: str = "ASC",
) -> List[User]:
"""List users with optional filtering.
Args:
limit: Maximum number of users to return
offset: Number of users to skip
search: Search term to filter users
order_by: Field to order by (name, email, createdAt)
order_direction: Order direction (ASC or DESC)
Returns:
List of User objects
Raises:
APIError: If the API request fails
ValidationError: If parameters are invalid
"""
# Validate parameters
if limit is not None and limit < 1:
raise ValidationError("limit must be greater than 0")
if offset is not None and offset < 0:
raise ValidationError("offset must be non-negative")
if order_by not in ["name", "email", "createdAt", "lastLoginAt"]:
raise ValidationError(
"order_by must be one of: name, email, createdAt, lastLoginAt"
)
if order_direction not in ["ASC", "DESC"]:
raise ValidationError("order_direction must be ASC or DESC")
# Build GraphQL query
query = """
query($filter: String, $orderBy: String) {
users {
list(filter: $filter, orderBy: $orderBy) {
id
name
email
providerKey
isSystem
isActive
isVerified
location
jobTitle
timezone
createdAt
updatedAt
lastLoginAt
}
}
}
"""
# Build variables
variables: Dict[str, Any] = {}
if search:
variables["filter"] = search
if order_by:
# Wiki.js expects format like "name ASC"
variables["orderBy"] = f"{order_by} {order_direction}"
# Make request
response = self._post(
"/graphql",
json_data=(
{"query": query, "variables": variables}
if variables
else {"query": query}
),
)
# Parse response
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
users_data = response.get("data", {}).get("users", {}).get("list", [])
# Apply client-side pagination if needed
if offset:
users_data = users_data[offset:]
if limit:
users_data = users_data[:limit]
# Convert to User objects
users = []
for user_data in users_data:
try:
normalized_data = self._normalize_user_data(user_data)
user = User(**normalized_data)
users.append(user)
except Exception as e:
raise APIError(f"Failed to parse user data: {str(e)}") from e
return users
def get(self, user_id: int) -> User:
"""Get a specific user by ID.
Args:
user_id: The user ID
Returns:
User object
Raises:
APIError: If the user is not found or request fails
ValidationError: If user_id is invalid
"""
if not isinstance(user_id, int) or user_id < 1:
raise ValidationError("user_id must be a positive integer")
# Build GraphQL query
query = """
query($id: Int!) {
users {
single(id: $id) {
id
name
email
providerKey
isSystem
isActive
isVerified
location
jobTitle
timezone
groups {
id
name
}
createdAt
updatedAt
lastLoginAt
}
}
}
"""
# Make request
response = self._post(
"/graphql",
json_data={"query": query, "variables": {"id": user_id}},
)
# Parse response
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
user_data = response.get("data", {}).get("users", {}).get("single")
if not user_data:
raise APIError(f"User with ID {user_id} not found")
# Convert to User object
try:
normalized_data = self._normalize_user_data(user_data)
return User(**normalized_data)
except Exception as e:
raise APIError(f"Failed to parse user data: {str(e)}") from e
def create(self, user_data: Union[UserCreate, Dict[str, Any]]) -> User:
"""Create a new user.
Args:
user_data: User creation data (UserCreate object or dict)
Returns:
Created User object
Raises:
APIError: If user creation fails
ValidationError: If user data is invalid
"""
# Convert to UserCreate if needed
if isinstance(user_data, dict):
try:
user_data = UserCreate(**user_data)
except Exception as e:
raise ValidationError(f"Invalid user data: {str(e)}") from e
elif not isinstance(user_data, UserCreate):
raise ValidationError("user_data must be UserCreate object or dict")
# Build GraphQL mutation
mutation = """
mutation(
$email: String!,
$name: String!,
$passwordRaw: String!,
$providerKey: String!,
$groups: [Int]!,
$mustChangePassword: Boolean!,
$sendWelcomeEmail: Boolean!,
$location: String,
$jobTitle: String,
$timezone: String
) {
users {
create(
email: $email,
name: $name,
passwordRaw: $passwordRaw,
providerKey: $providerKey,
groups: $groups,
mustChangePassword: $mustChangePassword,
sendWelcomeEmail: $sendWelcomeEmail,
location: $location,
jobTitle: $jobTitle,
timezone: $timezone
) {
responseResult {
succeeded
errorCode
slug
message
}
user {
id
name
email
providerKey
isSystem
isActive
isVerified
location
jobTitle
timezone
createdAt
updatedAt
}
}
}
}
"""
# Build variables
variables = {
"email": user_data.email,
"name": user_data.name,
"passwordRaw": user_data.password_raw,
"providerKey": user_data.provider_key,
"groups": user_data.groups,
"mustChangePassword": user_data.must_change_password,
"sendWelcomeEmail": user_data.send_welcome_email,
"location": user_data.location,
"jobTitle": user_data.job_title,
"timezone": user_data.timezone,
}
# Make request
response = self._post(
"/graphql", json_data={"query": mutation, "variables": variables}
)
# Parse response
if "errors" in response:
raise APIError(f"Failed to create user: {response['errors']}")
create_result = response.get("data", {}).get("users", {}).get("create", {})
response_result = create_result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"User creation failed: {error_msg}")
created_user_data = create_result.get("user")
if not created_user_data:
raise APIError("User creation failed - no user data returned")
# Convert to User object
try:
normalized_data = self._normalize_user_data(created_user_data)
return User(**normalized_data)
except Exception as e:
raise APIError(f"Failed to parse created user data: {str(e)}") from e
def update(
self, user_id: int, user_data: Union[UserUpdate, Dict[str, Any]]
) -> User:
"""Update an existing user.
Args:
user_id: The user ID
user_data: User update data (UserUpdate object or dict)
Returns:
Updated User object
Raises:
APIError: If user update fails
ValidationError: If parameters are invalid
"""
if not isinstance(user_id, int) or user_id < 1:
raise ValidationError("user_id must be a positive integer")
# Convert to UserUpdate if needed
if isinstance(user_data, dict):
try:
user_data = UserUpdate(**user_data)
except Exception as e:
raise ValidationError(f"Invalid user data: {str(e)}") from e
elif not isinstance(user_data, UserUpdate):
raise ValidationError("user_data must be UserUpdate object or dict")
# Build GraphQL mutation
mutation = """
mutation(
$id: Int!,
$email: String,
$name: String,
$passwordRaw: String,
$location: String,
$jobTitle: String,
$timezone: String,
$groups: [Int],
$isActive: Boolean,
$isVerified: Boolean
) {
users {
update(
id: $id,
email: $email,
name: $name,
passwordRaw: $passwordRaw,
location: $location,
jobTitle: $jobTitle,
timezone: $timezone,
groups: $groups,
isActive: $isActive,
isVerified: $isVerified
) {
responseResult {
succeeded
errorCode
slug
message
}
user {
id
name
email
providerKey
isSystem
isActive
isVerified
location
jobTitle
timezone
createdAt
updatedAt
}
}
}
}
"""
# Build variables (only include non-None values)
variables: Dict[str, Any] = {"id": user_id}
if user_data.name is not None:
variables["name"] = user_data.name
if user_data.email is not None:
variables["email"] = str(user_data.email)
if user_data.password_raw is not None:
variables["passwordRaw"] = user_data.password_raw
if user_data.location is not None:
variables["location"] = user_data.location
if user_data.job_title is not None:
variables["jobTitle"] = user_data.job_title
if user_data.timezone is not None:
variables["timezone"] = user_data.timezone
if user_data.groups is not None:
variables["groups"] = user_data.groups
if user_data.is_active is not None:
variables["isActive"] = user_data.is_active
if user_data.is_verified is not None:
variables["isVerified"] = user_data.is_verified
# Make request
response = self._post(
"/graphql", json_data={"query": mutation, "variables": variables}
)
# Parse response
if "errors" in response:
raise APIError(f"Failed to update user: {response['errors']}")
update_result = response.get("data", {}).get("users", {}).get("update", {})
response_result = update_result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"User update failed: {error_msg}")
updated_user_data = update_result.get("user")
if not updated_user_data:
raise APIError("User update failed - no user data returned")
# Convert to User object
try:
normalized_data = self._normalize_user_data(updated_user_data)
return User(**normalized_data)
except Exception as e:
raise APIError(f"Failed to parse updated user data: {str(e)}") from e
def delete(self, user_id: int) -> bool:
"""Delete a user.
Args:
user_id: The user ID
Returns:
True if deletion was successful
Raises:
APIError: If user deletion fails
ValidationError: If user_id is invalid
"""
if not isinstance(user_id, int) or user_id < 1:
raise ValidationError("user_id must be a positive integer")
# Build GraphQL mutation
mutation = """
mutation($id: Int!) {
users {
delete(id: $id) {
responseResult {
succeeded
errorCode
slug
message
}
}
}
}
"""
# Make request
response = self._post(
"/graphql",
json_data={"query": mutation, "variables": {"id": user_id}},
)
# Parse response
if "errors" in response:
raise APIError(f"Failed to delete user: {response['errors']}")
delete_result = response.get("data", {}).get("users", {}).get("delete", {})
response_result = delete_result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"User deletion failed: {error_msg}")
return True
def search(self, query: str, limit: Optional[int] = None) -> List[User]:
"""Search for users by name or email.
Args:
query: Search query string
limit: Maximum number of results to return
Returns:
List of matching User objects
Raises:
APIError: If search fails
ValidationError: If parameters are invalid
"""
if not query or not isinstance(query, str):
raise ValidationError("query must be a non-empty string")
if limit is not None and limit < 1:
raise ValidationError("limit must be greater than 0")
# Use the list method with search parameter
return self.list(search=query, limit=limit)
def _normalize_user_data(self, user_data: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize user data from API response to model format.
Args:
user_data: Raw user data from API
Returns:
Normalized data for User model
"""
normalized = {}
# Map API field names to model field names
field_mapping = {
"id": "id",
"name": "name",
"email": "email",
"providerKey": "provider_key",
"isSystem": "is_system",
"isActive": "is_active",
"isVerified": "is_verified",
"location": "location",
"jobTitle": "job_title",
"timezone": "timezone",
"createdAt": "created_at",
"updatedAt": "updated_at",
"lastLoginAt": "last_login_at",
}
for api_field, model_field in field_mapping.items():
if api_field in user_data:
normalized[model_field] = user_data[api_field]
# Handle groups - convert from API format
if "groups" in user_data:
if isinstance(user_data["groups"], list):
# Convert each group dict to proper format
normalized["groups"] = [
{"id": g["id"], "name": g["name"]}
for g in user_data["groups"]
if isinstance(g, dict)
]
else:
normalized["groups"] = []
else:
normalized["groups"] = []
return normalized
def iter_all(
self,
batch_size: int = 50,
search: Optional[str] = None,
order_by: str = "name",
order_direction: str = "ASC",
):
"""Iterate over all users with automatic pagination.
Args:
batch_size: Number of users to fetch per request (default: 50)
search: Search term to filter users
order_by: Field to sort by
order_direction: Sort direction (ASC or DESC)
Yields:
User objects one at a time
Example:
>>> for user in client.users.iter_all():
... print(f"{user.name} ({user.email})")
"""
offset = 0
while True:
batch = self.list(
limit=batch_size,
offset=offset,
search=search,
order_by=order_by,
order_direction=order_direction,
)
if not batch:
break
for user in batch:
yield user
if len(batch) < batch_size:
break
offset += batch_size

View File

@@ -1,11 +1,48 @@
"""Data models for wikijs-python-sdk."""
from .asset import (
Asset,
AssetFolder,
AssetMove,
AssetRename,
AssetUpload,
FolderCreate,
)
from .base import BaseModel
from .group import (
Group,
GroupAssignUser,
GroupCreate,
GroupPageRule,
GroupPermission,
GroupUnassignUser,
GroupUpdate,
GroupUser,
)
from .page import Page, PageCreate, PageUpdate
from .user import User, UserCreate, UserGroup, UserUpdate
__all__ = [
"Asset",
"AssetFolder",
"AssetMove",
"AssetRename",
"AssetUpload",
"BaseModel",
"FolderCreate",
"Group",
"GroupAssignUser",
"GroupCreate",
"GroupPageRule",
"GroupPermission",
"GroupUnassignUser",
"GroupUpdate",
"GroupUser",
"Page",
"PageCreate",
"PageUpdate",
"User",
"UserCreate",
"UserUpdate",
"UserGroup",
]

205
wikijs/models/asset.py Normal file
View File

@@ -0,0 +1,205 @@
"""Data models for Wiki.js assets."""
from typing import Optional
from pydantic import Field, field_validator
from .base import BaseModel, TimestampedModel
class AssetFolder(BaseModel):
"""Asset folder model."""
id: int = Field(..., description="Folder ID")
slug: str = Field(..., description="Folder slug/path")
name: Optional[str] = Field(None, description="Folder name")
class Config:
"""Pydantic configuration."""
populate_by_name = True
class Asset(TimestampedModel):
"""Wiki.js asset model.
Represents a file asset (image, document, etc.) in Wiki.js.
Attributes:
id: Asset ID
filename: Original filename
ext: File extension
kind: Asset kind (image, binary, etc.)
mime: MIME type
file_size: File size in bytes
folder_id: Parent folder ID
folder: Parent folder information
author_id: ID of user who uploaded
author_name: Name of user who uploaded
created_at: Upload timestamp
updated_at: Last update timestamp
"""
id: int = Field(..., description="Asset ID")
filename: str = Field(..., min_length=1, description="Original filename")
ext: str = Field(..., description="File extension")
kind: str = Field(..., description="Asset kind (image, binary, etc.)")
mime: str = Field(..., description="MIME type")
file_size: int = Field(
..., alias="fileSize", ge=0, description="File size in bytes"
)
folder_id: Optional[int] = Field(
None, alias="folderId", description="Parent folder ID"
)
folder: Optional[AssetFolder] = Field(None, description="Parent folder")
author_id: Optional[int] = Field(None, alias="authorId", description="Author ID")
author_name: Optional[str] = Field(
None, alias="authorName", description="Author name"
)
@field_validator("filename")
@classmethod
def validate_filename(cls, v: str) -> str:
"""Validate filename."""
if not v or not v.strip():
raise ValueError("Filename cannot be empty")
return v.strip()
@property
def size_mb(self) -> float:
"""Get file size in megabytes."""
return self.file_size / (1024 * 1024)
@property
def size_kb(self) -> float:
"""Get file size in kilobytes."""
return self.file_size / 1024
class Config:
"""Pydantic configuration."""
populate_by_name = True
class AssetUpload(BaseModel):
"""Model for uploading a new asset.
Attributes:
file_path: Local path to file to upload
folder_id: Target folder ID (default: 0 for root)
filename: Optional custom filename (uses file_path name if not provided)
"""
file_path: str = Field(..., alias="filePath", description="Local file path")
folder_id: int = Field(default=0, alias="folderId", description="Target folder ID")
filename: Optional[str] = Field(None, description="Custom filename")
@field_validator("file_path")
@classmethod
def validate_file_path(cls, v: str) -> str:
"""Validate file path."""
if not v or not v.strip():
raise ValueError("File path cannot be empty")
return v.strip()
class Config:
"""Pydantic configuration."""
populate_by_name = True
class AssetRename(BaseModel):
"""Model for renaming an asset.
Attributes:
asset_id: Asset ID to rename
new_filename: New filename
"""
asset_id: int = Field(..., alias="assetId", description="Asset ID")
new_filename: str = Field(
..., alias="newFilename", min_length=1, description="New filename"
)
@field_validator("asset_id")
@classmethod
def validate_asset_id(cls, v: int) -> int:
"""Validate asset ID."""
if v <= 0:
raise ValueError("Asset ID must be positive")
return v
@field_validator("new_filename")
@classmethod
def validate_filename(cls, v: str) -> str:
"""Validate filename."""
if not v or not v.strip():
raise ValueError("Filename cannot be empty")
return v.strip()
class Config:
"""Pydantic configuration."""
populate_by_name = True
class AssetMove(BaseModel):
"""Model for moving an asset to a different folder.
Attributes:
asset_id: Asset ID to move
folder_id: Target folder ID
"""
asset_id: int = Field(..., alias="assetId", description="Asset ID")
folder_id: int = Field(..., alias="folderId", description="Target folder ID")
@field_validator("asset_id")
@classmethod
def validate_asset_id(cls, v: int) -> int:
"""Validate asset ID."""
if v <= 0:
raise ValueError("Asset ID must be positive")
return v
@field_validator("folder_id")
@classmethod
def validate_folder_id(cls, v: int) -> int:
"""Validate folder ID."""
if v < 0:
raise ValueError("Folder ID must be non-negative")
return v
class Config:
"""Pydantic configuration."""
populate_by_name = True
class FolderCreate(BaseModel):
"""Model for creating a new folder.
Attributes:
slug: Folder slug/path
name: Optional folder name
"""
slug: str = Field(..., min_length=1, description="Folder slug/path")
name: Optional[str] = Field(None, description="Folder name")
@field_validator("slug")
@classmethod
def validate_slug(cls, v: str) -> str:
"""Validate slug."""
if not v or not v.strip():
raise ValueError("Slug cannot be empty")
# Remove leading/trailing slashes
v = v.strip().strip("/")
if not v:
raise ValueError("Slug cannot be just slashes")
return v
class Config:
"""Pydantic configuration."""
populate_by_name = True

217
wikijs/models/group.py Normal file
View File

@@ -0,0 +1,217 @@
"""Data models for Wiki.js groups."""
from typing import List, Optional
from pydantic import Field, field_validator
from .base import BaseModel, TimestampedModel
class GroupPermission(BaseModel):
"""Group permission model."""
id: str = Field(..., description="Permission identifier")
name: Optional[str] = Field(None, description="Permission name")
class Config:
"""Pydantic configuration."""
populate_by_name = True
class GroupPageRule(BaseModel):
"""Group page access rule model."""
id: str = Field(..., description="Rule identifier")
path: str = Field(..., description="Page path pattern")
roles: List[str] = Field(default_factory=list, description="Allowed roles")
match: str = Field(default="START", description="Match type (START, EXACT, REGEX)")
deny: bool = Field(default=False, description="Whether this is a deny rule")
locales: List[str] = Field(default_factory=list, description="Allowed locales")
class Config:
"""Pydantic configuration."""
populate_by_name = True
class GroupUser(BaseModel):
"""User member of a group (minimal representation)."""
id: int = Field(..., description="User ID")
name: str = Field(..., description="User name")
email: str = Field(..., description="User email")
class Config:
"""Pydantic configuration."""
populate_by_name = True
class Group(TimestampedModel):
"""Wiki.js group model.
Represents a complete group with all fields.
Attributes:
id: Group ID
name: Group name
is_system: Whether this is a system group
redirect_on_login: Path to redirect to on login
permissions: List of group permissions
page_rules: List of page access rules
users: List of users in this group (only populated in get operations)
created_at: Creation timestamp
updated_at: Last update timestamp
"""
id: int = Field(..., description="Group ID")
name: str = Field(..., min_length=1, max_length=255, description="Group name")
is_system: bool = Field(
default=False, alias="isSystem", description="System group flag"
)
redirect_on_login: Optional[str] = Field(
None, alias="redirectOnLogin", description="Redirect path on login"
)
permissions: List[str] = Field(
default_factory=list, description="Permission identifiers"
)
page_rules: List[GroupPageRule] = Field(
default_factory=list, alias="pageRules", description="Page access rules"
)
users: List[GroupUser] = Field(
default_factory=list, description="Users in this group"
)
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
"""Validate group name."""
if not v or not v.strip():
raise ValueError("Group name cannot be empty")
if len(v.strip()) < 1:
raise ValueError("Group name must be at least 1 character")
if len(v) > 255:
raise ValueError("Group name cannot exceed 255 characters")
return v.strip()
class Config:
"""Pydantic configuration."""
populate_by_name = True
class GroupCreate(BaseModel):
"""Model for creating a new group.
Attributes:
name: Group name (required)
redirect_on_login: Path to redirect to on login
permissions: List of permission identifiers
page_rules: List of page access rule configurations
"""
name: str = Field(..., min_length=1, max_length=255, description="Group name")
redirect_on_login: Optional[str] = Field(
None, alias="redirectOnLogin", description="Redirect path on login"
)
permissions: List[str] = Field(
default_factory=list, description="Permission identifiers"
)
page_rules: List[dict] = Field(
default_factory=list, alias="pageRules", description="Page access rules"
)
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
"""Validate group name."""
if not v or not v.strip():
raise ValueError("Group name cannot be empty")
if len(v.strip()) < 1:
raise ValueError("Group name must be at least 1 character")
if len(v) > 255:
raise ValueError("Group name cannot exceed 255 characters")
return v.strip()
class Config:
"""Pydantic configuration."""
populate_by_name = True
class GroupUpdate(BaseModel):
"""Model for updating an existing group.
All fields are optional to support partial updates.
Attributes:
name: Updated group name
redirect_on_login: Updated redirect path
permissions: Updated permission list
page_rules: Updated page access rules
"""
name: Optional[str] = Field(
None, min_length=1, max_length=255, description="Group name"
)
redirect_on_login: Optional[str] = Field(
None, alias="redirectOnLogin", description="Redirect path on login"
)
permissions: Optional[List[str]] = Field(None, description="Permission identifiers")
page_rules: Optional[List[dict]] = Field(
None, alias="pageRules", description="Page access rules"
)
@field_validator("name")
@classmethod
def validate_name(cls, v: Optional[str]) -> Optional[str]:
"""Validate group name if provided."""
if v is None:
return v
if not v or not v.strip():
raise ValueError("Group name cannot be empty")
if len(v.strip()) < 1:
raise ValueError("Group name must be at least 1 character")
if len(v) > 255:
raise ValueError("Group name cannot exceed 255 characters")
return v.strip()
class Config:
"""Pydantic configuration."""
populate_by_name = True
class GroupAssignUser(BaseModel):
"""Model for assigning a user to a group.
Attributes:
group_id: Group ID
user_id: User ID
"""
group_id: int = Field(..., alias="groupId", description="Group ID")
user_id: int = Field(..., alias="userId", description="User ID")
class Config:
"""Pydantic configuration."""
populate_by_name = True
class GroupUnassignUser(BaseModel):
"""Model for removing a user from a group.
Attributes:
group_id: Group ID
user_id: User ID
"""
group_id: int = Field(..., alias="groupId", description="Group ID")
user_id: int = Field(..., alias="userId", description="User ID")
class Config:
"""Pydantic configuration."""
populate_by_name = True

192
wikijs/models/user.py Normal file
View File

@@ -0,0 +1,192 @@
"""User-related data models for wikijs-python-sdk."""
import re
from typing import List, Optional
from pydantic import EmailStr, Field, field_validator
from .base import BaseModel, TimestampedModel
class UserGroup(BaseModel):
"""Represents a user's group membership.
This model contains information about a user's membership
in a specific group.
"""
id: int = Field(..., description="Group ID")
name: str = Field(..., description="Group name")
class User(TimestampedModel):
"""Represents a Wiki.js user.
This model contains all user data including profile information,
authentication details, and group memberships.
"""
id: int = Field(..., description="Unique user identifier")
name: str = Field(..., description="User's full name")
email: EmailStr = Field(..., description="User's email address")
# Authentication and status
provider_key: Optional[str] = Field(None, alias="providerKey", description="Auth provider key")
is_system: bool = Field(False, alias="isSystem", description="Whether user is system user")
is_active: bool = Field(True, alias="isActive", description="Whether user is active")
is_verified: bool = Field(False, alias="isVerified", description="Whether email is verified")
# Profile information
location: Optional[str] = Field(None, description="User's location")
job_title: Optional[str] = Field(None, alias="jobTitle", description="User's job title")
timezone: Optional[str] = Field(None, description="User's timezone")
# Permissions and groups
groups: List[UserGroup] = Field(default_factory=list, description="User's groups")
# Timestamps handled by TimestampedModel
last_login_at: Optional[str] = Field(None, alias="lastLoginAt", description="Last login timestamp")
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
"""Validate user name."""
if not v or not v.strip():
raise ValueError("Name cannot be empty")
# Check length
if len(v) < 2:
raise ValueError("Name must be at least 2 characters long")
if len(v) > 255:
raise ValueError("Name cannot exceed 255 characters")
return v.strip()
class Config:
"""Pydantic model configuration."""
populate_by_name = True
str_strip_whitespace = True
class UserCreate(BaseModel):
"""Model for creating a new user.
This model contains all required and optional fields
for creating a new Wiki.js user.
"""
email: EmailStr = Field(..., description="User's email address")
name: str = Field(..., description="User's full name")
password_raw: str = Field(..., alias="passwordRaw", description="User's password")
# Optional fields
provider_key: str = Field("local", alias="providerKey", description="Auth provider key")
groups: List[int] = Field(default_factory=list, description="Group IDs to assign")
must_change_password: bool = Field(False, alias="mustChangePassword", description="Force password change")
send_welcome_email: bool = Field(True, alias="sendWelcomeEmail", description="Send welcome email")
# Profile information
location: Optional[str] = Field(None, description="User's location")
job_title: Optional[str] = Field(None, alias="jobTitle", description="User's job title")
timezone: Optional[str] = Field(None, description="User's timezone")
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
"""Validate user name."""
if not v or not v.strip():
raise ValueError("Name cannot be empty")
if len(v) < 2:
raise ValueError("Name must be at least 2 characters long")
if len(v) > 255:
raise ValueError("Name cannot exceed 255 characters")
return v.strip()
@field_validator("password_raw")
@classmethod
def validate_password(cls, v: str) -> str:
"""Validate password strength."""
if not v:
raise ValueError("Password cannot be empty")
if len(v) < 6:
raise ValueError("Password must be at least 6 characters long")
if len(v) > 255:
raise ValueError("Password cannot exceed 255 characters")
return v
class Config:
"""Pydantic model configuration."""
populate_by_name = True
str_strip_whitespace = True
class UserUpdate(BaseModel):
"""Model for updating an existing user.
This model contains optional fields that can be updated
for an existing Wiki.js user. All fields are optional.
"""
name: Optional[str] = Field(None, description="User's full name")
email: Optional[EmailStr] = Field(None, description="User's email address")
password_raw: Optional[str] = Field(None, alias="passwordRaw", description="New password")
# Profile information
location: Optional[str] = Field(None, description="User's location")
job_title: Optional[str] = Field(None, alias="jobTitle", description="User's job title")
timezone: Optional[str] = Field(None, description="User's timezone")
# Group assignments
groups: Optional[List[int]] = Field(None, description="Group IDs to assign")
# Status flags
is_active: Optional[bool] = Field(None, alias="isActive", description="Whether user is active")
is_verified: Optional[bool] = Field(None, alias="isVerified", description="Whether email is verified")
@field_validator("name")
@classmethod
def validate_name(cls, v: Optional[str]) -> Optional[str]:
"""Validate user name if provided."""
if v is None:
return v
if not v.strip():
raise ValueError("Name cannot be empty")
if len(v) < 2:
raise ValueError("Name must be at least 2 characters long")
if len(v) > 255:
raise ValueError("Name cannot exceed 255 characters")
return v.strip()
@field_validator("password_raw")
@classmethod
def validate_password(cls, v: Optional[str]) -> Optional[str]:
"""Validate password strength if provided."""
if v is None:
return v
if len(v) < 6:
raise ValueError("Password must be at least 6 characters long")
if len(v) > 255:
raise ValueError("Password cannot exceed 255 characters")
return v
class Config:
"""Pydantic model configuration."""
populate_by_name = True
str_strip_whitespace = True