Merge pull request #2 from l3ocho/claude/sdk-improvements-011CUNfB1LKh2uZDNLMZ3PHe
Claude/sdk improvements 011 cu nf b1 l kh2u zdnlmz3 p he
This commit is contained in:
376
CLAUDE.md
376
CLAUDE.md
@@ -155,82 +155,308 @@ Task_Breakdown:
|
||||
Note: "GitHub-only deployment strategy implemented"
|
||||
```
|
||||
|
||||
### **Phase 2: Essential Features (0% COMPLETE) ⏳**
|
||||
### **Phase 2: Essential Features + Async Support (0% COMPLETE) ⏳**
|
||||
```yaml
|
||||
Status: PLANNED
|
||||
Status: READY_TO_START
|
||||
Completion: 0%
|
||||
Target_Start: "After Phase 1 Complete"
|
||||
Target_Duration: "3-4 weeks"
|
||||
Target_Version: "v0.2.0"
|
||||
Current_Task: "Task 2.1 - Async/Await Implementation"
|
||||
|
||||
Task_Breakdown:
|
||||
Task_2.1_Async_Support: # ⏳ READY
|
||||
Status: "READY"
|
||||
Completion: 0%
|
||||
Priority: "HIGH"
|
||||
Estimated_Time: "15-17 hours"
|
||||
AI_Sessions: "50-65"
|
||||
Key_Deliverables:
|
||||
- Dual client architecture (sync + async)
|
||||
- AsyncWikiJSClient with aiohttp
|
||||
- Async endpoint handlers
|
||||
- Performance benchmarks (>3x improvement)
|
||||
|
||||
Task_2.2_API_Expansion: # ⏳ READY
|
||||
Status: "READY"
|
||||
Completion: 0%
|
||||
Priority: "HIGH"
|
||||
Estimated_Time: "22-28 hours"
|
||||
AI_Sessions: "80-100"
|
||||
|
||||
Subtasks:
|
||||
- Users API (8-10h, 30-35 sessions)
|
||||
- Groups API (6-8h, 25-30 sessions)
|
||||
- Assets API (8-10h, 30-35 sessions)
|
||||
- Auto-Pagination (4-5h, 15-20 sessions)
|
||||
|
||||
Task_2.3_Testing_Documentation: # ⏳ READY
|
||||
Status: "READY"
|
||||
Completion: 0%
|
||||
Priority: "HIGH"
|
||||
Estimated_Time: "8-10 hours"
|
||||
AI_Sessions: "30-40"
|
||||
Requirements:
|
||||
- >95% test coverage for all new features
|
||||
- Complete API documentation
|
||||
- Usage examples for each API
|
||||
- Performance benchmarks
|
||||
|
||||
Success_Criteria:
|
||||
- [ ] Async client achieves >3x throughput vs sync
|
||||
- [ ] All Wiki.js APIs covered (Pages, Users, Groups, Assets)
|
||||
- [ ] >90% overall test coverage
|
||||
- [ ] Complete documentation with examples
|
||||
- [ ] Beta testing with 3+ users completed
|
||||
|
||||
Reference: "See docs/IMPROVEMENT_PLAN.md for detailed specifications"
|
||||
```
|
||||
|
||||
### **Phase 3: Reliability & Performance (0% COMPLETE) ⏳**
|
||||
```yaml
|
||||
Status: PLANNED
|
||||
Completion: 0%
|
||||
Target_Duration: "3-4 weeks"
|
||||
Target_Version: "v0.3.0"
|
||||
Target_Start: "After Phase 2 Complete"
|
||||
|
||||
Task_Breakdown:
|
||||
Task_3.1_Intelligent_Caching: # ⏳ PLANNED
|
||||
Status: "PLANNED"
|
||||
Completion: 0%
|
||||
Estimated_Time: "10-12 hours"
|
||||
AI_Sessions: "35-40"
|
||||
Features:
|
||||
- Pluggable cache backends (Memory, Redis, File)
|
||||
- Smart invalidation strategies
|
||||
- Thread-safe implementation
|
||||
- Cache hit ratio >80%
|
||||
|
||||
Task_3.2_Batch_Operations: # ⏳ PLANNED
|
||||
Status: "PLANNED"
|
||||
Completion: 0%
|
||||
Estimated_Time: "8-10 hours"
|
||||
AI_Sessions: "30-35"
|
||||
Features:
|
||||
- GraphQL batch query optimization
|
||||
- Batch CRUD operations
|
||||
- Partial failure handling
|
||||
- >10x performance improvement
|
||||
|
||||
Task_3.3_Rate_Limiting: # ⏳ PLANNED
|
||||
Status: "PLANNED"
|
||||
Completion: 0%
|
||||
Estimated_Time: "5-6 hours"
|
||||
AI_Sessions: "20-25"
|
||||
Features:
|
||||
- Token bucket algorithm
|
||||
- Configurable rate limits
|
||||
- Per-endpoint limits
|
||||
- Graceful handling
|
||||
|
||||
Task_3.4_Circuit_Breaker: # ⏳ PLANNED
|
||||
Status: "PLANNED"
|
||||
Completion: 0%
|
||||
Estimated_Time: "8-10 hours"
|
||||
AI_Sessions: "30-35"
|
||||
Features:
|
||||
- Circuit breaker pattern
|
||||
- Enhanced retry with exponential backoff
|
||||
- Automatic recovery
|
||||
- Failure detection <100ms
|
||||
|
||||
Success_Criteria:
|
||||
- [ ] Caching improves performance >50%
|
||||
- [ ] Batch operations >10x faster
|
||||
- [ ] System handles 1000+ concurrent requests
|
||||
- [ ] Circuit breaker prevents cascading failures
|
||||
- [ ] 24+ hour stability tests pass
|
||||
|
||||
Reference: "See docs/IMPROVEMENT_PLAN.md for detailed specifications"
|
||||
```
|
||||
|
||||
### **Phase 4: Advanced Features (0% COMPLETE) ⏳**
|
||||
```yaml
|
||||
Status: PLANNED
|
||||
Completion: 0%
|
||||
Target_Duration: "4-5 weeks"
|
||||
Target_Version: "v1.0.0"
|
||||
Target_Start: "After Phase 3 Complete"
|
||||
|
||||
Task_Breakdown:
|
||||
Task_4.1_Advanced_CLI: # ⏳ PLANNED
|
||||
Status: "PLANNED"
|
||||
Completion: 0%
|
||||
Estimated_Time: "12-15 hours"
|
||||
Features:
|
||||
- Interactive mode
|
||||
- Rich formatting
|
||||
- Progress bars
|
||||
- Bulk operations
|
||||
|
||||
Task_4.2_Plugin_Architecture: # ⏳ PLANNED
|
||||
Status: "PLANNED"
|
||||
Completion: 0%
|
||||
Estimated_Time: "10-12 hours"
|
||||
Features:
|
||||
- Middleware system
|
||||
- Custom auth providers
|
||||
- Plugin ecosystem
|
||||
- Extension points
|
||||
|
||||
Task_4.3_Webhook_Support: # ⏳ PLANNED
|
||||
Status: "PLANNED"
|
||||
Completion: 0%
|
||||
Estimated_Time: "8-10 hours"
|
||||
Features:
|
||||
- Webhook server
|
||||
- Event handlers
|
||||
- Signature verification
|
||||
- Async event processing
|
||||
|
||||
Success_Criteria:
|
||||
- [ ] CLI covers all major operations
|
||||
- [ ] Plugin system supports common use cases
|
||||
- [ ] Webhook handling is secure and reliable
|
||||
- [ ] Feature parity with official SDKs
|
||||
- [ ] Enterprise production deployments
|
||||
|
||||
Reference: "See docs/IMPROVEMENT_PLAN.md for detailed specifications"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 CURRENT FOCUS: TASK 1.1 - PROJECT FOUNDATION
|
||||
## 🎯 CURRENT FOCUS: PHASE 2 - ESSENTIAL FEATURES + ASYNC SUPPORT
|
||||
|
||||
### **Task 1.1 Detailed Breakdown**
|
||||
### **Phase 1 Completion Summary** ✅
|
||||
```yaml
|
||||
Task: "Project Foundation Setup"
|
||||
Status: "IN_PROGRESS"
|
||||
Priority: "HIGH"
|
||||
Completion: 0%
|
||||
Phase_1_Status: "COMPLETE"
|
||||
Completion: 100%
|
||||
Delivered:
|
||||
- ✅ Complete project foundation
|
||||
- ✅ Core WikiJSClient implementation
|
||||
- ✅ Authentication system (API Key + JWT)
|
||||
- ✅ Pages API (full CRUD operations)
|
||||
- ✅ Comprehensive test suite (>85% coverage)
|
||||
- ✅ Complete documentation and examples
|
||||
- ✅ Gitea-only deployment ready
|
||||
|
||||
Subtasks:
|
||||
1.1.1_Repository_Structure:
|
||||
Description: "Create basic project file structure"
|
||||
Status: "PENDING"
|
||||
Files_To_Create:
|
||||
- docs/CONTRIBUTING.md
|
||||
- LICENSE (MIT)
|
||||
- .gitignore
|
||||
- setup.py
|
||||
- pyproject.toml
|
||||
- requirements.txt
|
||||
- requirements-dev.txt
|
||||
|
||||
1.1.2_Python_Packaging:
|
||||
Description: "Configure Python packaging and dependencies"
|
||||
Status: "PENDING"
|
||||
Files_To_Create:
|
||||
- setup.py with full configuration
|
||||
- pyproject.toml with tool configurations
|
||||
- requirements.txt with core dependencies
|
||||
- requirements-dev.txt with development tools
|
||||
|
||||
1.1.3_CI_CD_Pipeline:
|
||||
Description: "Set up GitHub Actions workflows"
|
||||
Status: "PENDING"
|
||||
Files_To_Create:
|
||||
- .gitea/workflows/test.yml
|
||||
- .gitea/workflows/release.yml
|
||||
|
||||
1.1.4_Initial_Documentation:
|
||||
Description: "Create contributor-focused documentation"
|
||||
Status: "PENDING"
|
||||
Files_To_Create:
|
||||
- docs/CONTRIBUTING.md (detailed contribution guide)
|
||||
- docs/CHANGELOG.md (version history template)
|
||||
Ready_For: "Phase 2 Development"
|
||||
```
|
||||
|
||||
### **Completion Criteria for Task 1.1**
|
||||
- [ ] All repository structure files created
|
||||
- [ ] Python packaging properly configured
|
||||
- [ ] CI/CD pipeline functional
|
||||
- [ ] Contributing guidelines complete
|
||||
- [ ] All files pass linting and validation
|
||||
- [ ] **UPDATE PROGRESS**: Set Task_1.1 completion to 100%
|
||||
### **Phase 2 - Ready to Start** 🚀
|
||||
|
||||
**NEXT IMMEDIATE ACTION**: Begin Task 2.1 - Async/Await Implementation
|
||||
|
||||
#### **Task 2.1: Async/Await Implementation (READY)**
|
||||
```yaml
|
||||
Priority: "HIGH"
|
||||
Status: "READY_TO_START"
|
||||
Target_Completion: "Week 2 of Phase 2"
|
||||
|
||||
Implementation_Steps:
|
||||
Step_1_Architecture:
|
||||
Description: "Create wikijs/aio/ module structure"
|
||||
Files_To_Create:
|
||||
- wikijs/aio/__init__.py
|
||||
- wikijs/aio/client.py
|
||||
- wikijs/aio/endpoints/__init__.py
|
||||
- wikijs/aio/endpoints/base.py
|
||||
- wikijs/aio/endpoints/pages.py
|
||||
Estimated: "3-4 hours"
|
||||
|
||||
Step_2_AsyncClient:
|
||||
Description: "Implement AsyncWikiJSClient with aiohttp"
|
||||
Key_Features:
|
||||
- Async context manager support
|
||||
- aiohttp.ClientSession management
|
||||
- Async _arequest() method
|
||||
- Connection pooling configuration
|
||||
Estimated: "6-8 hours"
|
||||
|
||||
Step_3_AsyncEndpoints:
|
||||
Description: "Create async endpoint classes"
|
||||
Files_To_Create:
|
||||
- Async versions of all Page operations
|
||||
- AsyncPagesEndpoint implementation
|
||||
- Reuse existing models and exceptions
|
||||
Estimated: "4-5 hours"
|
||||
|
||||
Step_4_Testing:
|
||||
Description: "Comprehensive async testing"
|
||||
Test_Requirements:
|
||||
- Unit tests (>95% coverage)
|
||||
- Integration tests with real Wiki.js
|
||||
- Concurrent request tests (100+ requests)
|
||||
- Performance benchmarks (async vs sync)
|
||||
Estimated: "4-5 hours"
|
||||
|
||||
Step_5_Documentation:
|
||||
Description: "Async usage documentation"
|
||||
Files_To_Create:
|
||||
- docs/async_usage.md
|
||||
- examples/async_basic_usage.py
|
||||
- Update README.md with async examples
|
||||
Estimated: "2-3 hours"
|
||||
|
||||
Quality_Gates:
|
||||
- [ ] All async methods maintain same interface as sync
|
||||
- [ ] Performance benchmarks show >3x improvement
|
||||
- [ ] No resource leaks (proper cleanup)
|
||||
- [ ] All tests pass with >95% coverage
|
||||
- [ ] Documentation covers 100% of async functionality
|
||||
|
||||
Success_Metrics:
|
||||
- Async client handles 100+ concurrent requests
|
||||
- >3x throughput compared to sync client
|
||||
- Zero breaking changes to existing sync API
|
||||
- Clear migration guide for sync → async
|
||||
```
|
||||
|
||||
#### **Task 2.2: API Expansion (NEXT)**
|
||||
**Status**: Starts after Task 2.1 complete
|
||||
**Priority**: HIGH
|
||||
|
||||
Priority order:
|
||||
1. Users API (Week 3)
|
||||
2. Groups API (Week 3-4)
|
||||
3. Assets API (Week 4)
|
||||
4. Auto-Pagination (Week 4)
|
||||
|
||||
See `docs/IMPROVEMENT_PLAN.md` for detailed specifications.
|
||||
|
||||
---
|
||||
|
||||
## 📋 DEVELOPMENT GUIDELINES FOR PHASE 2
|
||||
|
||||
### **Before Starting Each Task**:
|
||||
1. [ ] Review task specifications in `docs/IMPROVEMENT_PLAN.md`
|
||||
2. [ ] Check architectural guidelines in `docs/wikijs_sdk_architecture.md`
|
||||
3. [ ] Review risk considerations in `docs/RISK_MANAGEMENT.md`
|
||||
4. [ ] Update CLAUDE.md with task status
|
||||
|
||||
### **During Development**:
|
||||
1. [ ] Follow TDD approach (write tests first)
|
||||
2. [ ] Maintain >95% test coverage for new code
|
||||
3. [ ] Update documentation alongside code
|
||||
4. [ ] Run quality checks continuously (black, mypy, flake8)
|
||||
5. [ ] Update progress in CLAUDE.md after each step
|
||||
|
||||
### **After Completing Each Task**:
|
||||
1. [ ] All quality gates pass
|
||||
2. [ ] Integration tests pass with real Wiki.js instance
|
||||
3. [ ] Documentation reviewed and complete
|
||||
4. [ ] Update CLAUDE.md completion percentages
|
||||
5. [ ] Commit with descriptive message
|
||||
6. [ ] Prepare for next task
|
||||
|
||||
### **Quality Standards** (Non-Negotiable):
|
||||
- ✅ Test coverage >95% for new features
|
||||
- ✅ Type hints on 100% of public APIs
|
||||
- ✅ Docstrings on 100% of public methods
|
||||
- ✅ Black formatting passes
|
||||
- ✅ MyPy strict mode passes
|
||||
- ✅ Flake8 with zero errors
|
||||
- ✅ Bandit security scan passes
|
||||
|
||||
---
|
||||
|
||||
@@ -504,18 +730,52 @@ This document evolves based on development experience:
|
||||
|
||||
---
|
||||
|
||||
## 🚀 READY FOR DEVELOPMENT
|
||||
## 🚀 READY FOR PHASE 2 DEVELOPMENT
|
||||
|
||||
**CURRENT INSTRUCTION**: Phase 1 Complete - Gitea-Only Deployment Ready
|
||||
**CURRENT STATUS**: ✅ Phase 1 Complete - Ready for Phase 2
|
||||
|
||||
**FOCUS**: Project is ready for GitHub-only installation and usage
|
||||
**CURRENT INSTRUCTION**: Begin Phase 2 - Essential Features + Async Support
|
||||
|
||||
**SUCCESS CRITERIA**: Users can install via `pip install git+https://gitea.hotserv.cloud/lmiranda/wikijs-sdk-python.git`
|
||||
**IMMEDIATE NEXT TASK**: Task 2.1 - Async/Await Implementation
|
||||
|
||||
**DEPLOYMENT STRATEGY**: Gitea-only (no PyPI publishing required)
|
||||
**FOCUS AREAS**:
|
||||
1. **Primary**: Implement dual sync/async client architecture
|
||||
2. **Secondary**: Expand API coverage (Users, Groups, Assets)
|
||||
3. **Tertiary**: Auto-pagination and developer experience improvements
|
||||
|
||||
**REMEMBER**: Always refer to documentation, update progress, and maintain quality standards!
|
||||
**KEY DOCUMENTS TO REFERENCE**:
|
||||
- `docs/IMPROVEMENT_PLAN.md` - Detailed implementation specifications
|
||||
- `docs/wikijs_sdk_architecture.md` - Architectural patterns
|
||||
- `docs/RISK_MANAGEMENT.md` - Risk mitigation strategies
|
||||
- This file (CLAUDE.md) - Progress tracking and coordination
|
||||
|
||||
**PHASE 2 SUCCESS CRITERIA**:
|
||||
- [ ] Async client achieves >3x throughput vs sync (100 concurrent requests)
|
||||
- [ ] Complete API coverage: Pages, Users, Groups, Assets
|
||||
- [ ] >90% overall test coverage maintained
|
||||
- [ ] Comprehensive documentation with examples for all APIs
|
||||
- [ ] Beta testing completed with 3+ users
|
||||
- [ ] Zero breaking changes to existing v0.1.0 functionality
|
||||
|
||||
**DEPLOYMENT STRATEGY**:
|
||||
- Maintain backward compatibility with v0.1.0
|
||||
- Gitea-only deployment continues
|
||||
- Users install via: `pip install git+https://gitea.hotserv.cloud/lmiranda/wikijs-sdk-python.git@v0.2.0`
|
||||
|
||||
**DEVELOPMENT PRINCIPLES**:
|
||||
1. ✅ **Test-Driven Development**: Write tests first, then implementation
|
||||
2. ✅ **Documentation Alongside Code**: Update docs as you build
|
||||
3. ✅ **Quality Gates**: Every commit must pass linting, typing, and tests
|
||||
4. ✅ **Progress Tracking**: Update CLAUDE.md after every major step
|
||||
5. ✅ **Backward Compatibility**: No breaking changes without explicit approval
|
||||
|
||||
**REMEMBER**:
|
||||
- Always refer to `docs/IMPROVEMENT_PLAN.md` for detailed specifications
|
||||
- Update progress tracking in CLAUDE.md after each task
|
||||
- Maintain quality standards: >95% coverage, full type hints, complete docs
|
||||
- Run quality checks continuously (black, mypy, flake8, bandit)
|
||||
- Commit frequently with clear, descriptive messages
|
||||
|
||||
---
|
||||
|
||||
**🤖 AI Developer: You are ready to begin professional SDK development. Follow this coordinator for guidance, track progress diligently, and build something amazing!**
|
||||
**🤖 AI Developer: Phase 1 is complete! You are now ready to evolve the SDK with async support and expanded APIs. Follow the improvement plan, maintain quality standards, and build something enterprise-grade!**
|
||||
1252
docs/IMPROVEMENT_PLAN.md
Normal file
1252
docs/IMPROVEMENT_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,42 +13,44 @@ from wikijs.exceptions import AuthenticationError
|
||||
class TestJWTAuth:
|
||||
"""Test JWTAuth implementation."""
|
||||
|
||||
def test_init_with_valid_token(self, mock_jwt_token):
|
||||
def test_init_with_valid_token(self, mock_jwt_token, mock_wiki_base_url):
|
||||
"""Test initialization with valid JWT token."""
|
||||
auth = JWTAuth(mock_jwt_token)
|
||||
auth = JWTAuth(mock_jwt_token, mock_wiki_base_url)
|
||||
assert auth._token == mock_jwt_token
|
||||
assert auth._base_url == mock_wiki_base_url
|
||||
assert auth._refresh_token is None
|
||||
assert auth._expires_at is None
|
||||
|
||||
def test_init_with_all_parameters(self, mock_jwt_token):
|
||||
def test_init_with_all_parameters(self, mock_jwt_token, mock_wiki_base_url):
|
||||
"""Test initialization with all parameters."""
|
||||
refresh_token = "refresh-token-123"
|
||||
expires_at = time.time() + 3600
|
||||
|
||||
auth = JWTAuth(mock_jwt_token, refresh_token, expires_at)
|
||||
auth = JWTAuth(mock_jwt_token, mock_wiki_base_url, refresh_token, expires_at)
|
||||
assert auth._token == mock_jwt_token
|
||||
assert auth._base_url == mock_wiki_base_url
|
||||
assert auth._refresh_token == refresh_token
|
||||
assert auth._expires_at == expires_at
|
||||
|
||||
def test_init_with_whitespace_token(self):
|
||||
def test_init_with_whitespace_token(self, mock_wiki_base_url):
|
||||
"""Test initialization trims whitespace from token."""
|
||||
auth = JWTAuth(" test-token ")
|
||||
auth = JWTAuth(" test-token ", mock_wiki_base_url)
|
||||
assert auth._token == "test-token"
|
||||
|
||||
def test_init_with_empty_token_raises_error(self):
|
||||
def test_init_with_empty_token_raises_error(self, mock_wiki_base_url):
|
||||
"""Test that empty JWT token raises ValueError."""
|
||||
with pytest.raises(ValueError, match="JWT token cannot be empty"):
|
||||
JWTAuth("")
|
||||
JWTAuth("", mock_wiki_base_url)
|
||||
|
||||
def test_init_with_whitespace_only_token_raises_error(self):
|
||||
def test_init_with_whitespace_only_token_raises_error(self, mock_wiki_base_url):
|
||||
"""Test that whitespace-only JWT token raises ValueError."""
|
||||
with pytest.raises(ValueError, match="JWT token cannot be empty"):
|
||||
JWTAuth(" ")
|
||||
JWTAuth(" ", mock_wiki_base_url)
|
||||
|
||||
def test_init_with_none_raises_error(self):
|
||||
def test_init_with_none_raises_error(self, mock_wiki_base_url):
|
||||
"""Test that None JWT token raises ValueError."""
|
||||
with pytest.raises(ValueError, match="JWT token cannot be empty"):
|
||||
JWTAuth(None)
|
||||
JWTAuth(None, mock_wiki_base_url)
|
||||
|
||||
def test_get_headers_returns_bearer_token(self, jwt_auth, mock_jwt_token):
|
||||
"""Test that get_headers returns proper Authorization header."""
|
||||
@@ -60,17 +62,17 @@ class TestJWTAuth:
|
||||
}
|
||||
assert headers == expected_headers
|
||||
|
||||
def test_get_headers_attempts_refresh_if_invalid(self, mock_jwt_token):
|
||||
def test_get_headers_attempts_refresh_if_invalid(self, mock_jwt_token, mock_wiki_base_url):
|
||||
"""Test that get_headers attempts refresh if token is invalid."""
|
||||
# Create JWT with expired token
|
||||
expires_at = time.time() - 3600 # Expired 1 hour ago
|
||||
refresh_token = "refresh-token-123"
|
||||
|
||||
auth = JWTAuth(mock_jwt_token, refresh_token, expires_at)
|
||||
auth = JWTAuth(mock_jwt_token, mock_wiki_base_url, refresh_token, expires_at)
|
||||
|
||||
# Mock the refresh method to avoid actual implementation
|
||||
with patch.object(auth, "refresh") as mock_refresh:
|
||||
mock_refresh.side_effect = AuthenticationError("Refresh not implemented")
|
||||
mock_refresh.side_effect = AuthenticationError("Refresh failed")
|
||||
|
||||
with pytest.raises(AuthenticationError):
|
||||
auth.get_headers()
|
||||
@@ -81,28 +83,28 @@ class TestJWTAuth:
|
||||
"""Test that is_valid returns True for valid token without expiry."""
|
||||
assert jwt_auth.is_valid() is True
|
||||
|
||||
def test_is_valid_returns_true_for_non_expired_token(self, mock_jwt_token):
|
||||
def test_is_valid_returns_true_for_non_expired_token(self, mock_jwt_token, mock_wiki_base_url):
|
||||
"""Test that is_valid returns True for non-expired token."""
|
||||
expires_at = time.time() + 3600 # Expires in 1 hour
|
||||
auth = JWTAuth(mock_jwt_token, expires_at=expires_at)
|
||||
auth = JWTAuth(mock_jwt_token, mock_wiki_base_url, expires_at=expires_at)
|
||||
assert auth.is_valid() is True
|
||||
|
||||
def test_is_valid_returns_false_for_expired_token(self, mock_jwt_token):
|
||||
def test_is_valid_returns_false_for_expired_token(self, mock_jwt_token, mock_wiki_base_url):
|
||||
"""Test that is_valid returns False for expired token."""
|
||||
expires_at = time.time() - 3600 # Expired 1 hour ago
|
||||
auth = JWTAuth(mock_jwt_token, expires_at=expires_at)
|
||||
auth = JWTAuth(mock_jwt_token, mock_wiki_base_url, expires_at=expires_at)
|
||||
assert auth.is_valid() is False
|
||||
|
||||
def test_is_valid_considers_refresh_buffer(self, mock_jwt_token):
|
||||
def test_is_valid_considers_refresh_buffer(self, mock_jwt_token, mock_wiki_base_url):
|
||||
"""Test that is_valid considers refresh buffer."""
|
||||
# Token expires in 4 minutes (less than 5 minute buffer)
|
||||
expires_at = time.time() + 240
|
||||
auth = JWTAuth(mock_jwt_token, expires_at=expires_at)
|
||||
auth = JWTAuth(mock_jwt_token, mock_wiki_base_url, expires_at=expires_at)
|
||||
assert auth.is_valid() is False # Should be invalid due to buffer
|
||||
|
||||
def test_is_valid_returns_false_for_empty_token(self, mock_jwt_token):
|
||||
def test_is_valid_returns_false_for_empty_token(self, mock_jwt_token, mock_wiki_base_url):
|
||||
"""Test that is_valid handles edge cases."""
|
||||
auth = JWTAuth(mock_jwt_token)
|
||||
auth = JWTAuth(mock_jwt_token, mock_wiki_base_url)
|
||||
auth._token = ""
|
||||
assert auth.is_valid() is False
|
||||
|
||||
@@ -117,57 +119,60 @@ class TestJWTAuth:
|
||||
):
|
||||
jwt_auth.refresh()
|
||||
|
||||
def test_refresh_raises_not_implemented_error(self, mock_jwt_token):
|
||||
"""Test that refresh raises not implemented error."""
|
||||
def test_refresh_with_refresh_token(self, mock_jwt_token, mock_wiki_base_url):
|
||||
"""Test that refresh is now implemented with refresh token."""
|
||||
refresh_token = "refresh-token-123"
|
||||
auth = JWTAuth(mock_jwt_token, refresh_token)
|
||||
auth = JWTAuth(mock_jwt_token, mock_wiki_base_url, refresh_token)
|
||||
|
||||
with pytest.raises(
|
||||
AuthenticationError, match="JWT token refresh not yet implemented"
|
||||
):
|
||||
# Mock the requests.post call
|
||||
with patch("requests.post") as mock_post:
|
||||
# Simulate failed refresh (network issue)
|
||||
mock_post.side_effect = Exception("Network error")
|
||||
|
||||
with pytest.raises(AuthenticationError, match="Unexpected error during token refresh"):
|
||||
auth.refresh()
|
||||
|
||||
def test_is_expired_returns_false_no_expiry(self, jwt_auth):
|
||||
"""Test that is_expired returns False when no expiry set."""
|
||||
assert jwt_auth.is_expired() is False
|
||||
|
||||
def test_is_expired_returns_false_for_valid_token(self, mock_jwt_token):
|
||||
def test_is_expired_returns_false_for_valid_token(self, mock_jwt_token, mock_wiki_base_url):
|
||||
"""Test that is_expired returns False for valid token."""
|
||||
expires_at = time.time() + 3600 # Expires in 1 hour
|
||||
auth = JWTAuth(mock_jwt_token, expires_at=expires_at)
|
||||
auth = JWTAuth(mock_jwt_token, mock_wiki_base_url, expires_at=expires_at)
|
||||
assert auth.is_expired() is False
|
||||
|
||||
def test_is_expired_returns_true_for_expired_token(self, mock_jwt_token):
|
||||
def test_is_expired_returns_true_for_expired_token(self, mock_jwt_token, mock_wiki_base_url):
|
||||
"""Test that is_expired returns True for expired token."""
|
||||
expires_at = time.time() - 3600 # Expired 1 hour ago
|
||||
auth = JWTAuth(mock_jwt_token, expires_at=expires_at)
|
||||
auth = JWTAuth(mock_jwt_token, mock_wiki_base_url, expires_at=expires_at)
|
||||
assert auth.is_expired() is True
|
||||
|
||||
def test_time_until_expiry_returns_none_no_expiry(self, jwt_auth):
|
||||
"""Test that time_until_expiry returns None when no expiry set."""
|
||||
assert jwt_auth.time_until_expiry() is None
|
||||
|
||||
def test_time_until_expiry_returns_correct_delta(self, mock_jwt_token):
|
||||
def test_time_until_expiry_returns_correct_delta(self, mock_jwt_token, mock_wiki_base_url):
|
||||
"""Test that time_until_expiry returns correct timedelta."""
|
||||
expires_at = time.time() + 3600 # Expires in 1 hour
|
||||
auth = JWTAuth(mock_jwt_token, expires_at=expires_at)
|
||||
auth = JWTAuth(mock_jwt_token, mock_wiki_base_url, expires_at=expires_at)
|
||||
|
||||
time_left = auth.time_until_expiry()
|
||||
assert isinstance(time_left, timedelta)
|
||||
# Should be approximately 1 hour (allowing for small time differences)
|
||||
assert 3550 <= time_left.total_seconds() <= 3600
|
||||
|
||||
def test_time_until_expiry_returns_zero_for_expired(self, mock_jwt_token):
|
||||
def test_time_until_expiry_returns_zero_for_expired(self, mock_jwt_token, mock_wiki_base_url):
|
||||
"""Test that time_until_expiry returns zero for expired token."""
|
||||
expires_at = time.time() - 3600 # Expired 1 hour ago
|
||||
auth = JWTAuth(mock_jwt_token, expires_at=expires_at)
|
||||
auth = JWTAuth(mock_jwt_token, mock_wiki_base_url, expires_at=expires_at)
|
||||
|
||||
time_left = auth.time_until_expiry()
|
||||
assert time_left.total_seconds() == 0
|
||||
|
||||
def test_token_preview_masks_token(self, mock_jwt_token):
|
||||
def test_token_preview_masks_token(self, mock_jwt_token, mock_wiki_base_url):
|
||||
"""Test that token_preview masks the token for security."""
|
||||
auth = JWTAuth(mock_jwt_token)
|
||||
auth = JWTAuth(mock_jwt_token, mock_wiki_base_url)
|
||||
preview = auth.token_preview
|
||||
|
||||
assert preview != mock_jwt_token # Should not show full token
|
||||
@@ -175,25 +180,25 @@ class TestJWTAuth:
|
||||
assert preview.endswith(mock_jwt_token[-10:])
|
||||
assert "..." in preview
|
||||
|
||||
def test_token_preview_handles_short_token(self):
|
||||
def test_token_preview_handles_short_token(self, mock_wiki_base_url):
|
||||
"""Test that token_preview handles short tokens."""
|
||||
short_token = "short"
|
||||
auth = JWTAuth(short_token)
|
||||
auth = JWTAuth(short_token, mock_wiki_base_url)
|
||||
preview = auth.token_preview
|
||||
|
||||
assert preview == "*****" # Should be all asterisks
|
||||
|
||||
def test_token_preview_handles_none_token(self, mock_jwt_token):
|
||||
def test_token_preview_handles_none_token(self, mock_jwt_token, mock_wiki_base_url):
|
||||
"""Test that token_preview handles None token."""
|
||||
auth = JWTAuth(mock_jwt_token)
|
||||
auth = JWTAuth(mock_jwt_token, mock_wiki_base_url)
|
||||
auth._token = None
|
||||
|
||||
assert auth.token_preview == "None"
|
||||
|
||||
def test_repr_shows_masked_token(self, mock_jwt_token):
|
||||
def test_repr_shows_masked_token(self, mock_jwt_token, mock_wiki_base_url):
|
||||
"""Test that __repr__ shows masked token."""
|
||||
expires_at = time.time() + 3600
|
||||
auth = JWTAuth(mock_jwt_token, expires_at=expires_at)
|
||||
auth = JWTAuth(mock_jwt_token, mock_wiki_base_url, expires_at=expires_at)
|
||||
repr_str = repr(auth)
|
||||
|
||||
assert "JWTAuth" in repr_str
|
||||
@@ -207,12 +212,12 @@ class TestJWTAuth:
|
||||
jwt_auth.validate_credentials()
|
||||
assert jwt_auth.is_valid() is True
|
||||
|
||||
def test_refresh_token_whitespace_handling(self, mock_jwt_token):
|
||||
def test_refresh_token_whitespace_handling(self, mock_jwt_token, mock_wiki_base_url):
|
||||
"""Test that refresh token whitespace is handled correctly."""
|
||||
refresh_token = " refresh-token-123 "
|
||||
auth = JWTAuth(mock_jwt_token, refresh_token)
|
||||
auth = JWTAuth(mock_jwt_token, mock_wiki_base_url, refresh_token)
|
||||
assert auth._refresh_token == "refresh-token-123"
|
||||
|
||||
# Test None refresh token
|
||||
auth = JWTAuth(mock_jwt_token, None)
|
||||
auth = JWTAuth(mock_jwt_token, mock_wiki_base_url, None)
|
||||
assert auth._refresh_token is None
|
||||
|
||||
@@ -25,9 +25,9 @@ def api_key_auth(mock_api_key):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jwt_auth(mock_jwt_token):
|
||||
def jwt_auth(mock_jwt_token, mock_wiki_base_url):
|
||||
"""Fixture providing JWTAuth instance."""
|
||||
return JWTAuth(mock_jwt_token)
|
||||
return JWTAuth(mock_jwt_token, mock_wiki_base_url)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -81,40 +81,50 @@ class TestWikiJSClientTestConnection:
|
||||
"""Mock API key."""
|
||||
return "test-api-key-12345"
|
||||
|
||||
@patch("wikijs.client.requests.Session.get")
|
||||
def test_test_connection_success(self, mock_get, mock_wiki_base_url, mock_api_key):
|
||||
"""Test successful connection test."""
|
||||
@patch("wikijs.client.requests.Session.request")
|
||||
def test_test_connection_success(self, mock_request, mock_wiki_base_url, mock_api_key):
|
||||
"""Test successful connection test using GraphQL query."""
|
||||
mock_response = Mock()
|
||||
mock_response.ok = True
|
||||
mock_response.status_code = 200
|
||||
mock_get.return_value = mock_response
|
||||
mock_response.json.return_value = {
|
||||
"data": {
|
||||
"site": {
|
||||
"title": "Test Wiki"
|
||||
}
|
||||
}
|
||||
}
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
client = WikiJSClient(mock_wiki_base_url, auth=mock_api_key)
|
||||
result = client.test_connection()
|
||||
|
||||
assert result is True
|
||||
# Verify it made a POST request to GraphQL endpoint
|
||||
mock_request.assert_called_once()
|
||||
|
||||
@patch("wikijs.client.requests.Session.get")
|
||||
def test_test_connection_timeout(self, mock_get, mock_wiki_base_url, mock_api_key):
|
||||
@patch("wikijs.client.requests.Session.request")
|
||||
def test_test_connection_timeout(self, mock_request, mock_wiki_base_url, mock_api_key):
|
||||
"""Test connection test timeout."""
|
||||
import requests
|
||||
|
||||
mock_get.side_effect = requests.exceptions.Timeout("Request timed out")
|
||||
mock_request.side_effect = requests.exceptions.Timeout("Request timed out")
|
||||
|
||||
client = WikiJSClient(mock_wiki_base_url, auth=mock_api_key)
|
||||
|
||||
with pytest.raises(TimeoutError, match="Connection test timed out"):
|
||||
with pytest.raises(TimeoutError):
|
||||
client.test_connection()
|
||||
|
||||
@patch("wikijs.client.requests.Session.get")
|
||||
def test_test_connection_error(self, mock_get, mock_wiki_base_url, mock_api_key):
|
||||
@patch("wikijs.client.requests.Session.request")
|
||||
def test_test_connection_error(self, mock_request, mock_wiki_base_url, mock_api_key):
|
||||
"""Test connection test with connection error."""
|
||||
import requests
|
||||
|
||||
mock_get.side_effect = requests.exceptions.ConnectionError("Connection failed")
|
||||
mock_request.side_effect = requests.exceptions.ConnectionError("Connection failed")
|
||||
|
||||
client = WikiJSClient(mock_wiki_base_url, auth=mock_api_key)
|
||||
|
||||
with pytest.raises(ConnectionError, match="Cannot connect"):
|
||||
with pytest.raises(ConnectionError):
|
||||
client.test_connection()
|
||||
|
||||
def test_test_connection_no_base_url(self):
|
||||
@@ -336,7 +346,7 @@ class TestWikiJSClientContextManager:
|
||||
mock_session_class.return_value = mock_session
|
||||
|
||||
# Mock generic exception during connection test
|
||||
mock_session.get.side_effect = RuntimeError("Unexpected error")
|
||||
mock_session.request.side_effect = RuntimeError("Unexpected error")
|
||||
|
||||
client = WikiJSClient("https://wiki.example.com", auth="test-key")
|
||||
|
||||
|
||||
@@ -19,17 +19,23 @@ class JWTAuth(AuthHandler):
|
||||
|
||||
Args:
|
||||
token: The JWT token string.
|
||||
base_url: The base URL of the Wiki.js instance (needed for token refresh).
|
||||
refresh_token: Optional refresh token for automatic renewal.
|
||||
expires_at: Optional expiration timestamp (Unix timestamp).
|
||||
|
||||
Example:
|
||||
>>> auth = JWTAuth("eyJ0eXAiOiJKV1QiLCJhbGc...")
|
||||
>>> auth = JWTAuth(
|
||||
... token="eyJ0eXAiOiJKV1QiLCJhbGc...",
|
||||
... base_url="https://wiki.example.com",
|
||||
... refresh_token="refresh_token_here"
|
||||
... )
|
||||
>>> client = WikiJSClient("https://wiki.example.com", auth=auth)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
base_url: str,
|
||||
refresh_token: Optional[str] = None,
|
||||
expires_at: Optional[float] = None,
|
||||
) -> None:
|
||||
@@ -37,16 +43,21 @@ class JWTAuth(AuthHandler):
|
||||
|
||||
Args:
|
||||
token: The JWT token string.
|
||||
base_url: The base URL of the Wiki.js instance.
|
||||
refresh_token: Optional refresh token for automatic renewal.
|
||||
expires_at: Optional expiration timestamp (Unix timestamp).
|
||||
|
||||
Raises:
|
||||
ValueError: If token is empty or None.
|
||||
ValueError: If token or base_url is empty or None.
|
||||
"""
|
||||
if not token or not token.strip():
|
||||
raise ValueError("JWT token cannot be empty")
|
||||
|
||||
if not base_url or not base_url.strip():
|
||||
raise ValueError("Base URL cannot be empty")
|
||||
|
||||
self._token = token.strip()
|
||||
self._base_url = base_url.strip().rstrip("/")
|
||||
self._refresh_token = refresh_token.strip() if refresh_token else None
|
||||
self._expires_at = expires_at
|
||||
self._refresh_buffer = 300 # Refresh 5 minutes before expiration
|
||||
@@ -91,15 +102,13 @@ class JWTAuth(AuthHandler):
|
||||
def refresh(self) -> None:
|
||||
"""Refresh the JWT token using the refresh token.
|
||||
|
||||
This method attempts to refresh the JWT token using the refresh token.
|
||||
If no refresh token is available, it raises an AuthenticationError.
|
||||
|
||||
Note: This is a placeholder implementation. In a real implementation,
|
||||
this would make an HTTP request to the Wiki.js token refresh endpoint.
|
||||
This method attempts to refresh the JWT token using the refresh token
|
||||
by making a request to the Wiki.js authentication endpoint.
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If refresh token is not available or refresh fails.
|
||||
"""
|
||||
import requests
|
||||
from ..exceptions import AuthenticationError
|
||||
|
||||
if not self._refresh_token:
|
||||
@@ -107,17 +116,58 @@ class JWTAuth(AuthHandler):
|
||||
"JWT token expired and no refresh token available"
|
||||
)
|
||||
|
||||
# TODO: Implement actual token refresh logic
|
||||
# This would typically involve:
|
||||
# 1. Making a POST request to /auth/refresh endpoint
|
||||
# 2. Sending the refresh token
|
||||
# 3. Updating self._token and self._expires_at with the response
|
||||
try:
|
||||
# Make request to Wiki.js token refresh endpoint
|
||||
refresh_url = f"{self._base_url}/api/auth/refresh"
|
||||
|
||||
response = requests.post(
|
||||
refresh_url,
|
||||
json={"refreshToken": self._refresh_token},
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
# Check if request was successful
|
||||
if not response.ok:
|
||||
error_msg = "Token refresh failed"
|
||||
try:
|
||||
error_data = response.json()
|
||||
error_msg = error_data.get("message", error_msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise AuthenticationError(
|
||||
"JWT token refresh not yet implemented. "
|
||||
"Please provide a new token or use API key authentication."
|
||||
f"Failed to refresh JWT token: {error_msg} (HTTP {response.status_code})"
|
||||
)
|
||||
|
||||
# Parse response
|
||||
data = response.json()
|
||||
|
||||
# Update token and expiration
|
||||
if "token" in data:
|
||||
self._token = data["token"]
|
||||
|
||||
if "expiresAt" in data:
|
||||
self._expires_at = data["expiresAt"]
|
||||
elif "expires_at" in data:
|
||||
self._expires_at = data["expires_at"]
|
||||
|
||||
# Optionally update refresh token if a new one is provided
|
||||
if "refreshToken" in data:
|
||||
self._refresh_token = data["refreshToken"]
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise AuthenticationError(
|
||||
f"Failed to refresh JWT token: {str(e)}"
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise AuthenticationError(
|
||||
f"Unexpected error during token refresh: {str(e)}"
|
||||
) from e
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if the JWT token is expired.
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from .utils import (
|
||||
normalize_url,
|
||||
parse_wiki_response,
|
||||
)
|
||||
from .version import __version__
|
||||
|
||||
|
||||
class WikiJSClient:
|
||||
@@ -82,7 +83,7 @@ class WikiJSClient:
|
||||
# Request configuration
|
||||
self.timeout = timeout
|
||||
self.verify_ssl = verify_ssl
|
||||
self.user_agent = user_agent or "wikijs-python-sdk/0.1.0"
|
||||
self.user_agent = user_agent or f"wikijs-python-sdk/{__version__}"
|
||||
|
||||
# Initialize HTTP session
|
||||
self._session = self._create_session()
|
||||
@@ -229,6 +230,9 @@ class WikiJSClient:
|
||||
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
|
||||
|
||||
@@ -236,6 +240,7 @@ class WikiJSClient:
|
||||
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")
|
||||
@@ -244,20 +249,47 @@ class WikiJSClient:
|
||||
raise ConfigurationError("Authentication not configured")
|
||||
|
||||
try:
|
||||
# Try to hit a basic endpoint (will implement with actual endpoints)
|
||||
# For now, just test basic connectivity
|
||||
self._session.get(
|
||||
self.base_url, timeout=self.timeout, verify=self.verify_ssl
|
||||
# Test with minimal GraphQL query to validate API access
|
||||
query = """
|
||||
query {
|
||||
site {
|
||||
title
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
response = 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 requests.exceptions.Timeout:
|
||||
raise TimeoutError(
|
||||
f"Connection test timed out after {self.timeout} seconds"
|
||||
)
|
||||
except AuthenticationError:
|
||||
# Re-raise authentication errors as-is
|
||||
raise
|
||||
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise ConnectionError(f"Cannot connect to {self.base_url}: {str(e)}")
|
||||
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)}")
|
||||
|
||||
@@ -72,13 +72,19 @@ class RateLimitError(ClientError):
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class ConnectionError(WikiJSException):
|
||||
class ConnectionError(APIError):
|
||||
"""Raised when there's a connection issue."""
|
||||
|
||||
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None) -> None:
|
||||
super().__init__(message, status_code=None, response=None, details=details)
|
||||
|
||||
class TimeoutError(WikiJSException):
|
||||
|
||||
class TimeoutError(APIError):
|
||||
"""Raised when a request times out."""
|
||||
|
||||
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None) -> None:
|
||||
super().__init__(message, status_code=None, response=None, details=details)
|
||||
|
||||
|
||||
def create_api_error(status_code: int, message: str, response: Any = None) -> APIError:
|
||||
"""Create appropriate API error based on status code.
|
||||
|
||||
Reference in New Issue
Block a user