Complete Phase 1 foundation: Tasks 1.1 and 1.2
✅ Task 1.1 - Project Foundation Setup: - Repository structure with Python packaging (setup.py, pyproject.toml) - Development dependencies and requirements - Contributing guidelines and MIT license - GitHub workflows for CI/CD (test.yml, release.yml) - Issue and PR templates for community contributions - Comprehensive project documentation ✅ Task 1.2 - Core Client Structure: - wikijs package with proper module organization - Core client class foundation in client.py - Exception hierarchy for error handling - Base model classes and page models - Type checking support (py.typed) - Utility modules and helper functions 📊 Progress: Phase 1 MVP Development now 40% complete 🎯 Next: Task 1.3 - Authentication System implementation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
77
wikijs/__init__.py
Normal file
77
wikijs/__init__.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Wiki.js Python SDK - Professional SDK for Wiki.js API integration.
|
||||
|
||||
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:
|
||||
|
||||
>>> from wikijs import WikiJSClient
|
||||
>>> client = WikiJSClient('https://wiki.example.com', auth='your-api-key')
|
||||
>>> # API endpoints will be available as development progresses
|
||||
|
||||
Features:
|
||||
- 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
|
||||
"""
|
||||
|
||||
from .client import WikiJSClient
|
||||
from .exceptions import (
|
||||
WikiJSException,
|
||||
APIError,
|
||||
AuthenticationError,
|
||||
ConfigurationError,
|
||||
ValidationError,
|
||||
ClientError,
|
||||
ServerError,
|
||||
NotFoundError,
|
||||
PermissionError,
|
||||
RateLimitError,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
)
|
||||
from .models import BaseModel, Page, PageCreate, PageUpdate
|
||||
from .version import __version__, __version_info__
|
||||
|
||||
# Public API
|
||||
__all__ = [
|
||||
# Main client
|
||||
"WikiJSClient",
|
||||
|
||||
# Data models
|
||||
"BaseModel",
|
||||
"Page",
|
||||
"PageCreate",
|
||||
"PageUpdate",
|
||||
|
||||
# Exceptions
|
||||
"WikiJSException",
|
||||
"APIError",
|
||||
"AuthenticationError",
|
||||
"ConfigurationError",
|
||||
"ValidationError",
|
||||
"ClientError",
|
||||
"ServerError",
|
||||
"NotFoundError",
|
||||
"PermissionError",
|
||||
"RateLimitError",
|
||||
"ConnectionError",
|
||||
"TimeoutError",
|
||||
|
||||
# Version info
|
||||
"__version__",
|
||||
"__version_info__",
|
||||
]
|
||||
|
||||
# Package metadata
|
||||
__author__ = "Wiki.js SDK Contributors"
|
||||
__email__ = ""
|
||||
__license__ = "MIT"
|
||||
__description__ = "Professional Python SDK for Wiki.js API integration"
|
||||
__url__ = "https://github.com/yourusername/wikijs-python-sdk"
|
||||
|
||||
# For type checking
|
||||
__all__ += ["__author__", "__email__", "__license__", "__description__", "__url__"]
|
||||
19
wikijs/auth/__init__.py
Normal file
19
wikijs/auth/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Authentication module for wikijs-python-sdk.
|
||||
|
||||
This module will contain authentication handlers for different
|
||||
authentication methods supported by Wiki.js.
|
||||
|
||||
Future implementations:
|
||||
- API key authentication
|
||||
- JWT token authentication
|
||||
- OAuth2 authentication
|
||||
"""
|
||||
|
||||
# Placeholder for future authentication implementations
|
||||
# from .base import AuthHandler
|
||||
# from .api_key import APIKeyAuth
|
||||
# from .jwt import JWTAuth
|
||||
|
||||
__all__ = [
|
||||
# Will be implemented in Task 1.3
|
||||
]
|
||||
261
wikijs/client.py
Normal file
261
wikijs/client.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""Main WikiJS client for wikijs-python-sdk."""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from .exceptions import (
|
||||
APIError,
|
||||
AuthenticationError,
|
||||
ConfigurationError,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
create_api_error,
|
||||
)
|
||||
from .utils import normalize_url, build_api_url, parse_wiki_response, extract_error_message
|
||||
|
||||
|
||||
class WikiJSClient:
|
||||
"""Main client for interacting with Wiki.js API.
|
||||
|
||||
This client provides a high-level interface for all Wiki.js API operations
|
||||
including pages, users, groups, and system management. It handles authentication,
|
||||
error handling, and response parsing automatically.
|
||||
|
||||
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
|
||||
|
||||
Example:
|
||||
Basic usage with API key:
|
||||
|
||||
>>> client = WikiJSClient('https://wiki.example.com', auth='your-api-key')
|
||||
>>> # Will be available after endpoints are implemented:
|
||||
>>> # pages = client.pages.list()
|
||||
|
||||
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,
|
||||
):
|
||||
# Validate and normalize base URL
|
||||
self.base_url = normalize_url(base_url)
|
||||
|
||||
# Store authentication
|
||||
if isinstance(auth, str):
|
||||
# Simple API key - will be handled by auth module later
|
||||
self._api_key = auth
|
||||
self._auth_handler = None
|
||||
else:
|
||||
# Auth handler (for future implementation)
|
||||
self._api_key = None
|
||||
self._auth_handler = auth
|
||||
|
||||
# Request configuration
|
||||
self.timeout = timeout
|
||||
self.verify_ssl = verify_ssl
|
||||
self.user_agent = user_agent or f"wikijs-python-sdk/0.1.0"
|
||||
|
||||
# Initialize HTTP session
|
||||
self._session = self._create_session()
|
||||
|
||||
# Endpoint handlers (will be initialized as we implement them)
|
||||
# self.pages = PagesEndpoint(self)
|
||||
# self.users = UsersEndpoint(self)
|
||||
# self.groups = GroupsEndpoint(self)
|
||||
|
||||
def _create_session(self) -> requests.Session:
|
||||
"""Create configured HTTP session with retry strategy.
|
||||
|
||||
Returns:
|
||||
Configured requests session
|
||||
"""
|
||||
session = requests.Session()
|
||||
|
||||
# Configure retry strategy
|
||||
retry_strategy = Retry(
|
||||
total=3,
|
||||
backoff_factor=1,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
allowed_methods=["HEAD", "GET", "OPTIONS", "POST", "PUT", "DELETE"],
|
||||
)
|
||||
|
||||
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||
session.mount("http://", adapter)
|
||||
session.mount("https://", adapter)
|
||||
|
||||
# Set default headers
|
||||
session.headers.update({
|
||||
"User-Agent": self.user_agent,
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
|
||||
# Set authentication headers
|
||||
if self._api_key:
|
||||
session.headers["Authorization"] = f"Bearer {self._api_key}"
|
||||
elif self._auth_handler:
|
||||
auth_headers = self._auth_handler.get_headers()
|
||||
session.headers.update(auth_headers)
|
||||
|
||||
return session
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
json_data: Optional[Dict[str, Any]] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""Make 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)
|
||||
|
||||
# Prepare request arguments
|
||||
request_kwargs = {
|
||||
"timeout": self.timeout,
|
||||
"verify": self.verify_ssl,
|
||||
"params": params,
|
||||
**kwargs
|
||||
}
|
||||
|
||||
# Add JSON data if provided
|
||||
if json_data is not None:
|
||||
request_kwargs["json"] = json_data
|
||||
|
||||
try:
|
||||
# Make request
|
||||
response = self._session.request(method, url, **request_kwargs)
|
||||
|
||||
# Handle response
|
||||
return self._handle_response(response)
|
||||
|
||||
except requests.exceptions.Timeout as e:
|
||||
raise TimeoutError(f"Request timed out after {self.timeout} seconds") from e
|
||||
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise ConnectionError(f"Failed to connect to {self.base_url}") from e
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise APIError(f"Request failed: {str(e)}") from e
|
||||
|
||||
def _handle_response(self, response: requests.Response) -> Dict[str, Any]:
|
||||
"""Handle HTTP response and extract data.
|
||||
|
||||
Args:
|
||||
response: HTTP response object
|
||||
|
||||
Returns:
|
||||
Parsed response data
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If authentication fails (401)
|
||||
APIError: If API returns an error
|
||||
"""
|
||||
# Handle authentication errors
|
||||
if response.status_code == 401:
|
||||
raise AuthenticationError("Authentication failed - check your API key")
|
||||
|
||||
# Handle other HTTP errors
|
||||
if not response.ok:
|
||||
error_message = extract_error_message(response)
|
||||
raise create_api_error(
|
||||
response.status_code,
|
||||
error_message,
|
||||
response
|
||||
)
|
||||
|
||||
# Parse JSON response
|
||||
try:
|
||||
data = response.json()
|
||||
except json.JSONDecodeError as e:
|
||||
raise APIError(f"Invalid JSON response: {str(e)}") from e
|
||||
|
||||
# Parse Wiki.js specific response format
|
||||
return parse_wiki_response(data)
|
||||
|
||||
def test_connection(self) -> bool:
|
||||
"""Test connection to Wiki.js instance.
|
||||
|
||||
Returns:
|
||||
True if connection successful
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If client is not properly configured
|
||||
ConnectionError: If cannot connect to server
|
||||
AuthenticationError: If authentication fails
|
||||
"""
|
||||
if not self.base_url:
|
||||
raise ConfigurationError("Base URL not configured")
|
||||
|
||||
if not self._api_key and not self._auth_handler:
|
||||
raise ConfigurationError("Authentication not configured")
|
||||
|
||||
try:
|
||||
# Try to hit a basic endpoint (will implement with actual endpoints)
|
||||
# For now, just test basic connectivity
|
||||
response = self._session.get(
|
||||
self.base_url,
|
||||
timeout=self.timeout,
|
||||
verify=self.verify_ssl
|
||||
)
|
||||
return True
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
raise TimeoutError(f"Connection test timed out after {self.timeout} seconds")
|
||||
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise ConnectionError(f"Cannot connect to {self.base_url}: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
raise ConnectionError(f"Connection test failed: {str(e)}")
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager exit - close session."""
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
"""Close the HTTP session and clean up resources."""
|
||||
if self._session:
|
||||
self._session.close()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of client."""
|
||||
return f"WikiJSClient(base_url='{self.base_url}')"
|
||||
22
wikijs/endpoints/__init__.py
Normal file
22
wikijs/endpoints/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""API endpoints module for wikijs-python-sdk.
|
||||
|
||||
This module will contain endpoint handlers for different
|
||||
Wiki.js API endpoints.
|
||||
|
||||
Future implementations:
|
||||
- Pages API (CRUD operations)
|
||||
- Users API (user management)
|
||||
- Groups API (group management)
|
||||
- Assets API (file management)
|
||||
- System API (system information)
|
||||
"""
|
||||
|
||||
# Placeholder for future endpoint implementations
|
||||
# from .base import BaseEndpoint
|
||||
# from .pages import PagesEndpoint
|
||||
# from .users import UsersEndpoint
|
||||
# from .groups import GroupsEndpoint
|
||||
|
||||
__all__ = [
|
||||
# Will be implemented in Task 1.4
|
||||
]
|
||||
109
wikijs/exceptions.py
Normal file
109
wikijs/exceptions.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Exception hierarchy for wikijs-python-sdk."""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
class WikiJSException(Exception):
|
||||
"""Base exception for all SDK errors."""
|
||||
|
||||
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.details = details or {}
|
||||
|
||||
|
||||
class ConfigurationError(WikiJSException):
|
||||
"""Raised when there's an issue with SDK configuration."""
|
||||
pass
|
||||
|
||||
|
||||
class AuthenticationError(WikiJSException):
|
||||
"""Raised when authentication fails."""
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(WikiJSException):
|
||||
"""Raised when input validation fails."""
|
||||
|
||||
def __init__(self, message: str, field: Optional[str] = None, value: Any = None):
|
||||
super().__init__(message)
|
||||
self.field = field
|
||||
self.value = value
|
||||
|
||||
|
||||
class APIError(WikiJSException):
|
||||
"""Base class for API-related errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: Optional[int] = None,
|
||||
response: Optional[Any] = None,
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
super().__init__(message, details)
|
||||
self.status_code = status_code
|
||||
self.response = response
|
||||
|
||||
|
||||
class ClientError(APIError):
|
||||
"""Raised for 4xx HTTP status codes (client errors)."""
|
||||
pass
|
||||
|
||||
|
||||
class ServerError(APIError):
|
||||
"""Raised for 5xx HTTP status codes (server errors)."""
|
||||
pass
|
||||
|
||||
|
||||
class NotFoundError(ClientError):
|
||||
"""Raised when a requested resource is not found (404)."""
|
||||
pass
|
||||
|
||||
|
||||
class PermissionError(ClientError):
|
||||
"""Raised when access is forbidden (403)."""
|
||||
pass
|
||||
|
||||
|
||||
class RateLimitError(ClientError):
|
||||
"""Raised when rate limit is exceeded (429)."""
|
||||
|
||||
def __init__(self, message: str, retry_after: Optional[int] = None, **kwargs):
|
||||
super().__init__(message, status_code=429, **kwargs)
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class ConnectionError(WikiJSException):
|
||||
"""Raised when there's a connection issue."""
|
||||
pass
|
||||
|
||||
|
||||
class TimeoutError(WikiJSException):
|
||||
"""Raised when a request times out."""
|
||||
pass
|
||||
|
||||
|
||||
def create_api_error(status_code: int, message: str, response: Any = None) -> APIError:
|
||||
"""Create appropriate API error based on status code.
|
||||
|
||||
Args:
|
||||
status_code: HTTP status code
|
||||
message: Error message
|
||||
response: Raw response object
|
||||
|
||||
Returns:
|
||||
Appropriate APIError subclass instance
|
||||
"""
|
||||
if status_code == 404:
|
||||
return NotFoundError(message, status_code=status_code, response=response)
|
||||
elif status_code == 403:
|
||||
return PermissionError(message, status_code=status_code, response=response)
|
||||
elif status_code == 429:
|
||||
return RateLimitError(message, status_code=status_code, response=response)
|
||||
elif 400 <= status_code < 500:
|
||||
return ClientError(message, status_code=status_code, response=response)
|
||||
elif 500 <= status_code < 600:
|
||||
return ServerError(message, status_code=status_code, response=response)
|
||||
else:
|
||||
return APIError(message, status_code=status_code, response=response)
|
||||
11
wikijs/models/__init__.py
Normal file
11
wikijs/models/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Data models for wikijs-python-sdk."""
|
||||
|
||||
from .base import BaseModel
|
||||
from .page import Page, PageCreate, PageUpdate
|
||||
|
||||
__all__ = [
|
||||
"BaseModel",
|
||||
"Page",
|
||||
"PageCreate",
|
||||
"PageUpdate",
|
||||
]
|
||||
90
wikijs/models/base.py
Normal file
90
wikijs/models/base.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Base model functionality for wikijs-python-sdk."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel as PydanticBaseModel, ConfigDict
|
||||
|
||||
|
||||
class BaseModel(PydanticBaseModel):
|
||||
"""Base model with common functionality for all data models.
|
||||
|
||||
Provides:
|
||||
- Automatic validation via Pydantic
|
||||
- JSON serialization/deserialization
|
||||
- Field aliases for API compatibility
|
||||
- Consistent datetime handling
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
# Allow population by field name or alias
|
||||
populate_by_name=True,
|
||||
# Validate assignment to attributes
|
||||
validate_assignment=True,
|
||||
# Use enum values instead of names
|
||||
use_enum_values=True,
|
||||
# Allow extra fields for forward compatibility
|
||||
extra="ignore",
|
||||
# Serialize datetime as ISO format
|
||||
json_encoders={
|
||||
datetime: lambda v: v.isoformat() if v else None
|
||||
}
|
||||
)
|
||||
|
||||
def to_dict(self, exclude_none: bool = True) -> Dict[str, Any]:
|
||||
"""Convert model to dictionary.
|
||||
|
||||
Args:
|
||||
exclude_none: Whether to exclude None values
|
||||
|
||||
Returns:
|
||||
Dictionary representation of the model
|
||||
"""
|
||||
return self.model_dump(exclude_none=exclude_none, by_alias=True)
|
||||
|
||||
def to_json(self, exclude_none: bool = True) -> str:
|
||||
"""Convert model to JSON string.
|
||||
|
||||
Args:
|
||||
exclude_none: Whether to exclude None values
|
||||
|
||||
Returns:
|
||||
JSON string representation of the model
|
||||
"""
|
||||
return self.model_dump_json(exclude_none=exclude_none, by_alias=True)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "BaseModel":
|
||||
"""Create model instance from dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary data
|
||||
|
||||
Returns:
|
||||
Model instance
|
||||
"""
|
||||
return cls(**data)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> "BaseModel":
|
||||
"""Create model instance from JSON string.
|
||||
|
||||
Args:
|
||||
json_str: JSON string
|
||||
|
||||
Returns:
|
||||
Model instance
|
||||
"""
|
||||
return cls.model_validate_json(json_str)
|
||||
|
||||
|
||||
class TimestampedModel(BaseModel):
|
||||
"""Base model with timestamp fields."""
|
||||
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
@property
|
||||
def is_new(self) -> bool:
|
||||
"""Check if this is a new (unsaved) model."""
|
||||
return self.created_at is None
|
||||
188
wikijs/models/page.py
Normal file
188
wikijs/models/page.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Page-related data models for wikijs-python-sdk."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import Field, validator
|
||||
|
||||
from .base import BaseModel, TimestampedModel
|
||||
|
||||
|
||||
class Page(TimestampedModel):
|
||||
"""Represents a Wiki.js page.
|
||||
|
||||
This model contains all the data for a wiki page including
|
||||
content, metadata, and computed properties.
|
||||
"""
|
||||
|
||||
id: int = Field(..., description="Unique page identifier")
|
||||
title: str = Field(..., description="Page title")
|
||||
path: str = Field(..., description="Page path/slug")
|
||||
content: str = Field(..., description="Page content")
|
||||
|
||||
# Optional fields that may be present
|
||||
description: Optional[str] = Field(None, description="Page description")
|
||||
is_published: bool = Field(True, description="Whether page is published")
|
||||
is_private: bool = Field(False, description="Whether page is private")
|
||||
|
||||
# Metadata
|
||||
tags: List[str] = Field(default_factory=list, description="Page tags")
|
||||
locale: str = Field("en", description="Page locale")
|
||||
|
||||
# Author information
|
||||
author_id: Optional[int] = Field(None, alias="authorId")
|
||||
author_name: Optional[str] = Field(None, alias="authorName")
|
||||
author_email: Optional[str] = Field(None, alias="authorEmail")
|
||||
|
||||
# Editor information
|
||||
editor: Optional[str] = Field(None, description="Editor used")
|
||||
|
||||
@validator("path")
|
||||
def validate_path(cls, v):
|
||||
"""Validate page path format."""
|
||||
if not v:
|
||||
raise ValueError("Path cannot be empty")
|
||||
|
||||
# Remove leading/trailing slashes and normalize
|
||||
v = v.strip("/")
|
||||
|
||||
# Check for valid characters (letters, numbers, hyphens, underscores, slashes)
|
||||
if not re.match(r"^[a-zA-Z0-9\-_/]+$", v):
|
||||
raise ValueError("Path contains invalid characters")
|
||||
|
||||
return v
|
||||
|
||||
@validator("title")
|
||||
def validate_title(cls, v):
|
||||
"""Validate page title."""
|
||||
if not v or not v.strip():
|
||||
raise ValueError("Title cannot be empty")
|
||||
|
||||
# Limit title length
|
||||
if len(v) > 255:
|
||||
raise ValueError("Title cannot exceed 255 characters")
|
||||
|
||||
return v.strip()
|
||||
|
||||
@property
|
||||
def word_count(self) -> int:
|
||||
"""Calculate word count from content."""
|
||||
if not self.content:
|
||||
return 0
|
||||
|
||||
# Simple word count - split on whitespace
|
||||
words = self.content.split()
|
||||
return len(words)
|
||||
|
||||
@property
|
||||
def reading_time(self) -> int:
|
||||
"""Estimate reading time in minutes (assuming 200 words per minute)."""
|
||||
return max(1, self.word_count // 200)
|
||||
|
||||
@property
|
||||
def url_path(self) -> str:
|
||||
"""Get the full URL path for this page."""
|
||||
return f"/{self.path}"
|
||||
|
||||
def extract_headings(self) -> List[str]:
|
||||
"""Extract markdown headings from content.
|
||||
|
||||
Returns:
|
||||
List of heading text (without # markers)
|
||||
"""
|
||||
if not self.content:
|
||||
return []
|
||||
|
||||
headings = []
|
||||
for line in self.content.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("#"):
|
||||
# Remove # markers and whitespace
|
||||
heading = re.sub(r"^#+\s*", "", line).strip()
|
||||
if heading:
|
||||
headings.append(heading)
|
||||
|
||||
return headings
|
||||
|
||||
def has_tag(self, tag: str) -> bool:
|
||||
"""Check if page has a specific tag.
|
||||
|
||||
Args:
|
||||
tag: Tag to check for
|
||||
|
||||
Returns:
|
||||
True if page has the tag
|
||||
"""
|
||||
return tag.lower() in [t.lower() for t in self.tags]
|
||||
|
||||
|
||||
class PageCreate(BaseModel):
|
||||
"""Data model for creating a new page."""
|
||||
|
||||
title: str = Field(..., description="Page title")
|
||||
path: str = Field(..., description="Page path/slug")
|
||||
content: str = Field(..., description="Page content")
|
||||
|
||||
# Optional fields
|
||||
description: Optional[str] = Field(None, description="Page description")
|
||||
is_published: bool = Field(True, description="Whether to publish immediately")
|
||||
is_private: bool = Field(False, description="Whether page should be private")
|
||||
|
||||
tags: List[str] = Field(default_factory=list, description="Page tags")
|
||||
locale: str = Field("en", description="Page locale")
|
||||
editor: str = Field("markdown", description="Editor to use")
|
||||
|
||||
@validator("path")
|
||||
def validate_path(cls, v):
|
||||
"""Validate page path format."""
|
||||
if not v:
|
||||
raise ValueError("Path cannot be empty")
|
||||
|
||||
# Remove leading/trailing slashes and normalize
|
||||
v = v.strip("/")
|
||||
|
||||
# Check for valid characters
|
||||
if not re.match(r"^[a-zA-Z0-9\-_/]+$", v):
|
||||
raise ValueError("Path contains invalid characters")
|
||||
|
||||
return v
|
||||
|
||||
@validator("title")
|
||||
def validate_title(cls, v):
|
||||
"""Validate page title."""
|
||||
if not v or not v.strip():
|
||||
raise ValueError("Title cannot be empty")
|
||||
|
||||
if len(v) > 255:
|
||||
raise ValueError("Title cannot exceed 255 characters")
|
||||
|
||||
return v.strip()
|
||||
|
||||
|
||||
class PageUpdate(BaseModel):
|
||||
"""Data model for updating an existing page."""
|
||||
|
||||
# All fields optional for partial updates
|
||||
title: Optional[str] = Field(None, description="Page title")
|
||||
content: Optional[str] = Field(None, description="Page content")
|
||||
description: Optional[str] = Field(None, description="Page description")
|
||||
|
||||
is_published: Optional[bool] = Field(None, description="Publication status")
|
||||
is_private: Optional[bool] = Field(None, description="Privacy status")
|
||||
|
||||
tags: Optional[List[str]] = Field(None, description="Page tags")
|
||||
|
||||
@validator("title")
|
||||
def validate_title(cls, v):
|
||||
"""Validate page title if provided."""
|
||||
if v is not None:
|
||||
if not v.strip():
|
||||
raise ValueError("Title cannot be empty")
|
||||
|
||||
if len(v) > 255:
|
||||
raise ValueError("Title cannot exceed 255 characters")
|
||||
|
||||
return v.strip()
|
||||
|
||||
return v
|
||||
0
wikijs/py.typed
Normal file
0
wikijs/py.typed
Normal file
15
wikijs/utils/__init__.py
Normal file
15
wikijs/utils/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Utility functions for wikijs-python-sdk."""
|
||||
|
||||
from .helpers import (
|
||||
normalize_url,
|
||||
sanitize_path,
|
||||
validate_url,
|
||||
parse_wiki_response,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"normalize_url",
|
||||
"sanitize_path",
|
||||
"validate_url",
|
||||
"parse_wiki_response",
|
||||
]
|
||||
209
wikijs/utils/helpers.py
Normal file
209
wikijs/utils/helpers.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Helper utilities for wikijs-python-sdk."""
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from ..exceptions import APIError, ValidationError
|
||||
|
||||
|
||||
def normalize_url(base_url: str) -> str:
|
||||
"""Normalize a base URL for API usage.
|
||||
|
||||
Args:
|
||||
base_url: Base URL to normalize
|
||||
|
||||
Returns:
|
||||
Normalized URL without trailing slash
|
||||
|
||||
Raises:
|
||||
ValidationError: If URL is invalid
|
||||
"""
|
||||
if not base_url:
|
||||
raise ValidationError("Base URL cannot be empty")
|
||||
|
||||
# Add https:// if no scheme provided
|
||||
if not base_url.startswith(("http://", "https://")):
|
||||
base_url = f"https://{base_url}"
|
||||
|
||||
# Validate URL format
|
||||
if not validate_url(base_url):
|
||||
raise ValidationError(f"Invalid URL format: {base_url}")
|
||||
|
||||
# Remove trailing slash
|
||||
return base_url.rstrip("/")
|
||||
|
||||
|
||||
def validate_url(url: str) -> bool:
|
||||
"""Validate URL format.
|
||||
|
||||
Args:
|
||||
url: URL to validate
|
||||
|
||||
Returns:
|
||||
True if URL is valid
|
||||
"""
|
||||
try:
|
||||
result = urlparse(url)
|
||||
return all([result.scheme, result.netloc])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def sanitize_path(path: str) -> str:
|
||||
"""Sanitize a wiki page path.
|
||||
|
||||
Args:
|
||||
path: Path to sanitize
|
||||
|
||||
Returns:
|
||||
Sanitized path
|
||||
|
||||
Raises:
|
||||
ValidationError: If path is invalid
|
||||
"""
|
||||
if not path:
|
||||
raise ValidationError("Path cannot be empty")
|
||||
|
||||
# Remove leading/trailing slashes and whitespace
|
||||
path = path.strip().strip("/")
|
||||
|
||||
# Replace spaces with hyphens
|
||||
path = re.sub(r"\s+", "-", path)
|
||||
|
||||
# Remove invalid characters, keep only alphanumeric, hyphens, underscores, slashes
|
||||
path = re.sub(r"[^a-zA-Z0-9\-_/]", "", path)
|
||||
|
||||
# Remove multiple consecutive hyphens or slashes
|
||||
path = re.sub(r"[-/]+", lambda m: m.group(0)[0], path)
|
||||
|
||||
if not path:
|
||||
raise ValidationError("Path contains no valid characters")
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def build_api_url(base_url: str, endpoint: str) -> str:
|
||||
"""Build full API URL from base URL and endpoint.
|
||||
|
||||
Args:
|
||||
base_url: Base URL (already normalized)
|
||||
endpoint: API endpoint path
|
||||
|
||||
Returns:
|
||||
Full API URL
|
||||
"""
|
||||
# Ensure endpoint starts with /
|
||||
if not endpoint.startswith("/"):
|
||||
endpoint = f"/{endpoint}"
|
||||
|
||||
# Wiki.js API is typically at /graphql, but we'll use REST-style for now
|
||||
api_base = f"{base_url}/api"
|
||||
|
||||
return urljoin(api_base, endpoint.lstrip("/"))
|
||||
|
||||
|
||||
def parse_wiki_response(response_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Parse Wiki.js API response data.
|
||||
|
||||
Args:
|
||||
response_data: Raw response data from API
|
||||
|
||||
Returns:
|
||||
Parsed response data
|
||||
|
||||
Raises:
|
||||
APIError: If response indicates an error
|
||||
"""
|
||||
if not isinstance(response_data, dict):
|
||||
return response_data
|
||||
|
||||
# Check for error indicators
|
||||
if "error" in response_data:
|
||||
error_info = response_data["error"]
|
||||
if isinstance(error_info, dict):
|
||||
message = error_info.get("message", "Unknown API error")
|
||||
code = error_info.get("code")
|
||||
else:
|
||||
message = str(error_info)
|
||||
code = None
|
||||
|
||||
raise APIError(f"API Error: {message}", details={"code": code})
|
||||
|
||||
# Handle GraphQL-style errors
|
||||
if "errors" in response_data:
|
||||
errors = response_data["errors"]
|
||||
if errors:
|
||||
first_error = errors[0] if isinstance(errors, list) else errors
|
||||
message = first_error.get("message", "GraphQL error") if isinstance(first_error, dict) else str(first_error)
|
||||
raise APIError(f"GraphQL Error: {message}", details={"errors": errors})
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
def extract_error_message(response: Any) -> str:
|
||||
"""Extract error message from response.
|
||||
|
||||
Args:
|
||||
response: Response object or data
|
||||
|
||||
Returns:
|
||||
Error message string
|
||||
"""
|
||||
if hasattr(response, "json"):
|
||||
try:
|
||||
data = response.json()
|
||||
if isinstance(data, dict):
|
||||
# Try common error message fields
|
||||
for field in ["message", "error", "detail", "msg"]:
|
||||
if field in data:
|
||||
return str(data[field])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if hasattr(response, "text"):
|
||||
return response.text[:200] + "..." if len(response.text) > 200 else response.text
|
||||
|
||||
return str(response)
|
||||
|
||||
|
||||
def chunk_list(items: list, chunk_size: int) -> list:
|
||||
"""Split list into chunks of specified size.
|
||||
|
||||
Args:
|
||||
items: List to chunk
|
||||
chunk_size: Size of each chunk
|
||||
|
||||
Returns:
|
||||
List of chunks
|
||||
"""
|
||||
if chunk_size <= 0:
|
||||
raise ValueError("Chunk size must be positive")
|
||||
|
||||
return [items[i:i + chunk_size] for i in range(0, len(items), chunk_size)]
|
||||
|
||||
|
||||
def safe_get(data: Dict[str, Any], key: str, default: Any = None) -> Any:
|
||||
"""Safely get value from dictionary with dot notation support.
|
||||
|
||||
Args:
|
||||
data: Dictionary to get value from
|
||||
key: Key (supports dot notation like "user.name")
|
||||
default: Default value if key not found
|
||||
|
||||
Returns:
|
||||
Value or default
|
||||
"""
|
||||
if "." not in key:
|
||||
return data.get(key, default)
|
||||
|
||||
keys = key.split(".")
|
||||
current = data
|
||||
|
||||
for k in keys:
|
||||
if isinstance(current, dict) and k in current:
|
||||
current = current[k]
|
||||
else:
|
||||
return default
|
||||
|
||||
return current
|
||||
7
wikijs/version.py
Normal file
7
wikijs/version.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Version information for wikijs-python-sdk."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version_info__ = (0, 1, 0)
|
||||
|
||||
# Version history
|
||||
# 0.1.0 - MVP Release: Basic Wiki.js integration with Pages API
|
||||
Reference in New Issue
Block a user