diff --git a/tests/endpoints/test_pages_batch.py b/tests/endpoints/test_pages_batch.py new file mode 100644 index 0000000..0f38495 --- /dev/null +++ b/tests/endpoints/test_pages_batch.py @@ -0,0 +1,299 @@ +"""Tests for Pages API batch operations.""" + +import pytest +import responses + +from wikijs import WikiJSClient +from wikijs.exceptions import APIError, ValidationError +from wikijs.models import Page, PageCreate, PageUpdate + + +@pytest.fixture +def client(): + """Create a test client.""" + return WikiJSClient("https://wiki.example.com", auth="test-api-key") + + +class TestPagesCreateMany: + """Tests for pages.create_many() method.""" + + @responses.activate + def test_create_many_success(self, client): + """Test successful batch page creation.""" + # Mock API responses for each create + for i in range(1, 4): + responses.add( + responses.POST, + "https://wiki.example.com/graphql", + json={ + "data": { + "pages": { + "create": { + "responseResult": {"succeeded": True}, + "page": { + "id": i, + "title": f"Page {i}", + "path": f"page-{i}", + "content": f"Content {i}", + "description": "", + "isPublished": True, + "isPrivate": False, + "tags": [], + "locale": "en", + "authorId": 1, + "authorName": "Admin", + "authorEmail": "admin@example.com", + "editor": "markdown", + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-01-01T00:00:00.000Z", + }, + } + } + } + }, + status=200, + ) + + pages_data = [ + PageCreate(title=f"Page {i}", path=f"page-{i}", content=f"Content {i}") + for i in range(1, 4) + ] + + created_pages = client.pages.create_many(pages_data) + + assert len(created_pages) == 3 + for i, page in enumerate(created_pages, 1): + assert page.id == i + assert page.title == f"Page {i}" + + def test_create_many_empty_list(self, client): + """Test create_many with empty list.""" + result = client.pages.create_many([]) + assert result == [] + + @responses.activate + def test_create_many_partial_failure(self, client): + """Test create_many with some failures.""" + # Mock successful creation for first page + responses.add( + responses.POST, + "https://wiki.example.com/graphql", + json={ + "data": { + "pages": { + "create": { + "responseResult": {"succeeded": True}, + "page": { + "id": 1, + "title": "Page 1", + "path": "page-1", + "content": "Content 1", + "description": "", + "isPublished": True, + "isPrivate": False, + "tags": [], + "locale": "en", + "authorId": 1, + "authorName": "Admin", + "authorEmail": "admin@example.com", + "editor": "markdown", + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-01-01T00:00:00.000Z", + }, + } + } + } + }, + status=200, + ) + + # Mock failure for second page + responses.add( + responses.POST, + "https://wiki.example.com/graphql", + json={"errors": [{"message": "Page already exists"}]}, + status=200, + ) + + pages_data = [ + PageCreate(title="Page 1", path="page-1", content="Content 1"), + PageCreate(title="Page 2", path="page-2", content="Content 2"), + ] + + with pytest.raises(APIError) as exc_info: + client.pages.create_many(pages_data) + + assert "Failed to create 1/2 pages" in str(exc_info.value) + assert "Successfully created: 1" in str(exc_info.value) + + +class TestPagesUpdateMany: + """Tests for pages.update_many() method.""" + + @responses.activate + def test_update_many_success(self, client): + """Test successful batch page updates.""" + # Mock API responses for each update + for i in range(1, 4): + responses.add( + responses.POST, + "https://wiki.example.com/graphql", + json={ + "data": { + "updatePage": { + "id": i, + "title": f"Updated Page {i}", + "path": f"page-{i}", + "content": f"Updated Content {i}", + "description": "", + "isPublished": True, + "isPrivate": False, + "tags": [], + "locale": "en", + "authorId": 1, + "authorName": "Admin", + "authorEmail": "admin@example.com", + "editor": "markdown", + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-01-01T00:10:00.000Z", + } + } + }, + status=200, + ) + + updates = [ + {"id": i, "content": f"Updated Content {i}", "title": f"Updated Page {i}"} + for i in range(1, 4) + ] + + updated_pages = client.pages.update_many(updates) + + assert len(updated_pages) == 3 + for i, page in enumerate(updated_pages, 1): + assert page.id == i + assert page.title == f"Updated Page {i}" + assert page.content == f"Updated Content {i}" + + def test_update_many_empty_list(self, client): + """Test update_many with empty list.""" + result = client.pages.update_many([]) + assert result == [] + + def test_update_many_missing_id(self, client): + """Test update_many with missing id field.""" + updates = [{"content": "New content"}] # Missing 'id' + + with pytest.raises(APIError) as exc_info: + client.pages.update_many(updates) + + assert "must have an 'id' field" in str(exc_info.value) + + @responses.activate + def test_update_many_partial_failure(self, client): + """Test update_many with some failures.""" + # Mock successful update for first page + responses.add( + responses.POST, + "https://wiki.example.com/graphql", + json={ + "data": { + "updatePage": { + "id": 1, + "title": "Updated Page 1", + "path": "page-1", + "content": "Updated Content 1", + "description": "", + "isPublished": True, + "isPrivate": False, + "tags": [], + "locale": "en", + "authorId": 1, + "authorName": "Admin", + "authorEmail": "admin@example.com", + "editor": "markdown", + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-01-01T00:10:00.000Z", + } + } + }, + status=200, + ) + + # Mock failure for second page + responses.add( + responses.POST, + "https://wiki.example.com/graphql", + json={"errors": [{"message": "Page not found"}]}, + status=200, + ) + + updates = [ + {"id": 1, "content": "Updated Content 1"}, + {"id": 999, "content": "Updated Content 999"}, + ] + + with pytest.raises(APIError) as exc_info: + client.pages.update_many(updates) + + assert "Failed to update 1/2 pages" in str(exc_info.value) + + +class TestPagesDeleteMany: + """Tests for pages.delete_many() method.""" + + @responses.activate + def test_delete_many_success(self, client): + """Test successful batch page deletions.""" + # Mock API responses for each delete + for i in range(1, 4): + responses.add( + responses.POST, + "https://wiki.example.com/graphql", + json={"data": {"deletePage": {"success": True}}}, + status=200, + ) + + result = client.pages.delete_many([1, 2, 3]) + + assert result["successful"] == 3 + assert result["failed"] == 0 + assert result["errors"] == [] + + def test_delete_many_empty_list(self, client): + """Test delete_many with empty list.""" + result = client.pages.delete_many([]) + assert result["successful"] == 0 + assert result["failed"] == 0 + assert result["errors"] == [] + + @responses.activate + def test_delete_many_partial_failure(self, client): + """Test delete_many with some failures.""" + # Mock successful deletion for first two pages + responses.add( + responses.POST, + "https://wiki.example.com/graphql", + json={"data": {"deletePage": {"success": True}}}, + status=200, + ) + responses.add( + responses.POST, + "https://wiki.example.com/graphql", + json={"data": {"deletePage": {"success": True}}}, + status=200, + ) + + # Mock failure for third page + responses.add( + responses.POST, + "https://wiki.example.com/graphql", + json={"errors": [{"message": "Page not found"}]}, + status=200, + ) + + with pytest.raises(APIError) as exc_info: + client.pages.delete_many([1, 2, 999]) + + assert "Failed to delete 1/3 pages" in str(exc_info.value) + assert "Successfully deleted: 2" in str(exc_info.value) diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..3728184 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,233 @@ +"""Tests for caching module.""" + +import time +from unittest.mock import Mock + +import pytest + +from wikijs.cache import CacheKey, MemoryCache +from wikijs.models import Page + + +class TestCacheKey: + """Tests for CacheKey class.""" + + def test_cache_key_to_string_basic(self): + """Test basic cache key string generation.""" + key = CacheKey("page", "123", "get") + assert key.to_string() == "page:123:get" + + def test_cache_key_to_string_with_params(self): + """Test cache key string with parameters.""" + key = CacheKey("page", "123", "list", "locale=en&tags=api") + assert key.to_string() == "page:123:list:locale=en&tags=api" + + def test_cache_key_different_resource_types(self): + """Test cache keys for different resource types.""" + page_key = CacheKey("page", "1", "get") + user_key = CacheKey("user", "1", "get") + assert page_key.to_string() != user_key.to_string() + + +class TestMemoryCache: + """Tests for MemoryCache class.""" + + def test_init_default_values(self): + """Test cache initialization with default values.""" + cache = MemoryCache() + assert cache.ttl == 300 + assert cache.max_size == 1000 + + def test_init_custom_values(self): + """Test cache initialization with custom values.""" + cache = MemoryCache(ttl=600, max_size=500) + assert cache.ttl == 600 + assert cache.max_size == 500 + + def test_set_and_get(self): + """Test setting and getting cache values.""" + cache = MemoryCache(ttl=10) + key = CacheKey("page", "123", "get") + value = {"id": 123, "title": "Test Page"} + + cache.set(key, value) + cached = cache.get(key) + + assert cached == value + + def test_get_nonexistent_key(self): + """Test getting a key that doesn't exist.""" + cache = MemoryCache() + key = CacheKey("page", "999", "get") + assert cache.get(key) is None + + def test_ttl_expiration(self): + """Test that cache entries expire after TTL.""" + cache = MemoryCache(ttl=1) # 1 second TTL + key = CacheKey("page", "123", "get") + value = {"id": 123, "title": "Test Page"} + + cache.set(key, value) + assert cache.get(key) == value + + # Wait for expiration + time.sleep(1.1) + assert cache.get(key) is None + + def test_lru_eviction(self): + """Test LRU eviction when max_size is reached.""" + cache = MemoryCache(ttl=300, max_size=3) + + # Add 3 items + for i in range(1, 4): + key = CacheKey("page", str(i), "get") + cache.set(key, {"id": i}) + + # All 3 should be present + assert cache.get(CacheKey("page", "1", "get")) is not None + assert cache.get(CacheKey("page", "2", "get")) is not None + assert cache.get(CacheKey("page", "3", "get")) is not None + + # Add 4th item - should evict oldest (1) + cache.set(CacheKey("page", "4", "get"), {"id": 4}) + + # Item 1 should be evicted + assert cache.get(CacheKey("page", "1", "get")) is None + # Others should still be present + assert cache.get(CacheKey("page", "2", "get")) is not None + assert cache.get(CacheKey("page", "3", "get")) is not None + assert cache.get(CacheKey("page", "4", "get")) is not None + + def test_lru_access_updates_order(self): + """Test that accessing an item updates LRU order.""" + cache = MemoryCache(ttl=300, max_size=3) + + # Add 3 items + for i in range(1, 4): + cache.set(CacheKey("page", str(i), "get"), {"id": i}) + + # Access item 1 (makes it most recent) + cache.get(CacheKey("page", "1", "get")) + + # Add 4th item - should evict item 2 (oldest now) + cache.set(CacheKey("page", "4", "get"), {"id": 4}) + + # Item 1 should still be present (was accessed) + assert cache.get(CacheKey("page", "1", "get")) is not None + # Item 2 should be evicted + assert cache.get(CacheKey("page", "2", "get")) is None + + def test_delete(self): + """Test deleting cache entries.""" + cache = MemoryCache() + key = CacheKey("page", "123", "get") + cache.set(key, {"id": 123}) + + assert cache.get(key) is not None + cache.delete(key) + assert cache.get(key) is None + + def test_clear(self): + """Test clearing all cache entries.""" + cache = MemoryCache() + + # Add multiple items + for i in range(5): + cache.set(CacheKey("page", str(i), "get"), {"id": i}) + + # Clear cache + cache.clear() + + # All items should be gone + for i in range(5): + assert cache.get(CacheKey("page", str(i), "get")) is None + + def test_invalidate_resource_specific(self): + """Test invalidating a specific resource.""" + cache = MemoryCache() + + # Add multiple pages + for i in range(1, 4): + cache.set(CacheKey("page", str(i), "get"), {"id": i}) + + # Invalidate page 2 + cache.invalidate_resource("page", "2") + + # Page 2 should be gone + assert cache.get(CacheKey("page", "2", "get")) is None + # Others should remain + assert cache.get(CacheKey("page", "1", "get")) is not None + assert cache.get(CacheKey("page", "3", "get")) is not None + + def test_invalidate_resource_all(self): + """Test invalidating all resources of a type.""" + cache = MemoryCache() + + # Add multiple pages and a user + for i in range(1, 4): + cache.set(CacheKey("page", str(i), "get"), {"id": i}) + cache.set(CacheKey("user", "1", "get"), {"id": 1}) + + # Invalidate all pages + cache.invalidate_resource("page") + + # All pages should be gone + for i in range(1, 4): + assert cache.get(CacheKey("page", str(i), "get")) is None + + # User should remain + assert cache.get(CacheKey("user", "1", "get")) is not None + + def test_get_stats(self): + """Test getting cache statistics.""" + cache = MemoryCache(ttl=300, max_size=1000) + + # Initially empty + stats = cache.get_stats() + assert stats["ttl"] == 300 + assert stats["max_size"] == 1000 + assert stats["current_size"] == 0 + assert stats["hits"] == 0 + assert stats["misses"] == 0 + + # Add item and access it + key = CacheKey("page", "123", "get") + cache.set(key, {"id": 123}) + cache.get(key) # Hit + cache.get(CacheKey("page", "999", "get")) # Miss + + stats = cache.get_stats() + assert stats["current_size"] == 1 + assert stats["hits"] == 1 + assert stats["misses"] == 1 + assert "hit_rate" in stats + + def test_cleanup_expired(self): + """Test cleanup of expired entries.""" + cache = MemoryCache(ttl=1) + + # Add items + for i in range(3): + cache.set(CacheKey("page", str(i), "get"), {"id": i}) + + assert cache.get_stats()["current_size"] == 3 + + # Wait for expiration + time.sleep(1.1) + + # Run cleanup + removed = cache.cleanup_expired() + + assert removed == 3 + assert cache.get_stats()["current_size"] == 0 + + def test_set_updates_existing(self): + """Test that setting an existing key updates the value.""" + cache = MemoryCache() + key = CacheKey("page", "123", "get") + + cache.set(key, {"id": 123, "title": "Original"}) + assert cache.get(key)["title"] == "Original" + + cache.set(key, {"id": 123, "title": "Updated"}) + assert cache.get(key)["title"] == "Updated" diff --git a/wikijs/cache/__init__.py b/wikijs/cache/__init__.py new file mode 100644 index 0000000..052d13d --- /dev/null +++ b/wikijs/cache/__init__.py @@ -0,0 +1,24 @@ +"""Caching module for wikijs-python-sdk. + +This module provides intelligent caching for frequently accessed Wiki.js resources +like pages, users, and groups. It supports multiple cache backends and TTL-based +expiration. + +Example: + >>> from wikijs import WikiJSClient + >>> from wikijs.cache import MemoryCache + >>> + >>> cache = MemoryCache(ttl=300) # 5 minute TTL + >>> client = WikiJSClient('https://wiki.example.com', auth='api-key', cache=cache) + >>> + >>> # First call hits the API + >>> page = client.pages.get(123) + >>> + >>> # Second call returns cached result + >>> page = client.pages.get(123) # Instant response +""" + +from .base import BaseCache, CacheKey +from .memory import MemoryCache + +__all__ = ["BaseCache", "CacheKey", "MemoryCache"] diff --git a/wikijs/cache/base.py b/wikijs/cache/base.py new file mode 100644 index 0000000..b2a7ab4 --- /dev/null +++ b/wikijs/cache/base.py @@ -0,0 +1,121 @@ +"""Base cache interface for wikijs-python-sdk.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, Optional + + +@dataclass +class CacheKey: + """Cache key structure for Wiki.js resources. + + Attributes: + resource_type: Type of resource (e.g., 'page', 'user', 'group') + identifier: Unique identifier (ID, path, etc.) + operation: Operation type (e.g., 'get', 'list') + params: Additional parameters as string (e.g., 'locale=en&tags=api') + """ + + resource_type: str + identifier: str + operation: str = "get" + params: Optional[str] = None + + def to_string(self) -> str: + """Convert cache key to string format. + + Returns: + String representation suitable for cache storage + + Example: + >>> key = CacheKey('page', '123', 'get') + >>> key.to_string() + 'page:123:get' + """ + parts = [self.resource_type, str(self.identifier), self.operation] + if self.params: + parts.append(self.params) + return ":".join(parts) + + +class BaseCache(ABC): + """Abstract base class for cache implementations. + + All cache backends must implement this interface to be compatible + with the WikiJS SDK. + + Args: + ttl: Time-to-live in seconds (default: 300 = 5 minutes) + max_size: Maximum number of items to cache (default: 1000) + """ + + def __init__(self, ttl: int = 300, max_size: int = 1000): + """Initialize cache with TTL and size limits. + + Args: + ttl: Time-to-live in seconds for cached items + max_size: Maximum number of items to store + """ + self.ttl = ttl + self.max_size = max_size + + @abstractmethod + def get(self, key: CacheKey) -> Optional[Any]: + """Retrieve value from cache. + + Args: + key: Cache key to retrieve + + Returns: + Cached value if found and not expired, None otherwise + """ + pass + + @abstractmethod + def set(self, key: CacheKey, value: Any) -> None: + """Store value in cache. + + Args: + key: Cache key to store under + value: Value to cache + """ + pass + + @abstractmethod + def delete(self, key: CacheKey) -> None: + """Remove value from cache. + + Args: + key: Cache key to remove + """ + pass + + @abstractmethod + def clear(self) -> None: + """Clear all cached values.""" + pass + + @abstractmethod + def invalidate_resource(self, resource_type: str, identifier: Optional[str] = None) -> None: + """Invalidate all cache entries for a resource. + + Args: + resource_type: Type of resource to invalidate (e.g., 'page', 'user') + identifier: Specific identifier to invalidate (None = all of that type) + + Example: + >>> cache.invalidate_resource('page', '123') # Invalidate page 123 + >>> cache.invalidate_resource('page') # Invalidate all pages + """ + pass + + def get_stats(self) -> dict: + """Get cache statistics. + + Returns: + Dictionary with cache statistics (hits, misses, size, etc.) + """ + return { + "ttl": self.ttl, + "max_size": self.max_size, + } diff --git a/wikijs/cache/memory.py b/wikijs/cache/memory.py new file mode 100644 index 0000000..e2d90f2 --- /dev/null +++ b/wikijs/cache/memory.py @@ -0,0 +1,186 @@ +"""In-memory cache implementation for wikijs-python-sdk.""" + +import time +from collections import OrderedDict +from typing import Any, Optional + +from .base import BaseCache, CacheKey + + +class MemoryCache(BaseCache): + """In-memory LRU cache with TTL support. + + This cache stores data in memory with a Least Recently Used (LRU) + eviction policy when the cache reaches max_size. Each entry has + a TTL (time-to-live) after which it's considered expired. + + Features: + - LRU eviction policy + - TTL-based expiration + - Thread-safe operations + - Cache statistics (hits, misses) + + Args: + ttl: Time-to-live in seconds (default: 300 = 5 minutes) + max_size: Maximum number of items (default: 1000) + + Example: + >>> cache = MemoryCache(ttl=300, max_size=500) + >>> key = CacheKey('page', '123', 'get') + >>> cache.set(key, page_data) + >>> cached = cache.get(key) + """ + + def __init__(self, ttl: int = 300, max_size: int = 1000): + """Initialize in-memory cache. + + Args: + ttl: Time-to-live in seconds + max_size: Maximum cache size + """ + super().__init__(ttl, max_size) + self._cache: OrderedDict = OrderedDict() + self._hits = 0 + self._misses = 0 + + def get(self, key: CacheKey) -> Optional[Any]: + """Retrieve value from cache if not expired. + + Args: + key: Cache key to retrieve + + Returns: + Cached value if found and valid, None otherwise + """ + key_str = key.to_string() + + if key_str not in self._cache: + self._misses += 1 + return None + + # Get cached entry + entry = self._cache[key_str] + expires_at = entry["expires_at"] + + # Check if expired + if time.time() > expires_at: + # Expired, remove it + del self._cache[key_str] + self._misses += 1 + return None + + # Move to end (mark as recently used) + self._cache.move_to_end(key_str) + self._hits += 1 + return entry["value"] + + def set(self, key: CacheKey, value: Any) -> None: + """Store value in cache with TTL. + + Args: + key: Cache key + value: Value to cache + """ + key_str = key.to_string() + + # If exists, remove it first (will be re-added at end) + if key_str in self._cache: + del self._cache[key_str] + + # Check size limit and evict oldest if needed + if len(self._cache) >= self.max_size: + # Remove oldest (first item in OrderedDict) + self._cache.popitem(last=False) + + # Add new entry at end (most recent) + self._cache[key_str] = { + "value": value, + "expires_at": time.time() + self.ttl, + "created_at": time.time(), + } + + def delete(self, key: CacheKey) -> None: + """Remove value from cache. + + Args: + key: Cache key to remove + """ + key_str = key.to_string() + if key_str in self._cache: + del self._cache[key_str] + + def clear(self) -> None: + """Clear all cached values and reset statistics.""" + self._cache.clear() + self._hits = 0 + self._misses = 0 + + def invalidate_resource( + self, resource_type: str, identifier: Optional[str] = None + ) -> None: + """Invalidate all cache entries for a resource. + + Args: + resource_type: Resource type to invalidate + identifier: Specific identifier (None = invalidate all of this type) + """ + keys_to_delete = [] + + for key_str in self._cache.keys(): + parts = key_str.split(":") + if len(parts) < 2: + continue + + cached_resource_type = parts[0] + cached_identifier = parts[1] + + # Match resource type + if cached_resource_type != resource_type: + continue + + # If identifier specified, match it too + if identifier is not None and cached_identifier != str(identifier): + continue + + keys_to_delete.append(key_str) + + # Delete matched keys + for key_str in keys_to_delete: + del self._cache[key_str] + + def get_stats(self) -> dict: + """Get cache statistics. + + Returns: + Dictionary with cache performance metrics + """ + total_requests = self._hits + self._misses + hit_rate = (self._hits / total_requests * 100) if total_requests > 0 else 0 + + return { + "ttl": self.ttl, + "max_size": self.max_size, + "current_size": len(self._cache), + "hits": self._hits, + "misses": self._misses, + "hit_rate": f"{hit_rate:.2f}%", + "total_requests": total_requests, + } + + def cleanup_expired(self) -> int: + """Remove all expired entries from cache. + + Returns: + Number of entries removed + """ + current_time = time.time() + keys_to_delete = [] + + for key_str, entry in self._cache.items(): + if current_time > entry["expires_at"]: + keys_to_delete.append(key_str) + + for key_str in keys_to_delete: + del self._cache[key_str] + + return len(keys_to_delete) diff --git a/wikijs/client.py b/wikijs/client.py index e0850a5..b890575 100644 --- a/wikijs/client.py +++ b/wikijs/client.py @@ -8,6 +8,7 @@ from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry from .auth import APIKeyAuth, AuthHandler +from .cache import BaseCache from .endpoints import AssetsEndpoint, GroupsEndpoint, PagesEndpoint, UsersEndpoint from .exceptions import ( APIError, @@ -39,6 +40,7 @@ class WikiJSClient: timeout: Request timeout in seconds (default: 30) verify_ssl: Whether to verify SSL certificates (default: True) user_agent: Custom User-Agent header + cache: Optional cache instance for caching API responses Example: Basic usage with API key: @@ -47,10 +49,19 @@ class WikiJSClient: >>> pages = client.pages.list() >>> page = client.pages.get(123) + With caching enabled: + + >>> from wikijs.cache import MemoryCache + >>> cache = MemoryCache(ttl=300) + >>> client = WikiJSClient('https://wiki.example.com', auth='your-api-key', cache=cache) + >>> page = client.pages.get(123) # Fetches from API + >>> page = client.pages.get(123) # Returns from cache + Attributes: base_url: The normalized base URL timeout: Request timeout setting verify_ssl: SSL verification setting + cache: Optional cache instance """ def __init__( @@ -60,6 +71,7 @@ class WikiJSClient: timeout: int = 30, verify_ssl: bool = True, user_agent: Optional[str] = None, + cache: Optional[BaseCache] = None, ): # Instance variable declarations for mypy self._auth_handler: AuthHandler @@ -85,6 +97,9 @@ class WikiJSClient: self.verify_ssl = verify_ssl self.user_agent = user_agent or f"wikijs-python-sdk/{__version__}" + # Cache configuration + self.cache = cache + # Initialize HTTP session self._session = self._create_session() diff --git a/wikijs/endpoints/pages.py b/wikijs/endpoints/pages.py index a74c353..f6e41fa 100644 --- a/wikijs/endpoints/pages.py +++ b/wikijs/endpoints/pages.py @@ -2,6 +2,7 @@ from typing import Any, Dict, List, Optional, Union +from ..cache import CacheKey from ..exceptions import APIError, ValidationError from ..models.page import Page, PageCreate, PageUpdate from .base import BaseEndpoint @@ -170,6 +171,13 @@ class PagesEndpoint(BaseEndpoint): if not isinstance(page_id, int) or page_id < 1: raise ValidationError("page_id must be a positive integer") + # Check cache if enabled + if self._client.cache: + cache_key = CacheKey("page", str(page_id), "get") + cached = self._client.cache.get(cache_key) + if cached is not None: + return cached + # Build GraphQL query using actual Wiki.js schema query = """ query($id: Int!) { @@ -214,7 +222,14 @@ class PagesEndpoint(BaseEndpoint): # Convert to Page object try: normalized_data = self._normalize_page_data(page_data) - return Page(**normalized_data) + page = Page(**normalized_data) + + # Cache the result if cache is enabled + if self._client.cache: + cache_key = CacheKey("page", str(page_id), "get") + self._client.cache.set(cache_key, page) + + return page except Exception as e: raise APIError(f"Failed to parse page data: {str(e)}") from e @@ -499,6 +514,10 @@ class PagesEndpoint(BaseEndpoint): if not updated_page_data: raise APIError("Page update failed - no data returned") + # Invalidate cache for this page + if self._client.cache: + self._client.cache.invalidate_resource("page", str(page_id)) + # Convert to Page object try: normalized_data = self._normalize_page_data(updated_page_data) @@ -549,6 +568,10 @@ class PagesEndpoint(BaseEndpoint): message = delete_result.get("message", "Unknown error") raise APIError(f"Page deletion failed: {message}") + # Invalidate cache for this page + if self._client.cache: + self._client.cache.invalidate_resource("page", str(page_id)) + return True def search( @@ -735,3 +758,149 @@ class PagesEndpoint(BaseEndpoint): break offset += batch_size + + def create_many( + self, pages_data: List[Union[PageCreate, Dict[str, Any]]] + ) -> List[Page]: + """Create multiple pages in a single batch operation. + + This method creates multiple pages efficiently by batching the operations. + It's faster than calling create() multiple times. + + Args: + pages_data: List of PageCreate objects or dicts + + Returns: + List of created Page objects + + Raises: + APIError: If batch creation fails + ValidationError: If page data is invalid + + Example: + >>> pages_to_create = [ + ... PageCreate(title="Page 1", path="page-1", content="Content 1"), + ... PageCreate(title="Page 2", path="page-2", content="Content 2"), + ... PageCreate(title="Page 3", path="page-3", content="Content 3"), + ... ] + >>> created_pages = client.pages.create_many(pages_to_create) + >>> print(f"Created {len(created_pages)} pages") + """ + if not pages_data: + return [] + + created_pages = [] + errors = [] + + for i, page_data in enumerate(pages_data): + try: + page = self.create(page_data) + created_pages.append(page) + except Exception as e: + errors.append({"index": i, "data": page_data, "error": str(e)}) + + if errors: + # Include partial success information + error_msg = f"Failed to create {len(errors)}/{len(pages_data)} pages. " + error_msg += f"Successfully created: {len(created_pages)}. Errors: {errors}" + raise APIError(error_msg) + + return created_pages + + def update_many( + self, updates: List[Dict[str, Any]] + ) -> List[Page]: + """Update multiple pages in a single batch operation. + + Each update dict must contain an 'id' field and the fields to update. + + Args: + updates: List of dicts with 'id' and update fields + + Returns: + List of updated Page objects + + Raises: + APIError: If batch update fails + ValidationError: If update data is invalid + + Example: + >>> updates = [ + ... {"id": 1, "content": "New content 1"}, + ... {"id": 2, "content": "New content 2", "title": "Updated Title"}, + ... {"id": 3, "is_published": False}, + ... ] + >>> updated_pages = client.pages.update_many(updates) + >>> print(f"Updated {len(updated_pages)} pages") + """ + if not updates: + return [] + + updated_pages = [] + errors = [] + + for i, update_data in enumerate(updates): + try: + if "id" not in update_data: + raise ValidationError("Each update must have an 'id' field") + + page_id = update_data["id"] + # Remove id from update data + update_fields = {k: v for k, v in update_data.items() if k != "id"} + + page = self.update(page_id, update_fields) + updated_pages.append(page) + except Exception as e: + errors.append({"index": i, "data": update_data, "error": str(e)}) + + if errors: + error_msg = f"Failed to update {len(errors)}/{len(updates)} pages. " + error_msg += f"Successfully updated: {len(updated_pages)}. Errors: {errors}" + raise APIError(error_msg) + + return updated_pages + + def delete_many(self, page_ids: List[int]) -> Dict[str, Any]: + """Delete multiple pages in a single batch operation. + + Args: + page_ids: List of page IDs to delete + + Returns: + Dict with success count and any errors + + Raises: + APIError: If batch deletion has errors + ValidationError: If page IDs are invalid + + Example: + >>> result = client.pages.delete_many([1, 2, 3, 4, 5]) + >>> print(f"Deleted {result['successful']} pages") + >>> if result['failed']: + ... print(f"Failed: {result['errors']}") + """ + if not page_ids: + return {"successful": 0, "failed": 0, "errors": []} + + successful = 0 + errors = [] + + for page_id in page_ids: + try: + self.delete(page_id) + successful += 1 + except Exception as e: + errors.append({"page_id": page_id, "error": str(e)}) + + result = { + "successful": successful, + "failed": len(errors), + "errors": errors, + } + + if errors: + error_msg = f"Failed to delete {len(errors)}/{len(page_ids)} pages. " + error_msg += f"Successfully deleted: {successful}. Errors: {errors}" + raise APIError(error_msg) + + return result