diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..f9e2b96e8 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Confluence Cloud API Configuration +# Copy this file to .secrets/test.env and fill in your actual credentials +# The .secrets/ directory is excluded from git for security + +# Your Confluence Cloud URL (e.g., https://your-domain.atlassian.net) +CONFLUENCE_URL=https://your-domain.atlassian.net + +# Your Atlassian account email +CONFLUENCE_USERNAME=your-email@domain.com + +# Your Confluence API token (generate at: https://id.atlassian.com/manage-profile/security/api-tokens) +CONFLUENCE_API_TOKEN=your-api-token-here + +# Optional: Space key for testing (will be created if it doesn't exist) +CONFLUENCE_TEST_SPACE=TESTSPACE \ No newline at end of file diff --git a/.gitignore b/.gitignore index 24f60434f..552e8c174 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,13 @@ .idea .sonarlint .pytest_cache + +# Development test files (not part of main project) +dev-tests/ + +# Kiro AI assistant files +.kiro/ + # Created by .ignore support plugin (hsz.mobi) ### Python template # Byte-compiled / optimized / DLL files @@ -123,3 +130,9 @@ venv_/ Pipfile Pipfile.lock + +# Kiro AI assistant artifacts +.kiro/ + +# Test configuration and credentials +test_config.py diff --git a/TESTING_SETUP.md b/TESTING_SETUP.md new file mode 100644 index 000000000..09b911953 --- /dev/null +++ b/TESTING_SETUP.md @@ -0,0 +1,95 @@ +# Testing Setup for Confluence v2 API Development + +This document explains how to set up secure credentials for testing the Confluence v2 API implementation. + +## Quick Setup + +1. **Create the secrets directory (if it doesn't exist):** + ```bash + mkdir .secrets + ``` + +2. **Copy the example environment file:** + ```bash + copy .env.example .secrets\test.env + ``` + +3. **Edit `.secrets\test.env` with your actual credentials:** + ```bash + + # Your Confluence Cloud URL + CONFLUENCE_URL=https://your-domain.atlassian.net + + # Your Atlassian account email + CONFLUENCE_USERNAME=your-email@domain.com + + # Your API token (generate at: https://id.atlassian.com/manage-profile/security/api-tokens) + CONFLUENCE_API_TOKEN=ATATT3xFfGF0...your-token-here + + # Optional: Test space key + CONFLUENCE_TEST_SPACE=TESTSPACE + ``` + +4. **Test the configuration:** + ```bash + python test_config.py + ``` + +## Generating an API Token + +1. Go to [Atlassian Account Security](https://id.atlassian.com/manage-profile/security/api-tokens) + +2. Click "Create API token" + +3. Give it a label like "Confluence v2 API Development" + +4. Copy the generated token to your `.env` file + +## Security Notes + +- ✅ `.secrets/` directory is excluded from git (never committed) + +- ✅ `test_config.py` is excluded from git (contains helper functions) + +- ✅ API tokens are more secure than passwords + +- ✅ Tokens can be revoked at any time from your Atlassian account + +## Testing Different Environments + +You can create multiple environment files for different test scenarios: + +```bash +.secrets\test.env # Main testing environment +.secrets\dev.env # Development Confluence instance +.secrets\staging.env # Staging environment +``` + +Load specific environments by modifying the `env_path` in `test_config.py`: +```python +env_path = os.path.join('.secrets', 'dev.env') # Load specific environment +``` + +## Troubleshooting + +### "Missing required environment variables" error + +- Ensure your `.secrets/test.env` file exists and contains all required variables + +- Check that variable names match exactly (case-sensitive) + +- Verify there are no extra spaces around the `=` sign + +### Authentication failures + +- Verify your API token is correct and not expired + +- Ensure your username is the email address associated with your Atlassian account + +- Check that your Confluence URL is correct (should end with .atlassian.net for Cloud) + +### Permission errors + +- Ensure your account has appropriate permissions in the Confluence space + +- For testing, you may need to create a dedicated test space where you have admin rights \ No newline at end of file diff --git a/atlassian/__init__.py b/atlassian/__init__.py index 5c17f3eed..8422e7dbb 100644 --- a/atlassian/__init__.py +++ b/atlassian/__init__.py @@ -19,7 +19,6 @@ from .tempo import TempoCloud, TempoServer from .xray import Xray - __all__ = [ "Confluence", "ConfluenceCloud", diff --git a/atlassian/adf.py b/atlassian/adf.py new file mode 100644 index 000000000..2636bff05 --- /dev/null +++ b/atlassian/adf.py @@ -0,0 +1,421 @@ +# coding=utf-8 +""" +Minimal ADF (Atlassian Document Format) data models and utilities. + +This module provides basic ADF document structure classes and validation +for Confluence Cloud v2 API support. ADF is the native content format +for Confluence Cloud that enables rich content creation with structured, +JSON-based document representation. + +Key Features: +- ADF document construction with Python classes +- Content validation and format detection +- Conversion utilities between different content formats +- Type-safe ADF node creation + +Classes: + ADFDocument: Root document container + ADFNode: Base class for all ADF nodes + ADFParagraph: Paragraph content node + ADFText: Text content with optional formatting + ADFHeading: Heading nodes (levels 1-6) + +Functions: + create_simple_adf_document: Quick ADF document creation + convert_text_to_adf: Convert plain text to ADF format + validate_adf_document: Validate ADF document structure + convert_storage_to_adf: Basic storage format conversion + convert_adf_to_storage: Basic ADF to storage conversion + +.. versionadded:: 4.1.0 + Added ADF support for Confluence Cloud v2 API + +Examples: + Create a simple ADF document: + + >>> from atlassian.adf import create_simple_adf_document + >>> doc = create_simple_adf_document("Hello, World!") + >>> adf_dict = doc.to_dict() + + Build complex ADF using classes: + + >>> from atlassian.adf import ADFDocument, ADFHeading, ADFParagraph, ADFText + >>> + >>> document = ADFDocument() + >>> heading = ADFHeading(level=1, content=[ADFText("My Document")]) + >>> paragraph = ADFParagraph([ + ... ADFText("This is "), + ... ADFText("bold text", marks=[{"type": "strong"}]) + ... ]) + >>> + >>> document.add_content(heading) + >>> document.add_content(paragraph) + >>> adf_dict = document.to_dict() + +See Also: + - :doc:`confluence_adf`: Complete ADF documentation and examples + - :class:`atlassian.confluence.ConfluenceCloud`: Main Confluence client +""" + +from typing import Dict, List, Any, Optional, Union + + +class ADFNode: + """ + Base class for ADF document nodes. + """ + + def __init__(self, node_type: str, **kwargs): + """ + Initialize an ADF node. + + :param node_type: The type of the node (e.g., 'doc', 'paragraph', 'text') + :param kwargs: Additional node attributes + """ + self.type: str = node_type + self.attrs: Dict[str, Any] = kwargs.get("attrs", {}) + self.content: List[Union["ADFNode", Dict[str, Any]]] = kwargs.get("content", []) + self.text: Optional[str] = kwargs.get("text") + self.marks: List[Dict[str, Any]] = kwargs.get("marks", []) + + def to_dict(self) -> Dict[str, Any]: + """ + Convert the node to a dictionary representation. + + :return: Dictionary representation of the node + """ + result: Dict[str, Any] = {"type": self.type} + + if self.attrs: + result["attrs"] = self.attrs + + if self.content: + result["content"] = [node.to_dict() if isinstance(node, ADFNode) else node for node in self.content] + + if self.text is not None: + result["text"] = self.text + + if self.marks: + result["marks"] = self.marks + + return result + + +class ADFDocument: + """ + Represents an ADF document. + + The root container for ADF content that maintains the document structure + and provides methods for content manipulation. Every ADF document must + have version=1, type="doc", and a content array. + + Attributes: + version (int): ADF specification version (always 1) + type (str): Document type (always "doc") + content (List[ADFNode]): List of content nodes + + Examples: + Create an empty document: + + >>> document = ADFDocument() + >>> print(document.to_dict()) + {'version': 1, 'type': 'doc', 'content': []} + + Create document with initial content: + + >>> paragraph = ADFParagraph([ADFText("Hello, World!")]) + >>> document = ADFDocument([paragraph]) + + Add content to existing document: + + >>> document = ADFDocument() + >>> heading = ADFHeading(level=1, content=[ADFText("Title")]) + >>> document.add_content(heading) + """ + + def __init__(self, content: Optional[List[ADFNode]] = None): + """ + Initialize an ADF document. + + :param content: List of content nodes + """ + self.version = 1 + self.type = "doc" + self.content = content or [] + + def add_content(self, node: ADFNode): + """ + Add a content node to the document. + + :param node: The node to add + """ + self.content.append(node) + + def to_dict(self) -> Dict[str, Any]: + """ + Convert the document to a dictionary representation. + + :return: Dictionary representation of the document + """ + return {"version": self.version, "type": self.type, "content": [node.to_dict() for node in self.content]} + + +class ADFParagraph(ADFNode): + """ + Represents an ADF paragraph node. + """ + + def __init__(self, content: Optional[List[ADFNode]] = None): + """ + Initialize a paragraph node. + + :param content: List of content nodes (typically text nodes) + """ + super().__init__("paragraph", content=content or []) + + +class ADFText(ADFNode): + """ + Represents an ADF text node. + """ + + def __init__(self, text: str, marks: Optional[List[Dict[str, Any]]] = None): + """ + Initialize a text node. + + :param text: The text content + :param marks: List of text formatting marks + """ + super().__init__("text", text=text, marks=marks or []) + + +class ADFHeading(ADFNode): + """ + Represents an ADF heading node. + """ + + def __init__(self, level: int, content: Optional[List[ADFNode]] = None): + """ + Initialize a heading node. + + :param level: Heading level (1-6) + :param content: List of content nodes + """ + if not 1 <= level <= 6: + raise ValueError("Heading level must be between 1 and 6") + + super().__init__("heading", attrs={"level": level}, content=content or []) + + +def create_simple_adf_document(text: str) -> ADFDocument: + """ + Create a simple ADF document with a single paragraph of text. + + This is a convenience function for quickly creating basic ADF documents + from plain text content. The resulting document contains a single + paragraph with the provided text. + + :param text: The text content for the paragraph + :return: ADF document containing a single paragraph with the text + + Examples: + Create a simple document: + + >>> doc = create_simple_adf_document("Hello, World!") + >>> adf_dict = doc.to_dict() + >>> print(adf_dict['content'][0]['type']) # 'paragraph' + + Use with Confluence API: + + >>> doc = create_simple_adf_document("My page content") + >>> page = confluence.create_page_with_adf("SPACE123", "My Page", doc.to_dict()) + """ + text_node = ADFText(text) + paragraph = ADFParagraph([text_node]) + document = ADFDocument([paragraph]) + return document + + +def validate_adf_document(adf_dict: Dict[str, Any]) -> bool: + """ + Validate an ADF document dictionary structure. + + Performs basic validation of ADF document structure to ensure it meets + the minimum requirements for a valid ADF document. Checks for required + fields and correct data types. + + :param adf_dict: Dictionary representation of ADF document to validate + :return: True if the document structure is valid, False otherwise + + Validation checks: + - Document is a dictionary + - Has required 'type' field with value 'doc' + - Has required 'version' field with value 1 + - Has required 'content' field that is a list + + Examples: + Validate a correct ADF document: + + >>> adf_doc = { + ... "version": 1, + ... "type": "doc", + ... "content": [ + ... { + ... "type": "paragraph", + ... "content": [{"type": "text", "text": "Hello"}] + ... } + ... ] + ... } + >>> is_valid = validate_adf_document(adf_doc) + >>> print(is_valid) # True + + Validate an invalid document: + + >>> invalid_doc = {"content": []} # Missing version and type + >>> is_valid = validate_adf_document(invalid_doc) + >>> print(is_valid) # False + + Use before API submission: + + >>> if validate_adf_document(adf_content): + ... page = confluence.create_page_with_adf("SPACE123", "Title", adf_content) + ... else: + ... print("Invalid ADF structure") + + Note: + This function performs basic structural validation only. It does not + validate the content of individual nodes or their specific attributes. + For more comprehensive validation, consider using the official + Atlassian ADF validator tools. + """ + if not isinstance(adf_dict, dict): + return False + + # Check required fields + if adf_dict.get("type") != "doc": + return False + + if adf_dict.get("version") != 1: + return False + + if "content" not in adf_dict: + return False + + if not isinstance(adf_dict["content"], list): + return False + + return True + + +def convert_text_to_adf(text: str) -> Dict[str, Any]: + """ + Convert plain text to ADF format. + + Creates a valid ADF document containing a single paragraph with the + provided text. This is useful for converting simple text content to + ADF format for use with Confluence Cloud v2 API. + + :param text: Plain text content to convert + :return: ADF document dictionary with the text in a paragraph + + Examples: + Convert simple text: + + >>> adf_dict = convert_text_to_adf("Hello, World!") + >>> print(adf_dict['content'][0]['content'][0]['text']) # "Hello, World!" + + Use with Confluence API: + + >>> text_content = "This is my page content" + >>> adf_content = convert_text_to_adf(text_content) + >>> page = confluence.create_page_with_adf("SPACE123", "My Page", adf_content) + + Convert multiline text: + + >>> multiline_text = "Line 1\\nLine 2\\nLine 3" + >>> adf_content = convert_text_to_adf(multiline_text) + # Creates a single paragraph with the full text including newlines + + Note: + This function creates a single paragraph containing all the text. + Newlines in the input text are preserved as literal newline characters + within the paragraph. For more complex text processing (like converting + newlines to separate paragraphs), additional processing is needed. + """ + document = create_simple_adf_document(text) + return document.to_dict() + + +def convert_storage_to_adf(storage_content: str) -> Dict[str, Any]: + """ + Convert Confluence storage format to ADF format (basic conversion). + + This is a minimal implementation that handles simple cases. + For complex storage format, consider using Atlassian's official converters. + + :param storage_content: Storage format content (HTML-like) + :return: ADF document dictionary + """ + # Basic conversion - treat as plain text for now + # In a full implementation, this would parse HTML and convert to ADF nodes + import re + + # Remove HTML tags for basic conversion + text_content = re.sub(r"<[^>]+>", "", storage_content) + text_content = text_content.strip() + + if not text_content: + text_content = "Empty content" + + return convert_text_to_adf(text_content) + + +def convert_adf_to_storage(adf_content: Dict[str, Any]) -> str: + """ + Convert ADF format to Confluence storage format (basic conversion). + + This is a minimal implementation that handles simple cases. + + :param adf_content: ADF document dictionary + :return: Storage format content (HTML-like) + """ + if not validate_adf_document(adf_content): + raise ValueError("Invalid ADF document structure") + + # Basic conversion - extract text content and wrap in paragraph + text_parts = [] + + def extract_text_from_node(node): + if isinstance(node, dict): + if node.get("type") == "text" and "text" in node: + text_parts.append(node["text"]) + elif "content" in node: + for child in node["content"]: + extract_text_from_node(child) + + for content_node in adf_content.get("content", []): + extract_text_from_node(content_node) + + text_content = " ".join(text_parts).strip() + if not text_content: + text_content = "Empty content" + + return f"
{text_content}
" + + +def validate_content_format(content: Union[str, Dict[str, Any]], expected_format: str) -> bool: + """ + Validate content format matches expected format. + + :param content: Content to validate + :param expected_format: Expected format ('adf', 'storage', 'wiki') + :return: True if format matches + """ + if expected_format == "adf": + return isinstance(content, dict) and validate_adf_document(content) + elif expected_format == "storage": + return isinstance(content, str) and content.strip().startswith("<") + elif expected_format == "wiki": + return isinstance(content, str) + else: + return False diff --git a/atlassian/bamboo.py b/atlassian/bamboo.py index a9fa51441..81dc3266a 100755 --- a/atlassian/bamboo.py +++ b/atlassian/bamboo.py @@ -62,17 +62,7 @@ def _get_generator( logging.error(f"Broken response: {response}") yield response - def base_list_call( - self, - resource, - expand, - favourite, - clover_enabled, - max_results, - label=None, - start_index=0, - **kwargs - ): # fmt: skip + def base_list_call(self, resource, expand, favourite, clover_enabled, max_results, label=None, start_index=0, **kwargs): # fmt: skip flags = [] params = {"max-results": max_results} if expand: @@ -622,14 +612,7 @@ def delete_build_result(self, build_key): params = {"buildKey": plan_key, "buildNumber": build_number} return self.post(custom_resource, params=params, headers=self.form_token_headers) - def execute_build( - self, - plan_key, - stage=None, - execute_all_stages=True, - custom_revision=None, - **bamboo_variables - ): # fmt: skip + def execute_build(self, plan_key, stage=None, execute_all_stages=True, custom_revision=None, **bamboo_variables): # fmt: skip """ Fire build execution for specified plan. !IMPORTANT! NOTE: for some reason, this method always execute all stages diff --git a/atlassian/bitbucket/__init__.py b/atlassian/bitbucket/__init__.py index 4e23a98a5..635285459 100644 --- a/atlassian/bitbucket/__init__.py +++ b/atlassian/bitbucket/__init__.py @@ -2568,15 +2568,7 @@ def delete_code_insights_report(self, project_key, repository_slug, commit_id, r url = self._url_code_insights_report(project_key, repository_slug, commit_id, report_key) return self.delete(url) - def create_code_insights_report( - self, - project_key, - repository_slug, - commit_id, - report_key, - report_title, - **report_params - ): # fmt: skip + def create_code_insights_report(self, project_key, repository_slug, commit_id, report_key, report_title, **report_params): # fmt: skip """ Create a new insight report, or replace the existing one if a report already exists for the given repository_slug, commit, and report key. diff --git a/atlassian/confluence/__init__.py b/atlassian/confluence/__init__.py index c84484fd2..0ad8af8c4 100644 --- a/atlassian/confluence/__init__.py +++ b/atlassian/confluence/__init__.py @@ -33,4 +33,5 @@ def __getattr__(self, attr): "ConfluenceCloud", "ConfluenceServer", "ConfluenceBase", + "Confluence", ] diff --git a/atlassian/confluence/cloud/__init__.py b/atlassian/confluence/cloud/__init__.py index d7c686072..7610edb56 100644 --- a/atlassian/confluence/cloud/__init__.py +++ b/atlassian/confluence/cloud/__init__.py @@ -1,43 +1,708 @@ # coding=utf-8 from .base import ConfluenceCloudBase +from .v2 import ConfluenceCloudV2 +from typing import Optional, Dict, Any, List +import logging +import warnings + +log = logging.getLogger(__name__) class Cloud(ConfluenceCloudBase): """ - Confluence Cloud REST API wrapper + Confluence Cloud REST API wrapper with dual API support. + + Supports both v1 (legacy) and v2 (modern) API endpoints with automatic + version selection and explicit v2 API usage options. """ def __init__(self, url="https://api.atlassian.com/", *args, **kwargs): + # Extract v2 API configuration options + self._force_v2_api = kwargs.pop("force_v2_api", False) + self._prefer_v2_api = kwargs.pop("prefer_v2_api", False) + # Set default values only if not provided if "cloud" not in kwargs: kwargs["cloud"] = True if "api_version" not in kwargs: - kwargs["api_version"] = "2" + kwargs["api_version"] = "latest" if "api_root" not in kwargs: - kwargs["api_root"] = "wiki/api/v2" + kwargs["api_root"] = "wiki/rest/api" url = url.strip("/") super(Cloud, self).__init__(url, *args, **kwargs) - # Content Management + # Initialize v2 API client for dual support + self._v2_client = None + if self._force_v2_api or self._prefer_v2_api: + self._init_v2_client() + + # Validate backward compatibility on initialization + self._validate_backward_compatibility() + + def _init_v2_client(self): + """Initialize the v2 API client for dual API support.""" + if self._v2_client is None: + try: + # Create v2 client with same configuration + v2_kwargs = { + "username": getattr(self, "username", None), + "password": getattr(self, "password", None), + "token": getattr(self, "token", None), + "session": self._session, + "timeout": getattr(self, "timeout", 75), + "verify_ssl": getattr(self, "verify_ssl", True), + "cloud": True, + } + + # Copy authentication details if available + if hasattr(self, "oauth") and self.oauth: + v2_kwargs["oauth"] = self.oauth + if hasattr(self, "oauth2") and self.oauth2: + v2_kwargs["oauth2"] = self.oauth2 + + self._v2_client = ConfluenceCloudV2(self.url, **v2_kwargs) + log.debug("Initialized Confluence Cloud v2 API client") + except Exception as e: + log.warning(f"Failed to initialize v2 API client: {e}") + self._v2_client = None + + def _should_use_v2_api(self, operation: Optional[str] = None) -> bool: + """ + Determine whether to use v2 API for a given operation. + + :param operation: Operation name (for future operation-specific routing) + :return: True if v2 API should be used + """ + if self._force_v2_api: + return True + + if self._prefer_v2_api and self._v2_client is not None: + return True + + # Future: Add operation-specific routing logic here + # For now, default to v1 API unless explicitly configured + return False + + def _route_to_v2_if_needed(self, method_name: str, *args, **kwargs): + """ + Route method call to v2 API if configured to do so. + + :param method_name: Name of the method to call + :param args: Method arguments + :param kwargs: Method keyword arguments + :return: Result from appropriate API version + """ + if self._should_use_v2_api(method_name): + if self._v2_client is None: + self._init_v2_client() + + if self._should_use_v2_api(method_name): + if self._v2_client is None: + self._init_v2_client() + + if self._v2_client is not None and hasattr(self._v2_client, method_name): + log.debug(f"Routing {method_name} to v2 API") + + # Handle parameter conversion for specific methods + if method_name == "get_spaces": + # Convert v1 params format to v2 direct parameters + if "params" in kwargs and isinstance(kwargs["params"], dict): + params = kwargs.pop("params") + if "limit" in params: + kwargs["limit"] = params["limit"] + if "cursor" in params: + kwargs["cursor"] = params["cursor"] + + return getattr(self._v2_client, method_name)(*args, **kwargs) + else: + log.warning(f"v2 API client not available or method {method_name} not found, falling back to v1") + + # Fall back to v1 API (current implementation) + return None + + def enable_v2_api(self, force: bool = False, prefer: bool = False): + """ + Enable v2 API usage for this client instance. + + This method enables Confluence Cloud v2 API support while maintaining complete + backward compatibility. All existing code will continue to work unchanged with + potential performance improvements. + + **Backward Compatibility:** Enabling v2 API does not break existing code. + All method signatures, parameter names, and return formats remain the same. + + **Performance Benefits:** v2 API provides enhanced performance for: + - Large search result sets with cursor-based pagination + - Content operations with native ADF support + - Bulk operations and content processing + + :param force: If True, force all operations to use v2 API when available. + If False, prefer v2 API but fall back to v1 when needed. + Default: False (recommended for gradual migration) + :param prefer: If True, prefer v2 API but allow fallback to v1. + Ignored if force=True. Default: False + + .. versionadded:: 4.1.0 + Added comprehensive v2 API support with backward compatibility. + + .. note:: + **Migration Strategy:** Start with ``prefer=True`` to enable v2 optimizations + while maintaining full compatibility. Use ``force=True`` only when you want + to ensure all operations use v2 API exclusively. + + **Requirements:** Requires valid Confluence Cloud authentication and + a Confluence instance that supports v2 API. + + Examples: + Enable v2 API with fallback support (recommended): + + >>> confluence.enable_v2_api() + >>> # Existing code works unchanged with potential performance improvements + >>> results = confluence.search_content("type=page", limit=100) + + Force v2 API usage exclusively: + + >>> confluence.enable_v2_api(force=True) + >>> # All operations will use v2 API when available + >>> page = confluence.get_content("123456") + + Check API configuration after enabling: + + >>> confluence.enable_v2_api() + >>> info = confluence.get_api_version_info() + >>> print(f"v2 API enabled: {info['v2_available']}") + >>> print(f"Current default: {info['current_default']}") + + Gradual migration approach: + + >>> # Step 1: Enable v2 API + >>> confluence.enable_v2_api() + >>> + >>> # Step 2: Existing code benefits automatically + >>> pages = confluence.search_content("type=page") + >>> + >>> # Step 3: Use new v2-specific methods for enhanced features + >>> adf_page = confluence.create_page_with_adf(space_id, title, adf_content) + + See Also: + - :meth:`disable_v2_api`: Disable v2 API and use only v1 API + - :meth:`get_api_version_info`: Check current API configuration + - :doc:`confluence_v2_migration`: Complete migration guide + """ + if force: + self._force_v2_api = True + self._prefer_v2_api = True + elif prefer: + self._force_v2_api = False + self._prefer_v2_api = True + else: + # Default behavior when called without parameters + self._force_v2_api = False + self._prefer_v2_api = True + + self._init_v2_client() + log.info(f"Enabled v2 API usage (force={force}, prefer={prefer or not force})") + + def disable_v2_api(self): + """Disable v2 API usage and use only v1 API.""" + self._force_v2_api = False + self._prefer_v2_api = False + log.info("Disabled v2 API usage, using v1 API only") + + def _validate_backward_compatibility(self): + """ + Validate that all existing method signatures are preserved. + + This method ensures that the dual API implementation maintains + complete backward compatibility with existing code. + """ + # All existing methods must be present with same signatures + required_methods = [ + # Content Management + "get_content", + "get_content_by_type", + "create_content", + "update_content", + "delete_content", + "get_content_children", + "get_content_descendants", + "get_content_ancestors", + # Space Management + "get_spaces", + "get_space", + "create_space", + "update_space", + "delete_space", + "get_space_content", + # User Management + "get_users", + "get_user", + "get_current_user", + # Group Management + "get_groups", + "get_group", + "get_group_members", + # Label Management + "get_labels", + "get_content_labels", + "add_content_labels", + "remove_content_label", + # Attachment Management + "get_attachments", + "get_attachment", + "create_attachment", + "update_attachment", + "delete_attachment", + # Comment Management + "get_comments", + "get_comment", + "create_comment", + "update_comment", + "delete_comment", + # Search + "search_content", + "search_spaces", + # Page Properties + "get_content_properties", + "get_content_property", + "create_content_property", + "update_content_property", + "delete_content_property", + # Templates + "get_templates", + "get_template", + # Analytics + "get_content_analytics", + "get_space_analytics", + # Export + "export_content", + "export_space", + # Utility + "get_metadata", + "get_health", + ] + + for method_name in required_methods: + if not hasattr(self, method_name): + raise AttributeError(f"Backward compatibility broken: missing method {method_name}") + + log.debug("Backward compatibility validation passed") + + def _issue_migration_warning(self, old_method: str, new_method: str, reason: str): + """ + Issue a deprecation warning for methods where v2 API provides better alternatives. + + :param old_method: Name of the method being called + :param new_method: Recommended v2 API method + :param reason: Reason why v2 API is better + """ + if self._prefer_v2_api or self._force_v2_api: + # Don't warn if user has already opted into v2 API + return + + warnings.warn( + f"{old_method}() will continue to work but consider using {new_method}() " + f"for {reason}. Enable v2 API with enable_v2_api() for enhanced features.", + FutureWarning, + stacklevel=3, + ) + + def get_api_version_info(self) -> Dict[str, Any]: + """ + Get information about API version configuration. + + :return: Dictionary with API version information + """ + return { + "v1_available": True, + "v2_available": self._v2_client is not None, + "force_v2_api": self._force_v2_api, + "prefer_v2_api": self._prefer_v2_api, + "current_default": "v2" if self._should_use_v2_api() else "v1", + } + """ + Get information about API version configuration. + + :return: Dictionary with API version information + """ + return { + "v1_available": True, + "v2_available": self._v2_client is not None, + "force_v2_api": self._force_v2_api, + "prefer_v2_api": self._prefer_v2_api, + "current_default": "v2" if self._should_use_v2_api() else "v1", + } + + # Content Management with dual API support def get_content(self, content_id, **kwargs): - """Get content by ID.""" + """ + Get content by ID with dual API support. + + This method maintains full backward compatibility with v1 API while providing + automatic v2 API optimizations when enabled. All existing code will continue + to work unchanged with enhanced performance benefits. + + **Backward Compatibility:** This method preserves all v1 API behavior including + response format, parameter names, and error handling. + + **v2 API Enhancement:** When v2 API is enabled, this method can automatically + benefit from improved performance and additional content format support. + + :param content_id: Content ID to retrieve (numeric for Server, UUID for Cloud) + :param expand: Comma-separated list of properties to expand + Common values: + - 'body.storage': Get content in storage format + - 'body.view': Get rendered HTML content + - 'version': Get version information + - 'space': Get space information + - 'history': Get content history + - 'children.page': Get child pages + - 'ancestors': Get ancestor pages + :param version: Specific version number to retrieve (optional) + :param status: Content status filter ('current', 'trashed', 'draft', 'any') + :param kwargs: Additional parameters passed to the API + :return: Content data in v1 API format containing: + - id: Content ID + - type: Content type ('page', 'blogpost', 'comment', etc.) + - title: Content title + - space: Space information (if expanded) + - body: Content body (if expanded) + - version: Version information (if expanded) + - history: Content history (if expanded) + - _links: Navigation links + + .. versionchanged:: 4.1.0 + Added automatic v2 API performance optimizations while maintaining + full backward compatibility. + + .. note:: + **v2 API Alternative:** For new applications working with ADF content, + consider using :meth:`get_page_with_adf` which provides native ADF + format support and enhanced v2 API features. + + **Performance Enhancement:** Enable v2 API with ``enable_v2_api()`` to + get automatic performance improvements while maintaining the same interface. + + Examples: + Get basic content information: + + >>> content = confluence.get_content("123456") + >>> print(f"Title: {content['title']}") + >>> print(f"Type: {content['type']}") + + Get content with body and version: + + >>> content = confluence.get_content( + ... "123456", + ... expand="body.storage,version,space" + ... ) + >>> body_content = content['body']['storage']['value'] + >>> version_number = content['version']['number'] + + Get specific version of content: + + >>> content = confluence.get_content( + ... "123456", + ... version=5, + ... expand="body.storage" + ... ) + + With v2 API enabled (automatic performance improvement): + + >>> confluence.enable_v2_api() # Enable v2 optimizations + >>> content = confluence.get_content("123456", expand="body.storage") + >>> # Same interface, potential performance benefits + + See Also: + - :meth:`get_page_with_adf`: v2 API method with native ADF support + - :meth:`get_content_by_type`: Get content filtered by type + - :doc:`confluence_v2_migration`: Migration guide for v2 API features + """ + # Try v2 API routing first + v2_result = self._route_to_v2_if_needed("get_page_by_id", content_id, **kwargs) + if v2_result is not None: + return v2_result + + # Fall back to v1 API - maintains exact backward compatibility return self.get(f"content/{content_id}", **kwargs) def get_content_by_type(self, content_type, **kwargs): - """Get content by type (page, blogpost, etc.).""" + """ + Get content by type (page, blogpost, etc.) with dual API support. + + This method maintains full backward compatibility with v1 API. + + :param content_type: Content type ('page', 'blogpost', etc.) + :param kwargs: Additional parameters (space, limit, start, etc.) + :return: Content data in v1 API format for backward compatibility + + .. note:: + For new applications using pages, consider get_pages() with v2 API + for cursor-based pagination and ADF content support. + """ + # Try v2 API routing for pages + if content_type == "page": + v2_result = self._route_to_v2_if_needed("get_pages", **kwargs) + if v2_result is not None: + return v2_result + + # Fall back to v1 API - maintains exact backward compatibility return self.get("content", params={"type": content_type, **kwargs}) def create_content(self, data, **kwargs): - """Create new content.""" + """ + Create new content with dual API support. + + This method maintains full backward compatibility with v1 API data structures + while providing automatic v2 API optimizations when enabled. All existing code + will continue to work unchanged. + + **Backward Compatibility:** This method preserves all v1 API behavior including + data structure format, parameter names, and response format. + + **v2 API Enhancement:** When v2 API is enabled, page creation can automatically + benefit from improved performance and enhanced content processing. + + :param data: Content data dictionary in v1 API format with required fields: + - type: Content type ('page', 'blogpost', 'comment') + - title: Content title (required for pages and blogposts) + - space: Space information (dict with 'key' or 'id') + - body: Content body with representation format + - ancestors: Parent page information (optional, for hierarchical pages) + :param kwargs: Additional parameters passed to the API + :return: Created content data in v1 API format containing: + - id: Content ID + - type: Content type + - title: Content title + - space: Space information + - body: Content body (if included) + - version: Version information + - _links: Navigation links + + .. versionchanged:: 4.1.0 + Added automatic v2 API performance optimizations for page creation + while maintaining full backward compatibility. + + .. note:: + **v2 API Alternative:** For new applications creating pages with rich content, + consider using :meth:`create_page_with_adf` which provides native ADF + format support, better performance, and enhanced v2 API features. + + **Performance Enhancement:** Enable v2 API with ``enable_v2_api()`` to + get automatic performance improvements for page creation while maintaining + the same interface. + + Examples: + Create a simple page: + + >>> page_data = { + ... "type": "page", + ... "title": "My New Page", + ... "space": {"key": "DEMO"}, + ... "body": { + ... "storage": { + ... "value": "Hello, World!
", + ... "representation": "storage" + ... } + ... } + ... } + >>> page = confluence.create_content(page_data) + >>> print(f"Created page: {page['id']}") + + Create a child page: + + >>> child_data = { + ... "type": "page", + ... "title": "Child Page", + ... "space": {"key": "DEMO"}, + ... "ancestors": [{"id": "123456"}], # Parent page ID + ... "body": { + ... "storage": { + ... "value": "This is a child page.
", + ... "representation": "storage" + ... } + ... } + ... } + >>> child_page = confluence.create_content(child_data) + + Create a blog post: + + >>> blog_data = { + ... "type": "blogpost", + ... "title": "My Blog Post", + ... "space": {"key": "DEMO"}, + ... "body": { + ... "storage": { + ... "value": "Blog content here.
", + ... "representation": "storage" + ... } + ... } + ... } + >>> blog = confluence.create_content(blog_data) + + With v2 API enabled (automatic performance improvement): + + >>> confluence.enable_v2_api() # Enable v2 optimizations + >>> page = confluence.create_content(page_data) + >>> # Same interface, potential performance benefits for page creation + + See Also: + - :meth:`create_page_with_adf`: v2 API method with native ADF support + - :meth:`update_content`: Update existing content + - :doc:`confluence_v2_migration`: Migration guide for v2 API features + """ + # Check if this is page creation that can use v2 API + if isinstance(data, dict) and data.get("type") == "page": + space_id = data.get("space", {}).get("id") if isinstance(data.get("space"), dict) else data.get("space") + title = data.get("title") + content = ( + data.get("body", {}).get("storage", {}).get("value") if isinstance(data.get("body"), dict) else None + ) + parent_id = data.get("ancestors", [{}])[-1].get("id") if data.get("ancestors") else None + + if space_id and title and content: + v2_result = self._route_to_v2_if_needed("create_page", space_id, title, content, parent_id) + if v2_result is not None: + return v2_result + + # Fall back to v1 API - maintains exact backward compatibility return self.post("content", data=data, **kwargs) def update_content(self, content_id, data, **kwargs): - """Update existing content.""" + """ + Update existing content with dual API support. + + This method maintains full backward compatibility with v1 API data structures + while providing automatic v2 API optimizations when enabled. All existing code + will continue to work unchanged. + + **Backward Compatibility:** This method preserves all v1 API behavior including + data structure format, parameter names, and response format. + + **v2 API Enhancement:** When v2 API is enabled, page updates can automatically + benefit from improved performance and enhanced content processing. + + :param content_id: Content ID to update (numeric for Server, UUID for Cloud) + :param data: Updated content data dictionary in v1 API format with fields: + - type: Content type ('page', 'blogpost', 'comment') + - title: Updated content title (optional) + - body: Updated content body with representation format + - version: Version information for optimistic locking (recommended) + - space: Space information (usually unchanged) + :param kwargs: Additional parameters passed to the API + :return: Updated content data in v1 API format containing: + - id: Content ID + - type: Content type + - title: Updated content title + - body: Updated content body (if included) + - version: New version information + - _links: Navigation links + + .. versionchanged:: 4.1.0 + Added automatic v2 API performance optimizations for page updates + while maintaining full backward compatibility. + + .. note:: + **v2 API Alternative:** For new applications updating pages with rich content, + consider using :meth:`update_page_with_adf` which provides native ADF + format support, better performance, and enhanced v2 API features. + + **Performance Enhancement:** Enable v2 API with ``enable_v2_api()`` to + get automatic performance improvements for page updates while maintaining + the same interface. + + **Version Management:** Always include version information to prevent + concurrent modification conflicts. Get current version with :meth:`get_content`. + + Examples: + Update page content: + + >>> # First, get current version + >>> current_page = confluence.get_content("123456", expand="version") + >>> current_version = current_page['version']['number'] + >>> + >>> # Update with new content + >>> update_data = { + ... "type": "page", + ... "title": "Updated Page Title", + ... "body": { + ... "storage": { + ... "value": "Updated content here.
", + ... "representation": "storage" + ... } + ... }, + ... "version": {"number": current_version + 1} + ... } + >>> updated_page = confluence.update_content("123456", update_data) + >>> print(f"Updated to version: {updated_page['version']['number']}") + + Update only the title: + + >>> update_data = { + ... "type": "page", + ... "title": "New Title Only", + ... "version": {"number": current_version + 1} + ... } + >>> updated_page = confluence.update_content("123456", update_data) + + Update blog post: + + >>> blog_update = { + ... "type": "blogpost", + ... "title": "Updated Blog Post", + ... "body": { + ... "storage": { + ... "value": "Updated blog content.
", + ... "representation": "storage" + ... } + ... }, + ... "version": {"number": current_version + 1} + ... } + >>> updated_blog = confluence.update_content("789012", blog_update) + + With v2 API enabled (automatic performance improvement): + + >>> confluence.enable_v2_api() # Enable v2 optimizations + >>> updated_page = confluence.update_content("123456", update_data) + >>> # Same interface, potential performance benefits for page updates + + See Also: + - :meth:`update_page_with_adf`: v2 API method with native ADF support + - :meth:`get_content`: Get current content and version information + - :doc:`confluence_v2_migration`: Migration guide for v2 API features + """ + # Check if this is page update that can use v2 API + if isinstance(data, dict) and data.get("type") == "page": + title = data.get("title") + content = ( + data.get("body", {}).get("storage", {}).get("value") if isinstance(data.get("body"), dict) else None + ) + version = data.get("version", {}).get("number") if isinstance(data.get("version"), dict) else None + + v2_result = self._route_to_v2_if_needed("update_page", content_id, title, content, None, version) + if v2_result is not None: + return v2_result + + # Fall back to v1 API - maintains exact backward compatibility return self.put(f"content/{content_id}", data=data, **kwargs) def delete_content(self, content_id, **kwargs): - """Delete content.""" + """ + Delete content with dual API support. + + This method maintains full backward compatibility with v1 API. + + :param content_id: Content ID to delete + :param kwargs: Additional parameters + :return: Deletion result + """ + # Try v2 API routing first + v2_result = self._route_to_v2_if_needed("delete_page", content_id) + if v2_result is not None: + return v2_result + + # Fall back to v1 API - maintains exact backward compatibility return self.delete(f"content/{content_id}", **kwargs) def get_content_children(self, content_id, **kwargs): @@ -52,29 +717,87 @@ def get_content_ancestors(self, content_id, **kwargs): """Get ancestor content.""" return self.get(f"content/{content_id}/ancestors", **kwargs) - # Space Management + # Space Management with dual API support def get_spaces(self, **kwargs): - """Get all spaces.""" - return self.get("space", **kwargs) + """ + Get all spaces with dual API support. + + This method maintains full backward compatibility with v1 API. + + :param kwargs: Additional parameters (type, status, label, favourite, etc.) + :return: Spaces data in v1 API format for backward compatibility + + .. note:: + For new applications, consider enabling v2 API support with enable_v2_api() + for cursor-based pagination and enhanced space metadata. + """ + # Try v2 API routing first + v2_result = self._route_to_v2_if_needed("get_spaces", **kwargs) + if v2_result is not None: + return v2_result + + # Fall back to v1 API - maintains exact backward compatibility + return self.get(self.resource_url("space"), **kwargs) def get_space(self, space_id, **kwargs): - """Get space by ID.""" + """ + Get space by ID. + + This method maintains full backward compatibility with v1 API. + + :param space_id: Space ID or key + :param kwargs: Additional parameters (expand, etc.) + :return: Space data + """ return self.get(f"space/{space_id}", **kwargs) def create_space(self, data, **kwargs): - """Create new space.""" + """ + Create new space. + + This method maintains full backward compatibility with v1 API. + + :param data: Space data dictionary + :param kwargs: Additional parameters + :return: Created space data + """ return self.post("space", data=data, **kwargs) def update_space(self, space_id, data, **kwargs): - """Update existing space.""" + """ + Update existing space. + + This method maintains full backward compatibility with v1 API. + + :param space_id: Space ID to update + :param data: Updated space data dictionary + :param kwargs: Additional parameters + :return: Updated space data + """ return self.put(f"space/{space_id}", data=data, **kwargs) def delete_space(self, space_id, **kwargs): - """Delete space.""" + """ + Delete space. + + This method maintains full backward compatibility with v1 API. + + :param space_id: Space ID to delete + :param kwargs: Additional parameters + :return: Deletion result + """ return self.delete(f"space/{space_id}", **kwargs) def get_space_content(self, space_id, **kwargs): - """Get space content.""" + """ + Get space content. + + This method maintains full backward compatibility with v1 API. + + :param space_id: Space ID + :param kwargs: Additional parameters + :return: Space content data + """ return self.get(f"space/{space_id}/content", **kwargs) # User Management @@ -88,7 +811,7 @@ def get_user(self, user_id, **kwargs): def get_current_user(self, **kwargs): """Get current user.""" - return self.get("user/current", **kwargs) + return self.get(self.resource_url("user/current"), **kwargs) # Group Management def get_groups(self, **kwargs): @@ -162,13 +885,115 @@ def delete_comment(self, comment_id, **kwargs): """Delete comment.""" return self.delete(f"content/{comment_id}", **kwargs) - # Search + # Search with dual API support def search_content(self, query, **kwargs): - """Search content.""" + """ + Search content using CQL (Confluence Query Language) with dual API support. + + This method maintains full backward compatibility with v1 API while providing + automatic performance optimizations when v2 API is enabled. All existing code + will continue to work unchanged. + + **Backward Compatibility:** This method preserves all v1 API behavior including + response format, parameter names, and error handling. + + **Performance Enhancement:** When v2 API is enabled, large result sets benefit + from improved pagination performance automatically. + + :param query: CQL query string for searching content + Examples: + - "type=page AND space=DEMO" + - "title~'meeting notes' AND lastModified >= '2024-01-01'" + - "creator=currentUser() AND type=blogpost" + :param limit: Maximum number of results to return (default: 25, max: 100) + :param start: Starting index for pagination (0-based, v1 API style) + :param expand: Comma-separated list of properties to expand + Examples: 'body.storage', 'version', 'space', 'history' + :param excerpt: Whether to include content excerpts in results + :param include_archived_spaces: Whether to include archived spaces in search + :param kwargs: Additional search parameters + :return: Search results in v1 API format containing: + - results: List of matching content items + - start: Starting index of results + - limit: Maximum results per page + - size: Number of results returned + - totalSize: Total number of matching results (if available) + - _links: Navigation links for pagination + + .. versionchanged:: 4.1.0 + Added automatic v2 API performance optimizations while maintaining + full backward compatibility. + + .. note:: + **Migration Recommendation:** For new applications or when working with + large result sets, consider using :meth:`search_pages_with_cursor` which + provides cursor-based pagination and better performance with v2 API. + + **Performance Warning:** This method will issue warnings for large pagination + requests (limit > 50 or start > 100) suggesting cursor-based alternatives. + Enable v2 API with ``enable_v2_api()`` to disable warnings and get + automatic performance improvements. + + Examples: + Basic content search: + + >>> results = confluence.search_content("type=page AND space=DEMO") + >>> for page in results['results']: + ... print(f"Found: {page['title']}") + + Search with pagination: + + >>> results = confluence.search_content( + ... "type=page AND lastModified >= '2024-01-01'", + ... limit=50, + ... start=0, + ... expand="body.storage,version" + ... ) + + Search for specific content: + + >>> results = confluence.search_content( + ... "title~'API documentation' AND type=page", + ... limit=10 + ... ) + + With v2 API enabled (automatic performance improvement): + + >>> confluence.enable_v2_api() # Enable v2 optimizations + >>> results = confluence.search_content("type=page", limit=100) + >>> # Same interface, better performance for large result sets + + See Also: + - :meth:`search_pages_with_cursor`: v2 API method with cursor pagination + - :meth:`cql`: Alternative CQL search method + - :doc:`confluence_v2_migration`: Migration guide for v2 API features + """ + # Issue migration warning for pagination-heavy use cases + if kwargs.get("limit", 0) > 50 or kwargs.get("start", 0) > 100: + self._issue_migration_warning( + "search_content", + "search_pages_with_cursor", + "cursor-based pagination and better performance with large result sets", + ) + + # Try v2 API routing first + v2_result = self._route_to_v2_if_needed("search_pages", query, **kwargs) + if v2_result is not None: + return v2_result + + # Fall back to v1 API - maintains exact backward compatibility return self.get("content/search", params={"cql": query, **kwargs}) def search_spaces(self, query, **kwargs): - """Search spaces.""" + """ + Search spaces. + + This method maintains full backward compatibility with v1 API. + + :param query: Search query string + :param kwargs: Additional parameters + :return: Space search results + """ return self.get("space/search", params={"query": query, **kwargs}) # Page Properties @@ -227,3 +1052,390 @@ def get_metadata(self, **kwargs): def get_health(self, **kwargs): """Get API health status.""" return self.get("health", **kwargs) + + # v2 API specific convenience methods + def create_page_with_adf( + self, space_id: str, title: str, adf_content: Dict[str, Any], parent_id: Optional[str] = None + ) -> Dict[str, Any]: + """ + Create a page with ADF content using v2 API. + + This method forces the use of v2 API regardless of configuration and provides + native ADF (Atlassian Document Format) support for rich content creation. + + ADF (Atlassian Document Format) is the native content format for Confluence Cloud + that provides structured, JSON-based representation of rich content including + headings, paragraphs, lists, tables, and formatted text. + + :param space_id: Space ID where page will be created (UUID format for Cloud) + :param title: Page title (must be unique within the space) + :param adf_content: ADF content dictionary in native format with required fields: + - version: Must be 1 + - type: Must be "doc" + - content: List of content nodes (paragraphs, headings, etc.) + :param parent_id: Parent page ID for hierarchical organization (optional) + :return: Created page data in v2 API format containing: + - id: Page ID (UUID) + - title: Page title + - spaceId: Space ID + - body: Page content in ADF format + - version: Page version information + - _links: Navigation links + :raises RuntimeError: If v2 API client is not available or not properly configured + :raises ValueError: If ADF content structure is invalid + :raises requests.HTTPError: If API request fails (permissions, space not found, etc.) + + .. versionadded:: 4.1.0 + Added native ADF support for Confluence Cloud v2 API + + .. note:: + This method requires Confluence Cloud and proper API token authentication. + Space ID must be in UUID format (not space key). + + Examples: + Create a simple page with text: + + >>> adf_content = { + ... "version": 1, + ... "type": "doc", + ... "content": [ + ... { + ... "type": "paragraph", + ... "content": [ + ... {"type": "text", "text": "Hello, World!"} + ... ] + ... } + ... ] + ... } + >>> page = confluence.create_page_with_adf("SPACE123", "My Page", adf_content) + >>> print(f"Created page: {page['id']}") + + Create a page with formatted content: + + >>> formatted_adf = { + ... "version": 1, + ... "type": "doc", + ... "content": [ + ... { + ... "type": "heading", + ... "attrs": {"level": 1}, + ... "content": [{"type": "text", "text": "Welcome"}] + ... }, + ... { + ... "type": "paragraph", + ... "content": [ + ... {"type": "text", "text": "This is "}, + ... { + ... "type": "text", + ... "text": "bold text", + ... "marks": [{"type": "strong"}] + ... } + ... ] + ... } + ... ] + ... } + >>> page = confluence.create_page_with_adf("SPACE123", "Formatted Page", formatted_adf) + + Create a child page: + + >>> child_page = confluence.create_page_with_adf( + ... space_id="SPACE123", + ... title="Child Page", + ... adf_content=adf_content, + ... parent_id="parent-page-uuid" + ... ) + + See Also: + - :meth:`update_page_with_adf`: Update existing page with ADF content + - :meth:`get_page_with_adf`: Retrieve page with ADF content + - :doc:`confluence_adf`: Complete ADF documentation and examples + """ + if self._v2_client is None: + self._init_v2_client() + + if self._v2_client is None: + raise RuntimeError("v2 API client not available") + + return self._v2_client.create_page(space_id, title, adf_content, parent_id, "adf") + + def update_page_with_adf( + self, + page_id: str, + title: Optional[str] = None, + adf_content: Optional[Dict[str, Any]] = None, + version: Optional[int] = None, + ) -> Dict[str, Any]: + """ + Update a page with ADF content using v2 API. + + This method forces the use of v2 API regardless of configuration and provides + native ADF support for rich content updates with optimistic locking support. + + :param page_id: Page ID to update (UUID format for Cloud) + :param title: New page title (optional, keeps existing if not provided) + :param adf_content: New ADF content dictionary (optional, keeps existing if not provided) + Must follow ADF structure with version=1, type="doc", and content array + :param version: Page version number for optimistic locking (highly recommended) + Use current version + 1 to prevent concurrent modification conflicts + :return: Updated page data in v2 API format containing: + - id: Page ID + - title: Updated page title + - body: Updated page content in ADF format + - version: New version information + - _links: Navigation links + :raises RuntimeError: If v2 API client is not available + :raises ValueError: If ADF content structure is invalid + :raises requests.HTTPError: If API request fails (version conflict, permissions, etc.) + + .. versionadded:: 4.1.0 + Added native ADF support for Confluence Cloud v2 API + + .. note:: + Always use version parameter to prevent concurrent modification issues. + Get current version with get_page_with_adf() before updating. + + Examples: + Update page title only: + + >>> updated_page = confluence.update_page_with_adf( + ... page_id="123456", + ... title="Updated Title", + ... version=2 + ... ) + + Update page content only: + + >>> new_adf = { + ... "version": 1, + ... "type": "doc", + ... "content": [ + ... { + ... "type": "paragraph", + ... "content": [ + ... {"type": "text", "text": "Updated content!"} + ... ] + ... } + ... ] + ... } + >>> updated_page = confluence.update_page_with_adf( + ... page_id="123456", + ... adf_content=new_adf, + ... version=2 + ... ) + + Update both title and content with version control: + + >>> # First, get current page to check version + >>> current_page = confluence.get_page_with_adf("123456", expand=['version']) + >>> current_version = current_page['version']['number'] + >>> + >>> # Update with proper version + >>> updated_page = confluence.update_page_with_adf( + ... page_id="123456", + ... title="New Title", + ... adf_content=new_adf, + ... version=current_version + 1 + ... ) + + See Also: + - :meth:`create_page_with_adf`: Create new page with ADF content + - :meth:`get_page_with_adf`: Retrieve page with current version info + - :doc:`confluence_adf`: Complete ADF documentation and examples + """ + if self._v2_client is None: + self._init_v2_client() + + if self._v2_client is None: + raise RuntimeError("v2 API client not available") + + return self._v2_client.update_page(page_id, title, adf_content, "adf", version) + + def get_page_with_adf(self, page_id: str, expand: Optional[List[str]] = None) -> Dict[str, Any]: + """ + Get a page with ADF content using v2 API. + + This method forces the use of v2 API regardless of configuration and returns + content in native ADF format for rich content processing and manipulation. + + :param page_id: Page ID to retrieve (UUID format for Cloud) + :param expand: List of properties to expand for additional data: + - 'body': Include page content in ADF format + - 'version': Include version information + - 'space': Include space details + - 'ancestors': Include parent page hierarchy + - 'children': Include child pages + - 'descendants': Include all descendant pages + - 'history': Include page history + - 'restrictions': Include page restrictions + - 'metadata': Include page metadata + :return: Page data with ADF content in v2 API format containing: + - id: Page ID (UUID) + - title: Page title + - spaceId: Space ID + - body: Page content with atlas_doc_format representation + - version: Version information (if expanded) + - space: Space details (if expanded) + - _links: Navigation links + :raises RuntimeError: If v2 API client is not available + :raises requests.HTTPError: If API request fails (page not found, permissions, etc.) + + .. versionadded:: 4.1.0 + Added native ADF support for Confluence Cloud v2 API + + .. note:: + The returned ADF content is in the 'body.atlas_doc_format.value' field. + This is the native format used by Confluence Cloud v2 API. + + Examples: + Get page with basic information: + + >>> page = confluence.get_page_with_adf("123456") + >>> print(f"Page title: {page['title']}") + >>> print(f"Space ID: {page['spaceId']}") + + Get page with ADF content: + + >>> page = confluence.get_page_with_adf("123456", expand=['body']) + >>> adf_content = page['body']['atlas_doc_format']['value'] + >>> print(f"ADF version: {adf_content['version']}") + >>> print(f"Content nodes: {len(adf_content['content'])}") + + Get page with version info for updates: + + >>> page = confluence.get_page_with_adf("123456", expand=['body', 'version']) + >>> current_version = page['version']['number'] + >>> adf_content = page['body']['atlas_doc_format']['value'] + >>> + >>> # Modify content and update + >>> modified_adf = modify_adf_content(adf_content) + >>> updated_page = confluence.update_page_with_adf( + ... page_id="123456", + ... adf_content=modified_adf, + ... version=current_version + 1 + ... ) + + Get page with full context: + + >>> page = confluence.get_page_with_adf( + ... page_id="123456", + ... expand=['body', 'version', 'space', 'ancestors'] + ... ) + >>> space_name = page['space']['name'] + >>> parent_pages = page['ancestors'] + + See Also: + - :meth:`create_page_with_adf`: Create new page with ADF content + - :meth:`update_page_with_adf`: Update page with ADF content + - :doc:`confluence_adf`: Complete ADF documentation and examples + """ + if self._v2_client is None: + self._init_v2_client() + + if self._v2_client is None: + raise RuntimeError("v2 API client not available") + + return self._v2_client.get_page_by_id(page_id, expand) + + def search_pages_with_cursor(self, cql: str, limit: int = 25, cursor: Optional[str] = None) -> Dict[str, Any]: + """ + Search pages using CQL with cursor-based pagination (v2 API). + + This method forces the use of v2 API regardless of configuration and provides + cursor-based pagination for efficient handling of large result sets. Cursor-based + pagination is more reliable and performant than offset-based pagination, especially + for large datasets where results may change during iteration. + + :param cql: CQL (Confluence Query Language) query string for filtering results. + Examples: + - "type=page AND space=DEMO" - Pages in DEMO space + - "type=page AND title~'meeting'" - Pages with 'meeting' in title + - "type=page AND created>=2024-01-01" - Pages created since Jan 1, 2024 + - "type=page AND label='important'" - Pages with 'important' label + :param limit: Number of results per page (1-250, default 25) + Higher limits reduce API calls but increase response time + :param cursor: Cursor token for pagination (from previous response '_links.next.cursor') + None for first page, use returned cursor for subsequent pages + :return: Search results with cursor-based pagination info containing: + - results: List of page objects with ADF content + - _links: Pagination links including 'next' cursor if more results exist + - size: Number of results in current response + :raises RuntimeError: If v2 API client is not available + :raises ValueError: If CQL query is invalid or limit is out of range + :raises requests.HTTPError: If API request fails + + .. versionadded:: 4.1.0 + Added cursor-based pagination for Confluence Cloud v2 API + + .. note:: + Cursor-based pagination provides better performance and consistency compared + to offset-based pagination, especially for large result sets or when data + changes during iteration. + + Examples: + Basic search with first page: + + >>> results = confluence.search_pages_with_cursor( + ... cql="type=page AND space=DEMO", + ... limit=50 + ... ) + >>> pages = results['results'] + >>> print(f"Found {len(pages)} pages") + + Iterate through all results using cursor: + + >>> all_pages = [] + >>> cursor = None + >>> + >>> while True: + ... results = confluence.search_pages_with_cursor( + ... cql="type=page AND space=DEMO", + ... limit=100, + ... cursor=cursor + ... ) + ... + ... all_pages.extend(results['results']) + ... + ... # Check if there are more results + ... if 'next' not in results.get('_links', {}): + ... break + ... + ... cursor = results['_links']['next']['cursor'] + >>> + >>> print(f"Total pages found: {len(all_pages)}") + + Search with complex CQL query: + + >>> results = confluence.search_pages_with_cursor( + ... cql="type=page AND space in ('DEMO', 'PROJ') AND created>=2024-01-01", + ... limit=25 + ... ) + + Process results with ADF content: + + >>> results = confluence.search_pages_with_cursor( + ... cql="type=page AND label='documentation'", + ... limit=50 + ... ) + >>> + >>> for page in results['results']: + ... page_id = page['id'] + ... title = page['title'] + ... + ... # Get full ADF content if needed + ... full_page = confluence.get_page_with_adf(page_id, expand=['body']) + ... adf_content = full_page['body']['atlas_doc_format']['value'] + ... + ... print(f"Processing page: {title}") + + See Also: + - :meth:`search_content`: Legacy v1 API search with offset pagination + - :meth:`get_page_with_adf`: Get full page content with ADF + - `CQL DocumentationHello, World!
", + "representation": "storage" + } + } + } + page = confluence.create_content(content_data) + + # v2 API approach (enhanced) + adf_content = { + "version": 1, + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "Hello, World!"}] + } + ] + } + page = confluence.create_page_with_adf("SPACE123", "My Page", adf_content) + + # Gradual migration approach + confluence.enable_v2_api() # Enable v2 features + + # Existing methods now benefit from v2 performance + results = confluence.search_content("type=page", limit=100) # Uses cursor pagination + + # New methods provide v2-specific features + results = confluence.search_pages_with_cursor("type=page", limit=100) + +API Version Information +~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # Check current API configuration + info = confluence.get_api_version_info() + print(info) + # { + # 'v1_available': True, + # 'v2_available': True, + # 'force_v2_api': False, + # 'prefer_v2_api': False, + # 'current_default': 'v1' + # } + + # Enable v2 API and check again + confluence.enable_v2_api() + info = confluence.get_api_version_info() + print(info['current_default']) # 'v2' Common Operations ----------------- @@ -52,6 +405,615 @@ Both implementations support: - Page properties - Export capabilities +v2 API Best Practices +--------------------- + +Performance Optimization +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # Use cursor-based pagination for large result sets + # Better performance than offset-based pagination + results = confluence.search_pages_with_cursor( + cql="type=page AND space=DEMO", + limit=250 # Maximum allowed + ) + + # Request only needed fields to reduce response size + page = confluence.get_page_with_adf( + page_id="123456", + expand=['body'] # Only get body content + ) + + # Batch operations when possible + pages_to_create = [ + ("Page 1", adf_content_1), + ("Page 2", adf_content_2), + ("Page 3", adf_content_3) + ] + + created_pages = [] + for title, content in pages_to_create: + page = confluence.create_page_with_adf("SPACE123", title, content) + created_pages.append(page) + +Content Format Handling +~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from atlassian.request_utils import detect_content_format, is_adf_content + + # Detect content format automatically + content_format = detect_content_format(content) + + if content_format == "adf": + # Content is already in ADF format + page = confluence.create_page_with_adf("SPACE123", "Title", content) + elif content_format == "storage": + # Convert storage format to ADF (basic conversion) + from atlassian.adf import convert_storage_to_adf + adf_content = convert_storage_to_adf(content) + page = confluence.create_page_with_adf("SPACE123", "Title", adf_content) + else: + # Treat as plain text + from atlassian.adf import convert_text_to_adf + adf_content = convert_text_to_adf(content) + page = confluence.create_page_with_adf("SPACE123", "Title", adf_content) + +Error Handling +~~~~~~~~~~~~~~ + +.. code-block:: python + + try: + page = confluence.create_page_with_adf("SPACE123", "Title", adf_content) + except RuntimeError as e: + if "v2 API client not available" in str(e): + # Fall back to v1 API + confluence.disable_v2_api() + # Use v1 API methods instead + page = confluence.create_content(v1_content_data) + else: + raise + + # Validate ADF content before submission + from atlassian.adf import validate_adf_document + + if not validate_adf_document(adf_content): + raise ValueError("Invalid ADF content structure") + + page = confluence.create_page_with_adf("SPACE123", "Title", adf_content) + +Version Management +~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # Always use version numbers for updates to prevent conflicts + page = confluence.get_page_with_adf("123456", expand=['version']) + current_version = page['version']['number'] + + # Update with version for optimistic locking + updated_page = confluence.update_page_with_adf( + page_id="123456", + title="Updated Title", + adf_content=new_content, + version=current_version + 1 + ) + +Troubleshooting v2 API +--------------------- + +This section covers common issues when working with Confluence Cloud v2 API and their solutions. + +Common Issues and Solutions +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**1. "v2 API client not available" Error** + +This error occurs when the v2 API client is not properly initialized or configured. + +.. code-block:: python + + # Check if v2 API is properly configured + info = confluence.get_api_version_info() + print(f"v2 available: {info['v2_available']}") + + if not info['v2_available']: + # Reinitialize v2 client + confluence.enable_v2_api() + + # Verify it's now available + info = confluence.get_api_version_info() + if not info['v2_available']: + print("v2 API initialization failed - check authentication and URL") + +**Solution Steps:** +- Verify your Confluence Cloud URL is correct +- Ensure you're using a valid API token (not password) +- Check that your Confluence instance supports v2 API +- Try reinitializing the client with ``confluence.enable_v2_api()`` + +**2. Invalid ADF Content Structure** + +ADF content must follow a specific structure. Common validation errors include missing required fields or incorrect node types. + +.. code-block:: python + + from atlassian.adf import validate_adf_document + + # Always validate ADF before submission + adf_content = { + "version": 1, + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "Hello, World!"}] + } + ] + } + + if not validate_adf_document(adf_content): + print("Invalid ADF structure") + # Common fixes: + # - Ensure version is 1 + # - Ensure type is "doc" + # - Ensure content is a list + # - Check all node types are valid + + # Fix common issues automatically + fixed_adf = { + "version": 1, + "type": "doc", + "content": adf_content.get("content", []) + } + +**Common ADF Structure Issues:** +- Missing ``version`` field (must be 1) +- Missing ``type`` field (must be "doc") +- ``content`` is not a list +- Invalid node types in content +- Missing required attributes (e.g., ``level`` for headings) + +**3. Cursor Pagination Issues** + +Cursor-based pagination can fail if cursors are malformed or expired. + +.. code-block:: python + + def get_all_pages_safe(confluence, cql, limit=100): + """Safely retrieve all pages with cursor pagination.""" + all_results = [] + cursor = None + max_iterations = 1000 # Prevent infinite loops + iteration = 0 + + while iteration < max_iterations: + try: + results = confluence.search_pages_with_cursor( + cql=cql, + limit=limit, + cursor=cursor + ) + + # Extract results + page_results = results.get('results', []) + if not page_results: + break + + all_results.extend(page_results) + + # Check for next page + next_link = results.get('_links', {}).get('next') + if not next_link: + break + + cursor = next_link.get('cursor') + if not cursor: + break + + iteration += 1 + + except Exception as e: + print(f"Error during pagination at iteration {iteration}: {e}") + # Log the cursor that failed + print(f"Failed cursor: {cursor}") + break + + return all_results + +**Cursor Pagination Best Practices:** +- Always check for the existence of ``_links.next`` before continuing +- Validate cursor values before using them +- Implement maximum iteration limits to prevent infinite loops +- Handle network errors gracefully +- Log failed cursors for debugging + +**4. Content Format Conversion Issues** + +Converting between different content formats (storage, ADF, wiki) can cause issues. + +.. code-block:: python + + from atlassian.request_utils import detect_content_format + from atlassian.adf import convert_text_to_adf, validate_adf_document + + def safe_create_page(confluence, space_id, title, content): + """Safely create a page handling different content formats.""" + try: + # Detect content format + content_format = detect_content_format(content) + + if content_format == "adf": + # Validate ADF content + if validate_adf_document(content): + return confluence.create_page_with_adf(space_id, title, content) + else: + raise ValueError("Invalid ADF content structure") + + elif content_format == "storage": + # Convert storage to ADF (basic conversion) + try: + from atlassian.adf import convert_storage_to_adf + adf_content = convert_storage_to_adf(content) + return confluence.create_page_with_adf(space_id, title, adf_content) + except Exception: + # Fall back to v1 API + return confluence.create_content({ + "type": "page", + "title": title, + "space": {"id": space_id}, + "body": { + "storage": { + "value": content, + "representation": "storage" + } + } + }) + + else: + # Treat as plain text + adf_content = convert_text_to_adf(str(content)) + return confluence.create_page_with_adf(space_id, title, adf_content) + + except Exception as e: + print(f"Failed to create page: {e}") + # Last resort: use v1 API with basic content + try: + return confluence.create_content({ + "type": "page", + "title": title, + "space": {"id": space_id}, + "body": { + "storage": { + "value": f"{str(content)}
", + "representation": "storage" + } + } + }) + except Exception as fallback_error: + print(f"Fallback also failed: {fallback_error}") + raise + +**5. Authentication and Permission Issues** + +v2 API requires proper authentication and permissions. + +.. code-block:: python + + def test_v2_api_access(confluence): + """Test v2 API access and permissions.""" + try: + # Test basic v2 API access + info = confluence.get_api_version_info() + print(f"API Info: {info}") + + if not info['v2_available']: + print("v2 API not available - check authentication") + return False + + # Test actual v2 API call + try: + # Try a simple v2 API operation + results = confluence.search_pages_with_cursor( + cql="type=page", + limit=1 + ) + print("v2 API access successful") + return True + + except Exception as api_error: + print(f"v2 API call failed: {api_error}") + # Check if it's a permission issue + if "403" in str(api_error) or "Forbidden" in str(api_error): + print("Permission denied - check API token permissions") + elif "401" in str(api_error) or "Unauthorized" in str(api_error): + print("Authentication failed - check API token validity") + return False + + except Exception as e: + print(f"Failed to test v2 API access: {e}") + return False + +**6. Space ID vs Space Key Confusion** + +v2 API uses Space IDs (UUIDs) while v1 API uses Space Keys (strings). + +.. code-block:: python + + def get_space_info(confluence, space_identifier): + """Get space information handling both ID and key formats.""" + try: + # Try as space key first (v1 API) + if isinstance(space_identifier, str) and not space_identifier.startswith('~'): + # Looks like a space key + space = confluence.get_space(space_identifier) + return { + 'id': space.get('id'), + 'key': space.get('key'), + 'name': space.get('name'), + 'type': space.get('type') + } + else: + # Assume it's a space ID + # v2 API call to get space by ID + # (Implementation depends on available v2 methods) + return {'id': space_identifier} + + except Exception as e: + print(f"Failed to get space info for '{space_identifier}': {e}") + return None + + # Usage example + space_info = get_space_info(confluence, "DEMO") # Space key + if space_info: + space_id = space_info['id'] + # Use space_id for v2 API calls + page = confluence.create_page_with_adf(space_id, "Title", adf_content) + +Debug Mode and Logging +~~~~~~~~~~~~~~~~~~~~~ + +Enable detailed logging to troubleshoot v2 API issues: + +.. code-block:: python + + import logging + + # Enable debug logging + logging.basicConfig(level=logging.DEBUG) + logger = logging.getLogger('atlassian') + logger.setLevel(logging.DEBUG) + + # This will show detailed API calls and responses + confluence.enable_v2_api() + page = confluence.get_content("123456") + + # You'll see output like: + # DEBUG:atlassian:Using v2 API for get_content + # DEBUG:atlassian:GET https://domain.atlassian.net/wiki/api/v2/pages/123456 + +**Custom Debug Helper:** + +.. code-block:: python + + def debug_api_call(confluence, operation_name, *args, **kwargs): + """Debug wrapper for API calls.""" + print(f"=== DEBUG: {operation_name} ===") + + # Show API version info + info = confluence.get_api_version_info() + print(f"API Config: {info}") + + # Show arguments + print(f"Args: {args}") + print(f"Kwargs: {kwargs}") + + try: + # Execute the operation + method = getattr(confluence, operation_name) + result = method(*args, **kwargs) + print(f"Success: {type(result)}") + return result + except Exception as e: + print(f"Error: {e}") + print(f"Error type: {type(e)}") + raise + + # Usage + page = debug_api_call(confluence, 'get_content', '123456') + +Migration Warnings and Compatibility +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The library provides helpful warnings when v2 API would provide better performance: + +.. code-block:: python + + import warnings + + # Capture migration warnings + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + # This will issue a warning for large pagination requests + results = confluence.search_content("type=page", limit=200, start=1000) + + if w: + for warning in w: + print(f"Warning: {warning.message}") + print(f"Category: {warning.category}") + # Example output: + # Warning: search_content() will continue to work but consider using + # search_pages_with_cursor() for cursor-based pagination and better + # performance with large result sets. + + # Disable warnings by enabling v2 API + confluence.enable_v2_api() # No more warnings + +**Handling Backward Compatibility:** + +.. code-block:: python + + def get_page_content_compatible(confluence, page_id): + """Get page content with backward compatibility.""" + info = confluence.get_api_version_info() + + if info['current_default'] == 'v2' or info['prefer_v2_api']: + # Use v2 API + try: + page = confluence.get_page_with_adf(page_id, expand=['body']) + return { + 'format': 'adf', + 'content': page['body']['atlas_doc_format']['value'] + } + except Exception: + # Fall back to v1 + pass + + # Use v1 API + page = confluence.get_content(page_id, expand='body.storage') + return { + 'format': 'storage', + 'content': page['body']['storage']['value'] + } + +Performance Troubleshooting +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**1. Slow Pagination Performance** + +.. code-block:: python + + # Slow: Large offset-based pagination + results = confluence.search_content("type=page", limit=50, start=5000) + + # Fast: Cursor-based pagination + confluence.enable_v2_api() + results = confluence.search_pages_with_cursor("type=page", limit=50) + +**2. Large Content Handling** + +.. code-block:: python + + # For large ADF documents, validate structure first + def create_large_page_safely(confluence, space_id, title, adf_content): + """Create large pages with validation and chunking if needed.""" + + # Validate ADF structure + if not validate_adf_document(adf_content): + raise ValueError("Invalid ADF structure") + + # Check content size (rough estimate) + import json + content_size = len(json.dumps(adf_content)) + + if content_size > 1024 * 1024: # 1MB + print(f"Warning: Large content size ({content_size} bytes)") + # Consider splitting into multiple pages + + try: + return confluence.create_page_with_adf(space_id, title, adf_content) + except Exception as e: + if "too large" in str(e).lower(): + print("Content too large - consider splitting into multiple pages") + raise + +Error Code Reference +~~~~~~~~~~~~~~~~~~~ + +Common HTTP error codes and their meanings in v2 API context: + +**400 Bad Request** +- Invalid ADF content structure +- Missing required parameters +- Malformed cursor tokens + +**401 Unauthorized** +- Invalid API token +- Expired authentication +- Missing authentication headers + +**403 Forbidden** +- Insufficient permissions for the operation +- Space access denied +- API token lacks required scopes + +**404 Not Found** +- Page, space, or resource doesn't exist +- Invalid page/space ID format +- Resource has been deleted + +**409 Conflict** +- Page title already exists in space +- Version conflict (optimistic locking) +- Concurrent modification detected + +**413 Payload Too Large** +- ADF content exceeds size limits +- Attachment too large +- Request body too large + +**429 Too Many Requests** +- Rate limiting exceeded +- Too many concurrent requests +- API quota exceeded + +**500 Internal Server Error** +- Confluence server error +- ADF processing error +- Temporary service unavailability + +Getting Additional Help +~~~~~~~~~~~~~~~~~~~~~~ + +If you encounter issues not covered in this troubleshooting guide: + +1. **Enable Debug Logging**: Use the debug logging examples above to get detailed information +2. **Check API Version Info**: Use ``get_api_version_info()`` to verify your configuration +3. **Validate ADF Content**: Use ``validate_adf_document()`` for content issues +4. **Test with Simple Examples**: Start with basic operations before complex ones +5. **Check Official Documentation**: Refer to Confluence Cloud REST API v2 documentation +6. **Community Support**: Use the project's Discord chat or GitHub issues + +**Useful Debug Commands:** + +.. code-block:: python + + # Quick diagnostic + def diagnose_confluence_setup(confluence): + """Run basic diagnostics on Confluence setup.""" + print("=== Confluence Setup Diagnostics ===") + + # API version info + info = confluence.get_api_version_info() + print(f"API Version Info: {info}") + + # Test basic connectivity + try: + spaces = confluence.get_all_spaces(limit=1) + print(f"Basic connectivity: OK (found {len(spaces)} spaces)") + except Exception as e: + print(f"Basic connectivity: FAILED - {e}") + + # Test v2 API if available + if info.get('v2_available'): + try: + results = confluence.search_pages_with_cursor("type=page", limit=1) + print("v2 API access: OK") + except Exception as e: + print(f"v2 API access: FAILED - {e}") + else: + print("v2 API: Not available") + + print("=== End Diagnostics ===") + + # Run diagnostics + diagnose_confluence_setup(confluence) + Server-Specific Features ------------------------ diff --git a/docs/confluence_adf.rst b/docs/confluence_adf.rst new file mode 100644 index 000000000..3f82cfb7b --- /dev/null +++ b/docs/confluence_adf.rst @@ -0,0 +1,838 @@ +Confluence ADF (Atlassian Document Format) +========================================== + +ADF (Atlassian Document Format) is the native content format for Confluence Cloud v2 API. It provides a structured, JSON-based representation of rich content that enables precise control over formatting and layout. + +Overview +-------- + +ADF is a tree-based document format where each node has a type and optional attributes, content, and marks. This structure allows for rich content creation while maintaining consistency and enabling advanced features like collaborative editing. + +**Key Benefits:** + +- **Rich Content Support**: Native support for headings, lists, tables, media, and more +- **Structured Data**: JSON-based format that's easy to parse and manipulate +- **Version Control**: Built-in versioning for collaborative editing +- **Extensibility**: Support for custom content types and extensions +- **Performance**: Optimized for Confluence Cloud's modern architecture + +Basic ADF Structure +------------------- + +Every ADF document follows this basic structure: + +.. code-block:: json + + { + "version": 1, + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Hello, World!" + } + ] + } + ] + } + +**Required Fields:** + +- ``version``: Always 1 for current ADF specification +- ``type``: Always "doc" for the root document +- ``content``: Array of content nodes + +Node Types +---------- + +Text Nodes +~~~~~~~~~~~ + +Text nodes represent plain text content with optional formatting marks: + +.. code-block:: python + + # Plain text + text_node = { + "type": "text", + "text": "Plain text content" + } + + # Formatted text with marks + formatted_text = { + "type": "text", + "text": "Bold and italic text", + "marks": [ + {"type": "strong"}, + {"type": "em"} + ] + } + + # Text with link + link_text = { + "type": "text", + "text": "Visit our website", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://example.com", + "title": "Example Website" + } + } + ] + } + +Paragraph Nodes +~~~~~~~~~~~~~~~ + +Paragraphs are the most common block-level content: + +.. code-block:: python + + paragraph = { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "This is a paragraph with " + }, + { + "type": "text", + "text": "bold text", + "marks": [{"type": "strong"}] + }, + { + "type": "text", + "text": " and normal text." + } + ] + } + +Heading Nodes +~~~~~~~~~~~~~ + +Headings support levels 1-6: + +.. code-block:: python + + # Level 1 heading + h1 = { + "type": "heading", + "attrs": {"level": 1}, + "content": [ + {"type": "text", "text": "Main Heading"} + ] + } + + # Level 2 heading with formatting + h2 = { + "type": "heading", + "attrs": {"level": 2}, + "content": [ + { + "type": "text", + "text": "Subheading with emphasis", + "marks": [{"type": "em"}] + } + ] + } + +List Nodes +~~~~~~~~~~ + +Both bullet and ordered lists are supported: + +.. code-block:: python + + # Bullet list + bullet_list = { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "First item"} + ] + } + ] + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "Second item"} + ] + } + ] + } + ] + } + + # Ordered list + ordered_list = { + "type": "orderedList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "First numbered item"} + ] + } + ] + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "Second numbered item"} + ] + } + ] + } + ] + } + +Code Blocks +~~~~~~~~~~~ + +Code blocks support syntax highlighting: + +.. code-block:: python + + # Inline code + inline_code = { + "type": "text", + "text": "print('hello')", + "marks": [{"type": "code"}] + } + + # Code block + code_block = { + "type": "codeBlock", + "attrs": { + "language": "python" + }, + "content": [ + { + "type": "text", + "text": "def hello_world():\n print('Hello, World!')\n return True" + } + ] + } + +Tables +~~~~~~ + +Tables support complex structures with headers and formatting: + +.. code-block:: python + + table = { + "type": "table", + "attrs": { + "isNumberColumnEnabled": False, + "layout": "default" + }, + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "attrs": {}, + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "Name"} + ] + } + ] + }, + { + "type": "tableHeader", + "attrs": {}, + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "Role"} + ] + } + ] + } + ] + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {}, + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "John Doe"} + ] + } + ] + }, + { + "type": "tableCell", + "attrs": {}, + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "Developer"} + ] + } + ] + } + ] + } + ] + } + +Text Marks +---------- + +Marks provide inline formatting for text nodes: + +.. code-block:: python + + # Available marks + marks_examples = { + "strong": {"type": "strong"}, # Bold + "em": {"type": "em"}, # Italic + "code": {"type": "code"}, # Inline code + "strike": {"type": "strike"}, # Strikethrough + "underline": {"type": "underline"}, # Underline + "subsup": { # Subscript/Superscript + "type": "subsup", + "attrs": {"type": "sub"} # or "sup" + }, + "textColor": { # Text color + "type": "textColor", + "attrs": {"color": "#ff0000"} + }, + "link": { # Hyperlink + "type": "link", + "attrs": { + "href": "https://example.com", + "title": "Example" + } + } + } + + # Text with multiple marks + formatted_text = { + "type": "text", + "text": "Bold, italic, and colored text", + "marks": [ + {"type": "strong"}, + {"type": "em"}, + { + "type": "textColor", + "attrs": {"color": "#0066cc"} + } + ] + } + +Using ADF with Python Classes +----------------------------- + +The library provides Python classes for easier ADF construction: + +.. code-block:: python + + from atlassian.adf import ( + ADFDocument, + ADFParagraph, + ADFText, + ADFHeading, + create_simple_adf_document, + convert_text_to_adf + ) + + # Create document using classes + document = ADFDocument() + + # Add heading + heading = ADFHeading(level=1, content=[ + ADFText("Welcome to ADF") + ]) + document.add_content(heading) + + # Add paragraph with formatted text + paragraph = ADFParagraph([ + ADFText("This is "), + ADFText("bold text", marks=[{"type": "strong"}]), + ADFText(" and this is "), + ADFText("italic text", marks=[{"type": "em"}]), + ADFText(".") + ]) + document.add_content(paragraph) + + # Convert to dictionary for API submission + adf_dict = document.to_dict() + + # Create page with constructed ADF + page = confluence.create_page_with_adf("SPACE123", "My Page", adf_dict) + +Utility Functions +---------------- + +Content Conversion +~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from atlassian.adf import ( + convert_text_to_adf, + convert_storage_to_adf, + convert_adf_to_storage, + validate_adf_document + ) + + # Convert plain text to ADF + text = "Hello, World!" + adf_content = convert_text_to_adf(text) + + # Convert storage format to ADF (basic conversion) + storage_content = "Hello, World!
" + adf_content = convert_storage_to_adf(storage_content) + + # Convert ADF back to storage format + storage_content = convert_adf_to_storage(adf_content) + + # Validate ADF structure + is_valid = validate_adf_document(adf_content) + if not is_valid: + print("Invalid ADF structure") + +Content Detection +~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from atlassian.request_utils import ( + detect_content_format, + is_adf_content, + validate_adf_structure + ) + + # Detect content format + content_format = detect_content_format(content) + print(f"Content format: {content_format}") # 'adf', 'storage', 'wiki', or 'unknown' + + # Check if content is ADF + if is_adf_content(content): + print("Content is in ADF format") + + # Validate ADF structure + if validate_adf_structure(adf_content): + print("Valid ADF structure") + +Complex ADF Examples +-------------------- + +Rich Document Example +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # Create a complex document with multiple content types + complex_adf = { + "version": 1, + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": {"level": 1}, + "content": [ + {"type": "text", "text": "Project Documentation"} + ] + }, + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "This document contains important information about our project."} + ] + }, + { + "type": "heading", + "attrs": {"level": 2}, + "content": [ + {"type": "text", "text": "Features"} + ] + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "ADF Support", + "marks": [{"type": "strong"}] + } + ] + } + ] + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Cursor-based Pagination", + "marks": [{"type": "strong"}] + } + ] + } + ] + } + ] + }, + { + "type": "heading", + "attrs": {"level": 2}, + "content": [ + {"type": "text", "text": "Code Example"} + ] + }, + { + "type": "codeBlock", + "attrs": {"language": "python"}, + "content": [ + { + "type": "text", + "text": "# Create a page with ADF content\npage = confluence.create_page_with_adf(\n space_id=\"SPACE123\",\n title=\"My Page\",\n adf_content=adf_content\n)" + } + ] + } + ] + } + + # Create the page + page = confluence.create_page_with_adf("SPACE123", "Complex Document", complex_adf) + +Template-Based Content Creation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + def create_meeting_notes_adf(meeting_title, date, attendees, agenda_items, notes): + """Create ADF content for meeting notes.""" + + content = [ + { + "type": "heading", + "attrs": {"level": 1}, + "content": [{"type": "text", "text": meeting_title}] + }, + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "Date: "}, + {"type": "text", "text": date, "marks": [{"type": "strong"}]} + ] + }, + { + "type": "heading", + "attrs": {"level": 2}, + "content": [{"type": "text", "text": "Attendees"}] + } + ] + + # Add attendees list + attendees_list = { + "type": "bulletList", + "content": [] + } + + for attendee in attendees: + attendees_list["content"].append({ + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": attendee}] + } + ] + }) + + content.append(attendees_list) + + # Add agenda + content.append({ + "type": "heading", + "attrs": {"level": 2}, + "content": [{"type": "text", "text": "Agenda"}] + }) + + agenda_list = { + "type": "orderedList", + "content": [] + } + + for item in agenda_items: + agenda_list["content"].append({ + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": item}] + } + ] + }) + + content.append(agenda_list) + + # Add notes section + content.append({ + "type": "heading", + "attrs": {"level": 2}, + "content": [{"type": "text", "text": "Notes"}] + }) + + content.append({ + "type": "paragraph", + "content": [{"type": "text", "text": notes}] + }) + + return { + "version": 1, + "type": "doc", + "content": content + } + + # Use the template + meeting_adf = create_meeting_notes_adf( + meeting_title="Weekly Team Standup", + date="2024-01-15", + attendees=["Alice", "Bob", "Charlie"], + agenda_items=[ + "Review last week's progress", + "Discuss current blockers", + "Plan next week's tasks" + ], + notes="Team discussed the new feature implementation and agreed on the timeline." + ) + + # Create the meeting notes page + page = confluence.create_page_with_adf("TEAM", "Weekly Standup - Jan 15", meeting_adf) + +Best Practices +-------------- + +Structure and Organization +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # Good: Well-structured document hierarchy + good_structure = { + "version": 1, + "type": "doc", + "content": [ + # Main heading + { + "type": "heading", + "attrs": {"level": 1}, + "content": [{"type": "text", "text": "Main Topic"}] + }, + # Introduction paragraph + { + "type": "paragraph", + "content": [{"type": "text", "text": "Introduction text..."}] + }, + # Subsection + { + "type": "heading", + "attrs": {"level": 2}, + "content": [{"type": "text", "text": "Subsection"}] + }, + # Content for subsection + { + "type": "paragraph", + "content": [{"type": "text", "text": "Subsection content..."}] + } + ] + } + + # Avoid: Flat structure without hierarchy + # Don't create documents with only paragraphs or only headings + +Content Validation +~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + def create_safe_adf_page(space_id, title, adf_content): + """Safely create a page with ADF content validation.""" + + # Validate ADF structure + if not validate_adf_document(adf_content): + raise ValueError("Invalid ADF document structure") + + # Check for required fields + if adf_content.get("version") != 1: + raise ValueError("ADF version must be 1") + + if adf_content.get("type") != "doc": + raise ValueError("ADF type must be 'doc'") + + if not isinstance(adf_content.get("content"), list): + raise ValueError("ADF content must be a list") + + # Create the page + try: + return confluence.create_page_with_adf(space_id, title, adf_content) + except Exception as e: + print(f"Failed to create page: {e}") + raise + +Performance Considerations +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # Good: Efficient ADF structure + # Use appropriate node types for content + # Avoid deeply nested structures when possible + # Group related content logically + + # Good: Reasonable document size + efficient_adf = { + "version": 1, + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "Concise, well-structured content"} + ] + } + ] + } + + # Avoid: Overly complex nested structures + # Avoid: Extremely large documents (consider splitting) + # Avoid: Unnecessary nesting levels + +Common Pitfalls +-------------- + +Invalid Structure +~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # Wrong: Missing required fields + invalid_adf = { + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "Hello"}]} + ] + } + # Missing "version" and "type" fields + + # Wrong: Incorrect version + invalid_version = { + "version": 2, # Should be 1 + "type": "doc", + "content": [] + } + + # Correct: Proper structure + valid_adf = { + "version": 1, + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "Hello"}] + } + ] + } + +Incorrect Node Types +~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # Wrong: Invalid node type + invalid_node = { + "type": "invalidType", # Not a valid ADF node type + "content": [] + } + + # Wrong: Missing required attributes + invalid_heading = { + "type": "heading", + # Missing "attrs" with "level" + "content": [{"type": "text", "text": "Heading"}] + } + + # Correct: Valid heading with required attributes + valid_heading = { + "type": "heading", + "attrs": {"level": 1}, + "content": [{"type": "text", "text": "Heading"}] + } + +Text Mark Issues +~~~~~~~~~~~~~~~ + +.. code-block:: python + + # Wrong: Invalid mark structure + invalid_marks = { + "type": "text", + "text": "Bold text", + "marks": "strong" # Should be a list + } + + # Wrong: Invalid mark type + invalid_mark_type = { + "type": "text", + "text": "Text", + "marks": [{"type": "invalidMark"}] + } + + # Correct: Valid marks structure + valid_marks = { + "type": "text", + "text": "Bold text", + "marks": [{"type": "strong"}] + } + +Resources +--------- + +- `Atlassian Document Format SpecificationHello, World!
", + "representation": "storage" + } + } +} +page = confluence.create_content(content_data) + +# v2 API (native ADF) +adf_content = { + "version": 1, + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "Hello, World!"}] + } + ] +} +page = confluence.create_page_with_adf("SPACE123", "My Page", adf_content) +``` + +### 3. Enhanced Performance + +v2 API provides better performance for: + +- Large search result sets + +- Bulk operations + +- Content with rich formatting + +## Migration Warnings + +The library provides helpful warnings when v2 API would provide better performance: + +```python + +# This will issue a warning for large pagination requests +results = confluence.search_content("type=page", limit=200, start=1000) + +# Warning: search_content() will continue to work but consider using + +# search_pages_with_cursor() for cursor-based pagination and better + +# performance with large result sets. +``` + +To disable warnings, enable v2 API support: + +```python +confluence.enable_v2_api() # No more warnings +``` + +## API Version Information + +Check your current API configuration: + +```python +info = confluence.get_api_version_info() +print(info) + +# { + +# 'v1_available': True, + +# 'v2_available': True, + +# 'force_v2_api': False, + +# 'prefer_v2_api': False, + +# 'current_default': 'v1' + +# } +``` + +## Method Mapping + +### Content Management + +| v1 Method | v2 Equivalent | Notes | +|-----------|---------------|-------| +| `get_content()` | `get_page_with_adf()` | v2 returns ADF content | +| `create_content()` | `create_page_with_adf()` | v2 accepts ADF content | +| `update_content()` | `update_page_with_adf()` | v2 accepts ADF content | +| `search_content()` | `search_pages_with_cursor()` | v2 uses cursor pagination | + +### Pagination + +| v1 Approach | v2 Approach | Benefits | +|-------------|-------------|----------| +| `limit=50, start=100` | `limit=50, cursor="token"` | Better performance, no offset limits | +| Offset-based | Cursor-based | Consistent results, handles large datasets | + +## Best Practices + +### 1. Gradual Migration + +Start by enabling v2 API support without changing your code: + +```python + +# Step 1: Enable v2 API +confluence.enable_v2_api() + +# Step 2: Your existing code benefits from v2 performance + +# (no code changes required) + +# Step 3: Gradually adopt v2-specific methods for new features +``` + +### 2. Use v2 for New Development + +For new applications, consider using v2-specific methods: + +```python + +# New development - use v2 methods directly +page = confluence.create_page_with_adf(space_id, title, adf_content) +results = confluence.search_pages_with_cursor(cql, limit=50) +``` + +### 3. Handle Both Formats + +If you need to support both v1 and v2 responses: + +```python +def get_page_content(confluence, page_id): + """Get page content, handling both v1 and v2 formats.""" + if confluence.get_api_version_info()['current_default'] == 'v2': + page = confluence.get_page_with_adf(page_id, expand=['body']) + return page['body']['atlas_doc_format']['value'] + else: + page = confluence.get_content(page_id, expand='body.storage') + return page['body']['storage']['value'] +``` + +## Troubleshooting + +### Common Issues + +1. **"v2 API client not available" Error** + + - Ensure you have proper authentication configured + + - Check that your Confluence Cloud instance supports v2 API + +2. **Different Response Formats** + + - v2 API returns different data structures + + - Use v2-specific methods for consistent v2 format + + - Use existing methods for v1 format compatibility + +3. **Pagination Differences** + + - v1 uses `start` and `limit` parameters + + - v2 uses `cursor` and `limit` parameters + + - Use appropriate method for your pagination needs + +### Getting Help + +- Check the API version info: `confluence.get_api_version_info()` + +- Enable debug logging to see which API version is being used + +- Refer to official Confluence Cloud REST API v2 documentation + +## Summary + +The dual API support provides: + +- **Complete backward compatibility** - existing code works unchanged + +- **Optional v2 features** - enable when you need enhanced functionality + +- **Gradual migration path** - migrate at your own pace + +- **Performance benefits** - better pagination and content handling + +- **Future-proofing** - ready for v2 API adoption + +Choose the migration approach that best fits your needs, from no changes required to full v2 API adoption. \ No newline at end of file diff --git a/docs/confluence_v2_migration.rst b/docs/confluence_v2_migration.rst new file mode 100644 index 000000000..c37920230 --- /dev/null +++ b/docs/confluence_v2_migration.rst @@ -0,0 +1,335 @@ +Confluence Cloud v2 API Migration Guide +======================================== + +This guide explains how to migrate from Confluence Cloud v1 API to v2 API while maintaining backward compatibility. + +Overview +-------- + +The atlassian-python-api library now supports both Confluence Cloud v1 and v2 APIs with complete backward compatibility. All existing code will continue to work unchanged, while new features are available through v2 API support. + +Backward Compatibility Guarantee +-------------------------------- + +**All existing method signatures and behaviors are preserved.** Your existing code will continue to work exactly as before without any changes required. + +What's Preserved +~~~~~~~~~~~~~~~ + +- All method signatures remain unchanged +- All parameter names and types remain the same +- All return value formats remain consistent +- All error handling behavior remains the same +- Default behavior uses v1 API (no breaking changes) + +Migration Options +----------------- + +Option 1: No Changes Required (Recommended for Most Users) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Your existing code continues to work unchanged: + +.. code-block:: python + + from atlassian import ConfluenceCloud + + # This continues to work exactly as before + confluence = ConfluenceCloud(url="https://your-domain.atlassian.net", token="your-token") + + # All existing methods work unchanged + page = confluence.get_content("123456") + spaces = confluence.get_spaces() + results = confluence.search_content("type=page AND space=DEMO") + +Option 2: Enable v2 API for Enhanced Features +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Enable v2 API support for new features while maintaining compatibility: + +.. code-block:: python + + from atlassian import ConfluenceCloud + + # Enable v2 API support + confluence = ConfluenceCloud(url="https://your-domain.atlassian.net", token="your-token") + confluence.enable_v2_api() # Prefer v2 API when available + + # Existing methods now use v2 API when beneficial + results = confluence.search_content("type=page", limit=100) # Uses cursor pagination + +Option 3: Force v2 API Usage +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Force all operations to use v2 API: + +.. code-block:: python + + from atlassian import ConfluenceCloud + + # Force v2 API usage + confluence = ConfluenceCloud( + url="https://your-domain.atlassian.net", + token="your-token", + force_v2_api=True + ) + + # Or enable after initialization + confluence.enable_v2_api(force=True) + +Option 4: Use v2-Specific Methods +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use new v2-specific methods for enhanced functionality: + +.. code-block:: python + + from atlassian import ConfluenceCloud + + confluence = ConfluenceCloud(url="https://your-domain.atlassian.net", token="your-token") + + # Create page with native ADF content + adf_content = { + "version": 1, + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "Hello, World!"} + ] + } + ] + } + + page = confluence.create_page_with_adf("SPACE123", "My Page", adf_content) + + # Search with cursor-based pagination + results = confluence.search_pages_with_cursor("type=page AND space=DEMO", limit=50) + +Key Benefits of v2 API +---------------------- + +1. Cursor-Based Pagination +~~~~~~~~~~~~~~~~~~~~~~~~~ + +v2 API provides cursor-based pagination for better performance with large result sets: + +.. code-block:: python + + # v1 API (offset-based, slower for large datasets) + results = confluence.search_content("type=page", limit=50, start=1000) + + # v2 API (cursor-based, faster and more reliable) + results = confluence.search_pages_with_cursor("type=page", limit=50, cursor="cursor_token") + +2. Native ADF Support +~~~~~~~~~~~~~~~~~~~~ + +v2 API supports Atlassian Document Format (ADF) natively: + +.. code-block:: python + + # v1 API (storage format) + content_data = { + "type": "page", + "title": "My Page", + "space": {"key": "DEMO"}, + "body": { + "storage": { + "value": "Hello, World!
", + "representation": "storage" + } + } + } + page = confluence.create_content(content_data) + + # v2 API (native ADF) + adf_content = { + "version": 1, + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "Hello, World!"}] + } + ] + } + page = confluence.create_page_with_adf("SPACE123", "My Page", adf_content) + +3. Enhanced Performance +~~~~~~~~~~~~~~~~~~~~~~ + +v2 API provides better performance for: + +- Large search result sets +- Bulk operations +- Content with rich formatting + +Migration Warnings +------------------ + +The library provides helpful warnings when v2 API would provide better performance: + +.. code-block:: python + + # This will issue a warning for large pagination requests + results = confluence.search_content("type=page", limit=200, start=1000) + # Warning: search_content() will continue to work but consider using + # search_pages_with_cursor() for cursor-based pagination and better + # performance with large result sets. + +To disable warnings, enable v2 API support: + +.. code-block:: python + + confluence.enable_v2_api() # No more warnings + +API Version Information +---------------------- + +Check your current API configuration: + +.. code-block:: python + + info = confluence.get_api_version_info() + print(info) + # { + # 'v1_available': True, + # 'v2_available': True, + # 'force_v2_api': False, + # 'prefer_v2_api': False, + # 'current_default': 'v1' + # } + +Method Mapping +-------------- + +Content Management +~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 30 30 40 + + * - v1 Method + - v2 Equivalent + - Notes + * - ``get_content()`` + - ``get_page_with_adf()`` + - v2 returns ADF content + * - ``create_content()`` + - ``create_page_with_adf()`` + - v2 accepts ADF content + * - ``update_content()`` + - ``update_page_with_adf()`` + - v2 accepts ADF content + * - ``search_content()`` + - ``search_pages_with_cursor()`` + - v2 uses cursor pagination + +Pagination +~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 30 30 40 + + * - v1 Approach + - v2 Approach + - Benefits + * - ``limit=50, start=100`` + - ``limit=50, cursor="token"`` + - Better performance, no offset limits + * - Offset-based + - Cursor-based + - Consistent results, handles large datasets + +Best Practices +-------------- + +1. Gradual Migration +~~~~~~~~~~~~~~~~~~~ + +Start by enabling v2 API support without changing your code: + +.. code-block:: python + + # Step 1: Enable v2 API + confluence.enable_v2_api() + + # Step 2: Your existing code benefits from v2 performance + # (no code changes required) + + # Step 3: Gradually adopt v2-specific methods for new features + +2. Use v2 for New Development +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For new applications, consider using v2-specific methods: + +.. code-block:: python + + # New development - use v2 methods directly + page = confluence.create_page_with_adf(space_id, title, adf_content) + results = confluence.search_pages_with_cursor(cql, limit=50) + +3. Handle Both Formats +~~~~~~~~~~~~~~~~~~~~~ + +If you need to support both v1 and v2 responses: + +.. code-block:: python + + def get_page_content(confluence, page_id): + """Get page content, handling both v1 and v2 formats.""" + if confluence.get_api_version_info()['current_default'] == 'v2': + page = confluence.get_page_with_adf(page_id, expand=['body']) + return page['body']['atlas_doc_format']['value'] + else: + page = confluence.get_content(page_id, expand='body.storage') + return page['body']['storage']['value'] + +Troubleshooting +-------------- + +Common Issues +~~~~~~~~~~~~ + +1. **"v2 API client not available" Error** + + - Ensure you have proper authentication configured + - Check that your Confluence Cloud instance supports v2 API + +2. **Different Response Formats** + + - v2 API returns different data structures + - Use v2-specific methods for consistent v2 format + - Use existing methods for v1 format compatibility + +3. **Pagination Differences** + + - v1 uses ``start`` and ``limit`` parameters + - v2 uses ``cursor`` and ``limit`` parameters + - Use appropriate method for your pagination needs + +Getting Help +~~~~~~~~~~~ + +- Check the API version info: ``confluence.get_api_version_info()`` +- Enable debug logging to see which API version is being used +- Refer to official Confluence Cloud REST API v2 documentation +- See the :doc:`confluence` troubleshooting section for detailed solutions + +Summary +------- + +The dual API support provides: + +- **Complete backward compatibility** - existing code works unchanged +- **Optional v2 features** - enable when you need enhanced functionality +- **Gradual migration path** - migrate at your own pace +- **Performance benefits** - better pagination and content handling +- **Future-proofing** - ready for v2 API adoption + +Choose the migration approach that best fits your needs, from no changes required to full v2 API adoption. \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index c7cc920e5..1438ada2d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -245,6 +245,8 @@ Add a connection: jira confluence + confluence_adf + confluence_v2_migration crowd bitbucket bamboo diff --git a/examples/confluence/README.md b/examples/confluence/README.md index 499e2476f..6eec120d3 100644 --- a/examples/confluence/README.md +++ b/examples/confluence/README.md @@ -1,6 +1,6 @@ # Confluence Examples -This directory contains examples demonstrating how to use the new Confluence API client with both Cloud and Server implementations. +This directory contains comprehensive examples demonstrating how to use the Confluence API client with both Cloud and Server implementations, including the new v2 API features. ## Structure @@ -8,7 +8,13 @@ This directory contains examples demonstrating how to use the new Confluence API examples/confluence/ ├── README.md ├── cloud/ -│ └── confluence_cloud_content_management.py +│ ├── confluence_cloud_content_management.py +│ ├── confluence_v2_api_basics.py +│ ├── confluence_adf_content_examples.py +│ ├── confluence_v1_to_v2_migration.py +│ ├── confluence_cursor_pagination.py +│ ├── confluence_error_handling.py +│ └── confluence_dual_api_configuration.py └── server/ └── confluence_server_content_management.py ``` @@ -17,44 +23,150 @@ examples/confluence/ ### Confluence Cloud -The `confluence_cloud_content_management.py` example demonstrates: +#### Basic Cloud API Usage + +**`confluence_cloud_content_management.py`** - Basic Confluence Cloud operations: - Initializing the Confluence Cloud client + - Getting spaces and space content + - Retrieving pages and page details + - Working with page children, labels, comments, and attachments + - Searching for content + - Getting user information -**Prerequisites:** +#### v2 API Examples + +**`confluence_v2_api_basics.py`** - Fundamental v2 API operations: + +- v2 API client initialization and configuration + +- Basic page operations (create, read, update, delete) + +- ADF (Atlassian Document Format) content handling + +- Cursor-based pagination + +- Error handling and best practices + +**`confluence_adf_content_examples.py`** - ADF content creation and manipulation: + +- Creating various ADF content types (headings, paragraphs, lists, tables) + +- Working with text formatting (bold, italic, links, code) + +- Using panels, code blocks, and other advanced elements + +- Converting between different content formats + +- Best practices for ADF content creation + +**`confluence_v1_to_v2_migration.py`** - Migration from v1 to v2 API: + +- API endpoint differences + +- Content format changes (Storage Format → ADF) + +- Pagination changes (offset-based → cursor-based) + +- Response structure differences + +- Migration strategies and best practices + +**`confluence_cursor_pagination.py`** - Cursor-based pagination: + +- Basic cursor pagination for pages and spaces + +- Handling pagination state and continuation + +- Performance comparison with offset-based pagination + +- Best practices for large dataset processing + +- Memory-efficient iteration patterns + +**`confluence_error_handling.py`** - Comprehensive error handling: + +- Common API error types and handling + +- Authentication and authorization errors + +- Rate limiting and throttling + +- Content validation errors + +- Debugging and logging strategies + +- Retry mechanisms and recovery patterns + +**`confluence_dual_api_configuration.py`** - Dual API usage: + +- Configuring dual API support + +- Switching between v1 and v2 APIs dynamically + +- API feature comparison and selection + +- Migration strategies and compatibility + +- Performance considerations + +#### Prerequisites for Cloud Examples + - Confluence Cloud instance + - API token (not username/password) -**Usage:** +- Python 3.9+ + +#### Usage ```bash cd examples/confluence/cloud -python confluence_cloud_content_management.py +python [example_name].py ``` -**Configuration:** -Update the following in the script: -- `url`: Your Confluence Cloud domain (e.g., `https://your-domain.atlassian.net`) -- `token`: Your API token +#### Configuration +Update the following variables in each script: + +- `CONFLUENCE_URL`: Your Confluence Cloud domain (e.g., `https://your-domain.atlassian.net`) + +- `API_TOKEN`: Your API token + +- `TEST_SPACE_KEY`: A test space key (optional, defaults to "DEMO") + +You can also set configuration via environment variables: +```bash +export CONFLUENCE_URL='https://your-domain.atlassian.net' +export CONFLUENCE_TOKEN='your-api-token' +export TEST_SPACE_KEY='DEMO' +``` ### Confluence Server -The `confluence_server_content_management.py` example demonstrates: +**`confluence_server_content_management.py`** - Server API operations: - Initializing the Confluence Server client + - Getting spaces and space content + - Working with pages, blog posts, and drafts + - Managing page labels, comments, and attachments + - Searching with CQL (Confluence Query Language) + - User and group management + - Trash and draft content management **Prerequisites:** + - Confluence Server instance + - Username and password credentials **Usage:** @@ -65,8 +177,11 @@ python confluence_server_content_management.py **Configuration:** Update the following in the script: + - `url`: Your Confluence Server URL (e.g., `https://your-confluence-server.com`) + - `username`: Your username + - `password`: Your password ## API Differences @@ -76,24 +191,45 @@ Update the following in the script: | Feature | Cloud | Server | |---------|-------|--------| | Authentication | API Token | Username/Password | -| API Version | v2 | v1.0 | -| API Root | `wiki/api/v2` | `rest/api/1.0` | -| Pagination | `_links.next.href` | `_links.next.href` | -| Content IDs | UUID strings | Numeric IDs | -| Space IDs | UUID strings | Space keys | +| API Version | v1/v2 (dual support) | v1.0 | +| API Root | `wiki/rest/api` (v1), `wiki/api/v2` (v2) | `rest/api/1.0` | +| Pagination | Offset-based (v1), Cursor-based (v2) | Offset-based | +| Content IDs | Numeric (v1), UUID strings (v2) | Numeric IDs | +| Space IDs | Space keys (v1), UUID strings (v2) | Space keys | +| Content Format | Storage Format (v1), ADF (v2) | Storage Format | + +### v1 vs v2 API (Cloud Only) + +| Aspect | v1 API | v2 API | +|--------|--------|--------| +| **Content Format** | Storage Format (XHTML-like) | ADF (Atlassian Document Format) | +| **Pagination** | Offset-based (`start`/`limit`) | Cursor-based (`cursor`) | +| **Performance** | Standard | Enhanced | +| **Response Structure** | Nested expansion model | Flatter, more consistent | +| **ID Format** | Numeric IDs | UUID strings | +| **Space Reference** | Space keys | Space IDs | +| **Best For** | Legacy integrations, complex content | New integrations, high performance | ### Common Operations -Both implementations support: +Both Cloud and Server implementations support: - Content management (create, read, update, delete) + - Space management + - User and group management + - Label management + - Attachment handling + - Comment management + - Search functionality + - Page properties + - Export capabilities ### Server-Specific Features @@ -101,30 +237,193 @@ Both implementations support: The Server implementation includes additional features: - Draft content management + - Trash content management + - Reindex operations + - Space permissions + - Space settings +### Cloud v2 API Features + +The Cloud v2 API provides: + +- Native ADF content support + +- Cursor-based pagination for better performance + +- Optimized response structures + +- Enhanced error handling + +- Better support for large datasets + +## Getting Started + +### For New Projects + +1. **Cloud**: Start with v2 API examples for best performance and modern features + +2. **Server**: Use the server examples for on-premises installations + +### For Existing Projects + +1. **Cloud**: Consider migrating from v1 to v2 using the migration example + +2. **Server**: Continue using existing patterns, v2 API not available + +### Example Learning Path + +1. **Start with basics**: `confluence_cloud_content_management.py` + +2. **Learn v2 API**: `confluence_v2_api_basics.py` + +3. **Master ADF content**: `confluence_adf_content_examples.py` + +4. **Handle pagination**: `confluence_cursor_pagination.py` + +5. **Implement error handling**: `confluence_error_handling.py` + +6. **Plan migration**: `confluence_v1_to_v2_migration.py` + +7. **Configure dual API**: `confluence_dual_api_configuration.py` + ## Error Handling -All examples include basic error handling. In production applications, you should implement more robust error handling based on your specific requirements. +All examples include comprehensive error handling patterns. Key areas covered: + +- **Authentication errors**: Invalid tokens, expired credentials + +- **Authorization errors**: Insufficient permissions + +- **Validation errors**: Invalid content structure, missing fields + +- **Rate limiting**: API throttling and backoff strategies + +- **Network issues**: Connectivity problems, timeouts + +- **Content errors**: ADF validation, format conversion ## Rate Limiting Be aware of API rate limits: + - **Cloud**: Varies by plan, typically 1000 requests per hour + - **Server**: Depends on server configuration +- **Best practices**: Implement exponential backoff, use cursor pagination, cache data + ## Security Notes - Never commit credentials to version control + - Use environment variables or secure credential storage + - API tokens for Cloud are preferred over username/password + - Consider using OAuth 2.0 for production applications +- Validate and sanitize all user input + +## Performance Tips + +### For v1 API + +- Use appropriate `expand` parameters to get needed data in one call + +- Implement proper pagination with `start` and `limit` + +- Cache frequently accessed data + +- Use bulk operations when available + +### For v2 API + +- Leverage cursor-based pagination for large datasets + +- Use ADF format for better performance + +- Take advantage of optimized response structures + +- Implement proper error handling and retry logic + +## Content Format Guide + +### Storage Format (v1 API) +```xml +This is a paragraph with formatting.
+This is an info panel.
+This page was created using the v1 API with Storage Format.
+Storage Format uses XHTML-like markup:
+This is an info panel created with Storage Format.
+| Project Key | Project Name | Leader | {project_name} | {lead_name} | {lead_email} | - """.format( - **data - ) + """.format(**data) html += "
|---|
Hello, World!
" + result = confluence_v2._prepare_content_for_v2(storage_content, "storage") + + assert result["representation"] == "atlas_doc_format" + assert validate_adf_document(result["value"]) + + def test_prepare_invalid_adf_content(self, confluence_v2): + """Test preparing invalid ADF content raises error.""" + invalid_adf = {"type": "invalid", "content": []} + + with pytest.raises(ValueError, match="Invalid ADF content structure"): + confluence_v2._prepare_content_for_v2(invalid_adf, "adf") + + def test_prepare_unsupported_content_format(self, confluence_v2): + """Test preparing content with unsupported format.""" + with pytest.raises(ValueError, match="Unsupported content format"): + confluence_v2._prepare_content_for_v2({"invalid": "data"}) + + +class TestConfluenceCloudV2PageOperations: + """Test cases for page operations using v2 API.""" + + @patch.object(ConfluenceCloudV2, "get") + def test_get_page_by_id(self, mock_get, confluence_v2, sample_page_response): + """Test get_page_by_id method.""" + mock_get.return_value = sample_page_response + + result = confluence_v2.get_page_by_id("123456") + + # Verify the correct endpoint was called + expected_endpoint = confluence_v2._get_v2_endpoint("pages/123456") + expected_headers = confluence_v2.v2_headers + # Without expand, params should be empty + mock_get.assert_called_once_with(expected_endpoint, params={}, headers=expected_headers) + + assert result == sample_page_response + assert result["id"] == "123456" + assert result["title"] == "Test Page" + + @patch.object(ConfluenceCloudV2, "get") + def test_get_page_by_id_with_expand(self, mock_get, confluence_v2, sample_page_response): + """Test get_page_by_id method with expand parameters.""" + mock_get.return_value = sample_page_response + + result = confluence_v2.get_page_by_id("123456", expand=["body", "version", "space"]) + + expected_endpoint = confluence_v2._get_v2_endpoint("pages/123456") + expected_params = {"body-format": "atlas_doc_format", "expand": "body,version,space"} + expected_headers = confluence_v2.v2_headers + mock_get.assert_called_once_with(expected_endpoint, params=expected_params, headers=expected_headers) + + assert result == sample_page_response + + @patch.object(ConfluenceCloudV2, "get") + def test_get_pages(self, mock_get, confluence_v2, sample_pages_response): + """Test get_pages method with cursor pagination.""" + mock_get.return_value = sample_pages_response + + result = confluence_v2.get_pages(space_id="SPACE123", limit=50) + + expected_endpoint = confluence_v2._get_v2_endpoint("pages") + expected_params = {"limit": 50, "body-format": "atlas_doc_format", "space-id": "SPACE123"} + expected_headers = confluence_v2.v2_headers + mock_get.assert_called_once_with(expected_endpoint, params=expected_params, headers=expected_headers) + + assert result == sample_pages_response + assert len(result["results"]) == 2 + assert "next" in result["_links"] + + @patch.object(ConfluenceCloudV2, "get") + def test_get_pages_with_cursor(self, mock_get, confluence_v2, sample_pages_response): + """Test get_pages method with cursor for pagination.""" + mock_get.return_value = sample_pages_response + + result = confluence_v2.get_pages(limit=25, cursor="test_cursor") + + expected_endpoint = confluence_v2._get_v2_endpoint("pages") + expected_params = {"limit": 25, "body-format": "atlas_doc_format", "cursor": "test_cursor"} + expected_headers = confluence_v2.v2_headers + mock_get.assert_called_once_with(expected_endpoint, params=expected_params, headers=expected_headers) + + assert result == sample_pages_response + + @patch.object(ConfluenceCloudV2, "post") + def test_create_page_with_adf(self, mock_post, confluence_v2, sample_adf_content, sample_page_response): + """Test create_page method with ADF content.""" + mock_post.return_value = sample_page_response + + result = confluence_v2.create_page( + space_id="SPACE123", title="Test Page", content=sample_adf_content, content_format="adf" + ) + + expected_endpoint = confluence_v2._get_v2_endpoint("pages") + expected_data = { + "spaceId": "SPACE123", + "title": "Test Page", + "body": {"representation": "atlas_doc_format", "value": sample_adf_content}, + } + expected_headers = confluence_v2.v2_headers + mock_post.assert_called_once_with(expected_endpoint, json=expected_data, headers=expected_headers) + + assert result == sample_page_response + + @patch.object(ConfluenceCloudV2, "post") + def test_create_page_with_text(self, mock_post, confluence_v2, sample_page_response): + """Test create_page method with plain text content.""" + mock_post.return_value = sample_page_response + + result = confluence_v2.create_page( + space_id="SPACE123", title="Test Page", content="Hello, World!", parent_id="789012" + ) + + expected_endpoint = confluence_v2._get_v2_endpoint("pages") + expected_headers = confluence_v2.v2_headers + # Verify the call was made (content will be converted to ADF internally) + mock_post.assert_called_once() + call_args = mock_post.call_args + + assert call_args[0][0] == expected_endpoint + assert call_args[1]["json"]["spaceId"] == "SPACE123" + assert call_args[1]["json"]["title"] == "Test Page" + assert call_args[1]["json"]["parentId"] == "789012" + assert call_args[1]["json"]["body"]["representation"] == "atlas_doc_format" + assert validate_adf_document(call_args[1]["json"]["body"]["value"]) + assert call_args[1]["headers"] == expected_headers + + assert result == sample_page_response + + @patch.object(ConfluenceCloudV2, "put") + def test_update_page(self, mock_put, confluence_v2, sample_adf_content, sample_page_response): + """Test update_page method.""" + mock_put.return_value = sample_page_response + + result = confluence_v2.update_page( + page_id="123456", title="Updated Title", content=sample_adf_content, content_format="adf", version=2 + ) + + expected_endpoint = confluence_v2._get_v2_endpoint("pages/123456") + expected_data = { + "title": "Updated Title", + "body": {"representation": "atlas_doc_format", "value": sample_adf_content}, + "version": {"number": 2}, + } + expected_headers = confluence_v2.v2_headers + mock_put.assert_called_once_with(expected_endpoint, json=expected_data, headers=expected_headers) + + assert result == sample_page_response + + @patch.object(ConfluenceCloudV2, "put") + def test_update_page_title_only(self, mock_put, confluence_v2, sample_page_response): + """Test update_page method with title only.""" + mock_put.return_value = sample_page_response + + result = confluence_v2.update_page(page_id="123456", title="New Title") + + expected_endpoint = confluence_v2._get_v2_endpoint("pages/123456") + expected_data = {"title": "New Title"} + expected_headers = confluence_v2.v2_headers + mock_put.assert_called_once_with(expected_endpoint, json=expected_data, headers=expected_headers) + + assert result == sample_page_response + + @patch.object(ConfluenceCloudV2, "delete") + def test_delete_page(self, mock_delete, confluence_v2): + """Test delete_page method.""" + mock_delete.return_value = None + + result = confluence_v2.delete_page("123456") + + expected_endpoint = confluence_v2._get_v2_endpoint("pages/123456") + expected_headers = confluence_v2.v2_headers + mock_delete.assert_called_once_with(expected_endpoint, headers=expected_headers) + + assert result is None + + +class TestConfluenceCloudV2SpaceOperations: + """Test cases for space operations using v2 API.""" + + @patch.object(ConfluenceCloudV2, "get") + def test_get_spaces(self, mock_get, confluence_v2, sample_spaces_response): + """Test get_spaces method.""" + mock_get.return_value = sample_spaces_response + + result = confluence_v2.get_spaces(limit=50) + + expected_endpoint = confluence_v2._get_v2_endpoint("spaces") + expected_params = {"limit": 50} + expected_headers = confluence_v2.v2_headers + mock_get.assert_called_once_with(expected_endpoint, params=expected_params, headers=expected_headers) + + assert result == sample_spaces_response + assert len(result["results"]) == 1 + assert result["results"][0]["key"] == "TEST" + + @patch.object(ConfluenceCloudV2, "get") + def test_get_spaces_with_cursor(self, mock_get, confluence_v2, sample_spaces_response): + """Test get_spaces method with cursor pagination.""" + mock_get.return_value = sample_spaces_response + + result = confluence_v2.get_spaces(limit=25, cursor="test_cursor") + + expected_endpoint = confluence_v2._get_v2_endpoint("spaces") + expected_params = {"limit": 25, "cursor": "test_cursor"} + expected_headers = confluence_v2.v2_headers + mock_get.assert_called_once_with(expected_endpoint, params=expected_params, headers=expected_headers) + + assert result == sample_spaces_response + + +class TestConfluenceCloudV2SearchOperations: + """Test cases for search operations using v2 API.""" + + @patch.object(ConfluenceCloudV2, "get") + def test_search_pages(self, mock_get, confluence_v2, sample_pages_response): + """Test search_pages method with CQL.""" + mock_get.return_value = sample_pages_response + + cql_query = "type=page AND space=TEST" + result = confluence_v2.search_pages(cql_query, limit=50) + + expected_endpoint = confluence_v2._get_v2_endpoint("pages") + expected_params = {"cql": cql_query, "limit": 50, "body-format": "atlas_doc_format"} + expected_headers = confluence_v2.v2_headers + mock_get.assert_called_once_with(expected_endpoint, params=expected_params, headers=expected_headers) + + assert result == sample_pages_response + + @patch.object(ConfluenceCloudV2, "get") + def test_search_pages_with_cursor(self, mock_get, confluence_v2, sample_pages_response): + """Test search_pages method with cursor pagination.""" + mock_get.return_value = sample_pages_response + + cql_query = "type=page" + result = confluence_v2.search_pages(cql_query, limit=25, cursor="search_cursor") + + expected_endpoint = confluence_v2._get_v2_endpoint("pages") + expected_params = {"cql": cql_query, "limit": 25, "body-format": "atlas_doc_format", "cursor": "search_cursor"} + expected_headers = confluence_v2.v2_headers + mock_get.assert_called_once_with(expected_endpoint, params=expected_params, headers=expected_headers) + + assert result == sample_pages_response + + +class TestConfluenceCloudV2UtilityMethods: + """Test cases for utility methods.""" + + def test_get_v2_endpoint(self, confluence_v2): + """Test _get_v2_endpoint method.""" + endpoint = confluence_v2._get_v2_endpoint("pages") + assert "wiki/api/v2" in endpoint + assert endpoint.endswith("pages") + + def test_get_v2_endpoint_with_id(self, confluence_v2): + """Test _get_v2_endpoint method with resource ID.""" + endpoint = confluence_v2._get_v2_endpoint("pages/123456") + assert "wiki/api/v2" in endpoint + assert endpoint.endswith("pages/123456") + + @patch.object(ConfluenceCloudV2, "get") + def test_v2_request_get(self, mock_get, confluence_v2): + """Test _v2_request method with GET.""" + mock_get.return_value = {"test": "data"} + + result = confluence_v2._v2_request("GET", "test/endpoint") + + expected_headers = confluence_v2.v2_headers + mock_get.assert_called_once_with("test/endpoint", headers=expected_headers) + assert result == {"test": "data"} + + @patch.object(ConfluenceCloudV2, "post") + def test_v2_request_post(self, mock_post, confluence_v2): + """Test _v2_request method with POST.""" + mock_post.return_value = {"created": "data"} + + result = confluence_v2._v2_request("POST", "test/endpoint", json={"test": "data"}) + + expected_headers = confluence_v2.v2_headers + mock_post.assert_called_once_with("test/endpoint", headers=expected_headers, json={"test": "data"}) + assert result == {"created": "data"} + + def test_v2_request_unsupported_method(self, confluence_v2): + """Test _v2_request method with unsupported HTTP method.""" + with pytest.raises(ValueError, match="Unsupported HTTP method: PATCH"): + confluence_v2._v2_request("PATCH", "test/endpoint") + + +class TestConfluenceCloudV2ErrorHandling: + """Test cases for error handling in v2 API operations.""" + + @patch.object(ConfluenceCloudV2, "get") + def test_get_page_by_id_not_found(self, mock_get, confluence_v2): + """Test get_page_by_id method when page is not found.""" + from atlassian.errors import ApiError + + mock_get.side_effect = ApiError("Page not found", status_code=404) + + with pytest.raises(ApiError): + confluence_v2.get_page_by_id("nonexistent") + + @patch.object(ConfluenceCloudV2, "post") + def test_create_page_permission_denied(self, mock_post, confluence_v2, sample_adf_content): + """Test create_page method when permission is denied.""" + from atlassian.errors import ApiError + + mock_post.side_effect = ApiError("Permission denied", status_code=403) + + with pytest.raises(ApiError): + confluence_v2.create_page( + space_id="RESTRICTED", title="Test Page", content=sample_adf_content, content_format="adf" + ) + + @patch.object(ConfluenceCloudV2, "put") + def test_update_page_version_conflict(self, mock_put, confluence_v2): + """Test update_page method when version conflict occurs.""" + from atlassian.errors import ApiError + + mock_put.side_effect = ApiError("Version conflict", status_code=409) + + with pytest.raises(ApiError): + confluence_v2.update_page(page_id="123456", title="Updated Title", version=1) # Outdated version + + +class TestConfluenceCloudV2Integration: + """ + Integration tests for Confluence Cloud v2 API. + + These tests require real Confluence Cloud credentials and are marked + as integration tests. They can be skipped in CI/CD environments. + + To run integration tests: + pytest -m integration tests/confluence/test_confluence_cloud_v2.py + + To skip integration tests: + pytest -m "not integration" tests/confluence/test_confluence_cloud_v2.py + """ + + @pytest.fixture + def confluence_integration(self): + """ + Fixture for real Confluence Cloud integration testing. + + Requires environment variables: + - CONFLUENCE_URL: Confluence Cloud URL + - CONFLUENCE_TOKEN: API token + - CONFLUENCE_SPACE_ID: Test space ID + """ + import os + + url = os.getenv("CONFLUENCE_URL") + token = os.getenv("CONFLUENCE_TOKEN") + space_id = os.getenv("CONFLUENCE_SPACE_ID") + + if not all([url, token, space_id]): + pytest.skip("Integration test credentials not configured") + + return {"client": ConfluenceCloudV2(url=url, token=token), "space_id": space_id} + + @pytest.mark.integration + def test_integration_get_spaces(self, confluence_integration): + """Integration test for get_spaces method.""" + client = confluence_integration["client"] + + result = client.get_spaces(limit=10) + + assert "results" in result + assert isinstance(result["results"], list) + if result["results"]: + space = result["results"][0] + assert "id" in space + assert "key" in space + assert "name" in space + + @pytest.mark.integration + def test_integration_get_pages(self, confluence_integration): + """Integration test for get_pages method.""" + client = confluence_integration["client"] + space_id = confluence_integration["space_id"] + + result = client.get_pages(space_id=space_id, limit=5) + + assert "results" in result + assert isinstance(result["results"], list) + + @pytest.mark.integration + def test_integration_search_pages(self, confluence_integration): + """Integration test for search_pages method.""" + client = confluence_integration["client"] + space_id = confluence_integration["space_id"] + + cql_query = f"type=page AND space={space_id}" + result = client.search_pages(cql_query, limit=5) + + assert "results" in result + assert isinstance(result["results"], list) + + @pytest.mark.integration + def test_integration_create_update_delete_page(self, confluence_integration): + """Integration test for complete page lifecycle.""" + client = confluence_integration["client"] + space_id = confluence_integration["space_id"] + + # Create test page with ADF content + adf_content = { + "version": 1, + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "This is a test page created by the v2 API test suite."}], + } + ], + } + + # Create page + created_page = client.create_page( + space_id=space_id, title="V2 API Test Page", content=adf_content, content_format="adf" + ) + + assert created_page["title"] == "V2 API Test Page" + assert created_page["spaceId"] == space_id + page_id = created_page["id"] + + try: + # Get the created page + retrieved_page = client.get_page_by_id(page_id, expand=["body", "version"]) + assert retrieved_page["id"] == page_id + assert retrieved_page["title"] == "V2 API Test Page" + + # Update the page + updated_adf = { + "version": 1, + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "This page has been updated by the v2 API test suite."}], + } + ], + } + + updated_page = client.update_page( + page_id=page_id, + title="Updated V2 API Test Page", + content=updated_adf, + content_format="adf", + version=retrieved_page["version"]["number"] + 1, + ) + + assert updated_page["title"] == "Updated V2 API Test Page" + + finally: + # Clean up: delete the test page + client.delete_page(page_id) diff --git a/tests/confluence/test_confluence_dual_api.py b/tests/confluence/test_confluence_dual_api.py new file mode 100644 index 000000000..319265775 --- /dev/null +++ b/tests/confluence/test_confluence_dual_api.py @@ -0,0 +1,486 @@ +# coding=utf-8 +""" +Test cases for Confluence Cloud dual API support. + +This test suite covers the dual API functionality that allows seamless +switching between v1 and v2 APIs while maintaining backward compatibility. +""" + +import pytest +from unittest.mock import patch + +from atlassian.confluence.cloud import Cloud as ConfluenceCloud +from atlassian.confluence.cloud.v2 import ConfluenceCloudV2 + + +@pytest.fixture +def confluence_dual(): + """Fixture for ConfluenceCloud client with dual API support.""" + return ConfluenceCloud(url="https://test.atlassian.net", token="test-token", cloud=True) + + +@pytest.fixture +def confluence_v2_enabled(): + """Fixture for ConfluenceCloud client with v2 API enabled.""" + client = ConfluenceCloud(url="https://test.atlassian.net", token="test-token", cloud=True) + client.enable_v2_api() + return client + + +@pytest.fixture +def confluence_v2_forced(): + """Fixture for ConfluenceCloud client with v2 API forced.""" + client = ConfluenceCloud(url="https://test.atlassian.net", token="test-token", cloud=True) + client.enable_v2_api(force=True) + return client + + +@pytest.fixture +def sample_adf_content(): + """Fixture providing sample ADF content.""" + return { + "version": 1, + "type": "doc", + "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Test ADF content"}]}], + } + + +class TestDualAPIInitialization: + """Test cases for dual API initialization.""" + + def test_init_default_configuration(self): + """Test default initialization without v2 API.""" + confluence = ConfluenceCloud(url="https://test.atlassian.net", token="test-token") + + assert confluence._force_v2_api is False + assert confluence._prefer_v2_api is False + assert confluence._v2_client is None + assert confluence.cloud is True + assert confluence.api_version == "latest" + assert confluence.api_root == "wiki/rest/api" + + def test_init_with_v2_preference(self): + """Test initialization with v2 API preference.""" + confluence = ConfluenceCloud(url="https://test.atlassian.net", token="test-token", prefer_v2_api=True) + + assert confluence._force_v2_api is False + assert confluence._prefer_v2_api is True + # v2 client should be initialized when preferred + assert confluence._v2_client is not None + + def test_init_with_v2_forced(self): + """Test initialization with v2 API forced.""" + confluence = ConfluenceCloud(url="https://test.atlassian.net", token="test-token", force_v2_api=True) + + assert confluence._force_v2_api is True + # force_v2_api doesn't automatically set prefer_v2_api + assert confluence._prefer_v2_api is False + assert confluence._v2_client is not None + + def test_backward_compatibility_validation(self): + """Test that backward compatibility validation passes.""" + confluence = ConfluenceCloud(url="https://test.atlassian.net", token="test-token") + + # Should not raise any exceptions during initialization + # All required methods should be present + required_methods = [ + "get_content", + "create_content", + "update_content", + "delete_content", + "get_spaces", + "create_space", + "search_content", + ] + + for method_name in required_methods: + assert hasattr(confluence, method_name), f"Missing method: {method_name}" + + +class TestDualAPIConfiguration: + """Test cases for dual API configuration methods.""" + + def test_enable_v2_api_prefer(self, confluence_dual): + """Test enabling v2 API with preference.""" + assert confluence_dual._prefer_v2_api is False + assert confluence_dual._force_v2_api is False + + confluence_dual.enable_v2_api() + + assert confluence_dual._prefer_v2_api is True + assert confluence_dual._force_v2_api is False + assert confluence_dual._v2_client is not None + + def test_enable_v2_api_force(self, confluence_dual): + """Test enabling v2 API with force.""" + confluence_dual.enable_v2_api(force=True) + + assert confluence_dual._prefer_v2_api is True + assert confluence_dual._force_v2_api is True + assert confluence_dual._v2_client is not None + + def test_disable_v2_api(self, confluence_v2_enabled): + """Test disabling v2 API.""" + assert confluence_v2_enabled._prefer_v2_api is True + + confluence_v2_enabled.disable_v2_api() + + assert confluence_v2_enabled._prefer_v2_api is False + assert confluence_v2_enabled._force_v2_api is False + + def test_get_api_version_info_default(self, confluence_dual): + """Test getting API version info with default configuration.""" + info = confluence_dual.get_api_version_info() + + assert info["v1_available"] is True + assert info["v2_available"] is False # Not initialized by default + assert info["force_v2_api"] is False + assert info["prefer_v2_api"] is False + assert info["current_default"] == "v1" + + def test_get_api_version_info_v2_enabled(self, confluence_v2_enabled): + """Test getting API version info with v2 enabled.""" + info = confluence_v2_enabled.get_api_version_info() + + assert info["v1_available"] is True + assert info["v2_available"] is True + assert info["force_v2_api"] is False + assert info["prefer_v2_api"] is True + assert info["current_default"] == "v2" + + def test_get_api_version_info_v2_forced(self, confluence_v2_forced): + """Test getting API version info with v2 forced.""" + info = confluence_v2_forced.get_api_version_info() + + assert info["v1_available"] is True + assert info["v2_available"] is True + assert info["force_v2_api"] is True + assert info["prefer_v2_api"] is True + assert info["current_default"] == "v2" + + +class TestDualAPIRouting: + """Test cases for API routing logic.""" + + def test_should_use_v2_api_default(self, confluence_dual): + """Test v2 API usage decision with default configuration.""" + assert confluence_dual._should_use_v2_api() is False + assert confluence_dual._should_use_v2_api("get_content") is False + + def test_should_use_v2_api_preferred(self, confluence_v2_enabled): + """Test v2 API usage decision with v2 preferred.""" + assert confluence_v2_enabled._should_use_v2_api() is True + assert confluence_v2_enabled._should_use_v2_api("get_content") is True + + def test_should_use_v2_api_forced(self, confluence_v2_forced): + """Test v2 API usage decision with v2 forced.""" + assert confluence_v2_forced._should_use_v2_api() is True + assert confluence_v2_forced._should_use_v2_api("get_content") is True + + @patch.object(ConfluenceCloudV2, "get_page_by_id") + def test_route_to_v2_if_needed_success(self, mock_v2_method, confluence_v2_enabled): + """Test successful routing to v2 API.""" + mock_v2_method.return_value = {"id": "123", "title": "Test Page"} + + result = confluence_v2_enabled._route_to_v2_if_needed("get_page_by_id", "123") + + assert result == {"id": "123", "title": "Test Page"} + mock_v2_method.assert_called_once_with("123") + + def test_route_to_v2_if_needed_no_routing(self, confluence_dual): + """Test no routing when v2 API is not enabled.""" + result = confluence_dual._route_to_v2_if_needed("get_page_by_id", "123") + + assert result is None + + @patch.object(ConfluenceCloudV2, "__init__", side_effect=Exception("Init failed")) + def test_route_to_v2_if_needed_init_failure(self, mock_init, confluence_dual): + """Test routing when v2 client initialization fails.""" + confluence_dual.enable_v2_api() + + result = confluence_dual._route_to_v2_if_needed("get_page_by_id", "123") + + assert result is None + + +class TestDualAPIContentOperations: + """Test cases for content operations with dual API support.""" + + @patch.object(ConfluenceCloud, "get") + def test_get_content_v1_fallback(self, mock_get, confluence_dual): + """Test get_content falls back to v1 API by default.""" + mock_get.return_value = {"id": "123", "title": "Test Page"} + + result = confluence_dual.get_content("123") + + mock_get.assert_called_once_with("content/123") + assert result == {"id": "123", "title": "Test Page"} + + @patch.object(ConfluenceCloudV2, "get_page_by_id") + def test_get_content_v2_routing(self, mock_v2_method, confluence_v2_enabled): + """Test get_content routes to v2 API when enabled.""" + mock_v2_method.return_value = {"id": "123", "title": "Test Page"} + + result = confluence_v2_enabled.get_content("123", expand=["body"]) + + mock_v2_method.assert_called_once_with("123", expand=["body"]) + assert result == {"id": "123", "title": "Test Page"} + + @patch.object(ConfluenceCloud, "get") + @patch.object(ConfluenceCloudV2, "get_pages") + def test_get_content_by_type_v2_routing_pages(self, mock_v2_method, mock_v1_get, confluence_v2_enabled): + """Test get_content_by_type routes to v2 API for pages.""" + mock_v2_method.return_value = {"results": [{"id": "123", "title": "Test Page"}]} + + result = confluence_v2_enabled.get_content_by_type("page", space="TEST") + + mock_v2_method.assert_called_once_with(space="TEST") + mock_v1_get.assert_not_called() + assert result == {"results": [{"id": "123", "title": "Test Page"}]} + + @patch.object(ConfluenceCloud, "get") + def test_get_content_by_type_v1_fallback_blogpost(self, mock_get, confluence_v2_enabled): + """Test get_content_by_type falls back to v1 API for non-page types.""" + mock_get.return_value = {"results": [{"id": "456", "title": "Test Blog"}]} + + result = confluence_v2_enabled.get_content_by_type("blogpost", space="TEST") + + mock_get.assert_called_once_with("content", params={"type": "blogpost", "space": "TEST"}) + assert result == {"results": [{"id": "456", "title": "Test Blog"}]} + + @patch.object(ConfluenceCloud, "post") + @patch.object(ConfluenceCloudV2, "create_page") + def test_create_content_v2_routing_page(self, mock_v2_method, mock_v1_post, confluence_v2_enabled): + """Test create_content routes to v2 API for page creation.""" + mock_v2_method.return_value = {"id": "123", "title": "New Page"} + + page_data = { + "type": "page", + "title": "New Page", + "space": {"id": "SPACE123"}, + "body": {"storage": {"value": "Content
"}}, + } + + result = confluence_v2_enabled.create_content(page_data) + + mock_v2_method.assert_called_once_with("SPACE123", "New Page", "Content
", None) + mock_v1_post.assert_not_called() + assert result == {"id": "123", "title": "New Page"} + + @patch.object(ConfluenceCloud, "post") + def test_create_content_v1_fallback_non_page(self, mock_post, confluence_v2_enabled): + """Test create_content falls back to v1 API for non-page content.""" + mock_post.return_value = {"id": "456", "title": "New Blog"} + + blog_data = {"type": "blogpost", "title": "New Blog", "space": {"key": "TEST"}} + + result = confluence_v2_enabled.create_content(blog_data) + + mock_post.assert_called_once_with("content", data=blog_data) + assert result == {"id": "456", "title": "New Blog"} + + +class TestDualAPISpaceOperations: + """Test cases for space operations with dual API support.""" + + @patch.object(ConfluenceCloud, "get") + def test_get_spaces_v1_fallback(self, mock_get, confluence_dual): + """Test get_spaces falls back to v1 API by default.""" + mock_get.return_value = {"results": [{"key": "TEST", "name": "Test Space"}]} + + result = confluence_dual.get_spaces() + + mock_get.assert_called_once_with("wiki/rest/api/latest/space") + assert result == {"results": [{"key": "TEST", "name": "Test Space"}]} + + @patch.object(ConfluenceCloudV2, "get_spaces") + def test_get_spaces_v2_routing(self, mock_v2_method, confluence_v2_enabled): + """Test get_spaces routes to v2 API when enabled.""" + mock_v2_method.return_value = {"results": [{"id": "SPACE123", "key": "TEST"}]} + + result = confluence_v2_enabled.get_spaces(limit=50) + + mock_v2_method.assert_called_once_with(limit=50) + assert result == {"results": [{"id": "SPACE123", "key": "TEST"}]} + + +class TestDualAPISearchOperations: + """Test cases for search operations with dual API support.""" + + @patch.object(ConfluenceCloud, "get") + def test_search_content_v1_fallback(self, mock_get, confluence_dual): + """Test search_content falls back to v1 API by default.""" + mock_get.return_value = {"results": [{"id": "123", "title": "Search Result"}]} + + result = confluence_dual.search_content("type=page") + + mock_get.assert_called_once_with("content/search", params={"cql": "type=page"}) + assert result == {"results": [{"id": "123", "title": "Search Result"}]} + + @patch.object(ConfluenceCloudV2, "search_pages") + def test_search_content_v2_routing(self, mock_v2_method, confluence_v2_enabled): + """Test search_content routes to v2 API when enabled.""" + mock_v2_method.return_value = {"results": [{"id": "123", "title": "Search Result"}]} + + result = confluence_v2_enabled.search_content("type=page", limit=50) + + mock_v2_method.assert_called_once_with("type=page", limit=50) + assert result == {"results": [{"id": "123", "title": "Search Result"}]} + + @patch("warnings.warn") + def test_search_content_migration_warning(self, mock_warn, confluence_dual): + """Test search_content issues migration warning for large result sets.""" + with patch.object(confluence_dual, "get") as mock_get: + mock_get.return_value = {"results": []} + + # Should trigger warning for large limit + confluence_dual.search_content("type=page", limit=100) + + mock_warn.assert_called_once() + warning_message = mock_warn.call_args[0][0] + assert "search_pages_with_cursor" in warning_message + assert "cursor-based pagination" in warning_message + + +class TestDualAPIConvenienceMethods: + """Test cases for v2 API convenience methods.""" + + @patch.object(ConfluenceCloudV2, "create_page") + def test_create_page_with_adf(self, mock_create, confluence_dual, sample_adf_content): + """Test create_page_with_adf convenience method.""" + mock_create.return_value = {"id": "123", "title": "ADF Page"} + + result = confluence_dual.create_page_with_adf("SPACE123", "ADF Page", sample_adf_content, "parent123") + + mock_create.assert_called_once_with("SPACE123", "ADF Page", sample_adf_content, "parent123", "adf") + assert result == {"id": "123", "title": "ADF Page"} + + def test_create_page_with_adf_no_v2_client(self, confluence_dual): + """Test create_page_with_adf raises error when v2 client unavailable.""" + # Mock v2 client initialization to fail + with patch.object(confluence_dual, "_init_v2_client") as mock_init: + mock_init.return_value = None + confluence_dual._v2_client = None + + with pytest.raises(RuntimeError, match="v2 API client not available"): + confluence_dual.create_page_with_adf("SPACE123", "Title", {}) + + @patch.object(ConfluenceCloudV2, "update_page") + def test_update_page_with_adf(self, mock_update, confluence_dual, sample_adf_content): + """Test update_page_with_adf convenience method.""" + mock_update.return_value = {"id": "123", "title": "Updated ADF Page"} + + result = confluence_dual.update_page_with_adf("123", "Updated ADF Page", sample_adf_content, version=2) + + mock_update.assert_called_once_with("123", "Updated ADF Page", sample_adf_content, "adf", 2) + assert result == {"id": "123", "title": "Updated ADF Page"} + + @patch.object(ConfluenceCloudV2, "get_page_by_id") + def test_get_page_with_adf(self, mock_get, confluence_dual): + """Test get_page_with_adf convenience method.""" + mock_get.return_value = {"id": "123", "title": "ADF Page", "body": {"value": {}}} + + result = confluence_dual.get_page_with_adf("123", expand=["body", "version"]) + + mock_get.assert_called_once_with("123", ["body", "version"]) + assert result == {"id": "123", "title": "ADF Page", "body": {"value": {}}} + + @patch.object(ConfluenceCloudV2, "search_pages") + def test_search_pages_with_cursor(self, mock_search, confluence_dual): + """Test search_pages_with_cursor convenience method.""" + mock_search.return_value = {"results": [{"id": "123"}], "_links": {"next": {"cursor": "next_cursor"}}} + + result = confluence_dual.search_pages_with_cursor("type=page AND space=TEST", limit=50, cursor="test_cursor") + + mock_search.assert_called_once_with("type=page AND space=TEST", 50, "test_cursor") + assert result["results"] == [{"id": "123"}] + assert result["_links"]["next"]["cursor"] == "next_cursor" + + +class TestDualAPIErrorHandling: + """Test cases for error handling in dual API operations.""" + + @patch.object(ConfluenceCloudV2, "get_page_by_id", side_effect=Exception("v2 API error")) + @patch.object(ConfluenceCloud, "get") + def test_v2_error_fallback_to_v1(self, mock_v1_get, mock_v2_get, confluence_v2_enabled): + """Test that v2 API errors are properly raised (no automatic fallback).""" + mock_v1_get.return_value = {"id": "123", "title": "Fallback Result"} + + # The current implementation raises v2 API errors rather than falling back + # This documents the current behavior - fallback logic could be added later + + with pytest.raises(Exception, match="v2 API error"): + confluence_v2_enabled.get_content("123") + + def test_v2_client_initialization_failure(self, confluence_dual): + """Test handling of v2 client initialization failure.""" + with patch.object(ConfluenceCloudV2, "__init__", side_effect=Exception("Init failed")): + confluence_dual.enable_v2_api() + + # Should handle initialization failure gracefully + assert confluence_dual._v2_client is None + assert confluence_dual._prefer_v2_api is True # Preference should still be set + + +class TestDualAPIBackwardCompatibility: + """Test cases for backward compatibility with dual API support.""" + + def test_all_v1_methods_present(self, confluence_dual): + """Test that all v1 API methods are still present and callable.""" + v1_methods = [ + "get_content", + "get_content_by_type", + "create_content", + "update_content", + "delete_content", + "get_content_children", + "get_content_descendants", + "get_content_ancestors", + "get_spaces", + "get_space", + "create_space", + "update_space", + "delete_space", + "get_users", + "get_user", + "get_current_user", + "get_groups", + "get_group", + "get_group_members", + "search_content", + "search_spaces", + ] + + for method_name in v1_methods: + assert hasattr(confluence_dual, method_name), f"Missing method: {method_name}" + method = getattr(confluence_dual, method_name) + assert callable(method), f"Method not callable: {method_name}" + + def test_method_signatures_unchanged(self, confluence_dual): + """Test that method signatures remain unchanged for backward compatibility.""" + import inspect + + # Test a few key methods to ensure signatures are preserved + get_content_sig = inspect.signature(confluence_dual.get_content) + assert "content_id" in get_content_sig.parameters + + create_content_sig = inspect.signature(confluence_dual.create_content) + assert "data" in create_content_sig.parameters + + search_content_sig = inspect.signature(confluence_dual.search_content) + assert "query" in search_content_sig.parameters + + @patch.object(ConfluenceCloud, "get") + def test_existing_code_compatibility(self, mock_get, confluence_dual): + """Test that existing code patterns continue to work.""" + mock_get.return_value = {"id": "123", "title": "Test Page"} + + # Simulate existing code patterns + page = confluence_dual.get_content("123", expand="body,version") + assert page["id"] == "123" + + pages = confluence_dual.get_content_by_type("page", space="TEST", limit=10) # noqa: F841 + # Should work without modification + + search_results = confluence_dual.search_content("type=page AND space=TEST") # noqa: F841 + # Should work without modification diff --git a/tests/test_adf.py b/tests/test_adf.py new file mode 100644 index 000000000..0bb4ee41e --- /dev/null +++ b/tests/test_adf.py @@ -0,0 +1,488 @@ +# coding=utf-8 +""" +Test cases for ADF (Atlassian Document Format) utilities. + +This test suite covers ADF document creation, validation, and conversion utilities +used by the Confluence Cloud v2 API implementation. +""" + +import pytest +from atlassian.adf import ( + ADFNode, + ADFDocument, + ADFParagraph, + ADFText, + ADFHeading, + create_simple_adf_document, + validate_adf_document, + convert_text_to_adf, + convert_storage_to_adf, + convert_adf_to_storage, + validate_content_format, +) + + +class TestADFNode: + """Test cases for ADFNode base class.""" + + def test_create_basic_node(self): + """Test creating a basic ADF node.""" + node = ADFNode("paragraph") + assert node.type == "paragraph" + assert node.attrs == {} + assert node.content == [] + assert node.text is None + assert node.marks == [] + + def test_create_node_with_attributes(self): + """Test creating an ADF node with attributes.""" + attrs = {"level": 1} + node = ADFNode("heading", attrs=attrs) + assert node.type == "heading" + assert node.attrs == attrs + + def test_create_node_with_content(self): + """Test creating an ADF node with content.""" + content = [{"type": "text", "text": "Hello"}] + node = ADFNode("paragraph", content=content) + assert node.type == "paragraph" + assert node.content == content + + def test_create_text_node(self): + """Test creating a text node.""" + node = ADFNode("text", text="Hello, World!") + assert node.type == "text" + assert node.text == "Hello, World!" + + def test_node_to_dict_basic(self): + """Test converting basic node to dictionary.""" + node = ADFNode("paragraph") + result = node.to_dict() + expected = {"type": "paragraph"} + assert result == expected + + def test_node_to_dict_with_attributes(self): + """Test converting node with attributes to dictionary.""" + node = ADFNode("heading", attrs={"level": 2}) + result = node.to_dict() + expected = {"type": "heading", "attrs": {"level": 2}} + assert result == expected + + def test_node_to_dict_with_text(self): + """Test converting text node to dictionary.""" + node = ADFNode("text", text="Hello, World!") + result = node.to_dict() + expected = {"type": "text", "text": "Hello, World!"} + assert result == expected + + def test_node_to_dict_with_marks(self): + """Test converting node with marks to dictionary.""" + marks = [{"type": "strong"}] + node = ADFNode("text", text="Bold text", marks=marks) + result = node.to_dict() + expected = {"type": "text", "text": "Bold text", "marks": marks} + assert result == expected + + def test_node_to_dict_with_nested_content(self): + """Test converting node with nested content to dictionary.""" + text_node = ADFNode("text", text="Hello") + paragraph = ADFNode("paragraph", content=[text_node]) + result = paragraph.to_dict() + expected = {"type": "paragraph", "content": [{"type": "text", "text": "Hello"}]} + assert result == expected + + +class TestADFDocument: + """Test cases for ADFDocument class.""" + + def test_create_empty_document(self): + """Test creating an empty ADF document.""" + doc = ADFDocument() + assert doc.version == 1 + assert doc.type == "doc" + assert doc.content == [] + + def test_create_document_with_content(self): + """Test creating an ADF document with initial content.""" + text_node = ADFText("Hello, World!") + paragraph = ADFParagraph([text_node]) + doc = ADFDocument([paragraph]) + assert len(doc.content) == 1 + assert doc.content[0] == paragraph + + def test_add_content_to_document(self): + """Test adding content to an ADF document.""" + doc = ADFDocument() + text_node = ADFText("Hello, World!") + paragraph = ADFParagraph([text_node]) + doc.add_content(paragraph) + assert len(doc.content) == 1 + assert doc.content[0] == paragraph + + def test_document_to_dict(self): + """Test converting ADF document to dictionary.""" + text_node = ADFText("Hello, World!") + paragraph = ADFParagraph([text_node]) + doc = ADFDocument([paragraph]) + result = doc.to_dict() + expected = { + "version": 1, + "type": "doc", + "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello, World!"}]}], + } + assert result == expected + + +class TestADFParagraph: + """Test cases for ADFParagraph class.""" + + def test_create_empty_paragraph(self): + """Test creating an empty paragraph.""" + paragraph = ADFParagraph() + assert paragraph.type == "paragraph" + assert paragraph.content == [] + + def test_create_paragraph_with_text(self): + """Test creating a paragraph with text content.""" + text_node = ADFText("Hello, World!") + paragraph = ADFParagraph([text_node]) + assert paragraph.type == "paragraph" + assert len(paragraph.content) == 1 + assert paragraph.content[0] == text_node + + def test_paragraph_to_dict(self): + """Test converting paragraph to dictionary.""" + text_node = ADFText("Hello, World!") + paragraph = ADFParagraph([text_node]) + result = paragraph.to_dict() + expected = {"type": "paragraph", "content": [{"type": "text", "text": "Hello, World!"}]} + assert result == expected + + +class TestADFText: + """Test cases for ADFText class.""" + + def test_create_simple_text(self): + """Test creating simple text node.""" + text_node = ADFText("Hello, World!") + assert text_node.type == "text" + assert text_node.text == "Hello, World!" + assert text_node.marks == [] + + def test_create_text_with_marks(self): + """Test creating text node with formatting marks.""" + marks = [{"type": "strong"}, {"type": "em"}] + text_node = ADFText("Bold and italic", marks) + assert text_node.type == "text" + assert text_node.text == "Bold and italic" + assert text_node.marks == marks + + def test_text_to_dict(self): + """Test converting text node to dictionary.""" + text_node = ADFText("Hello, World!") + result = text_node.to_dict() + expected = {"type": "text", "text": "Hello, World!"} + assert result == expected + + def test_text_with_marks_to_dict(self): + """Test converting text node with marks to dictionary.""" + marks = [{"type": "strong"}] + text_node = ADFText("Bold text", marks) + result = text_node.to_dict() + expected = {"type": "text", "text": "Bold text", "marks": marks} + assert result == expected + + +class TestADFHeading: + """Test cases for ADFHeading class.""" + + def test_create_heading_level_1(self): + """Test creating a level 1 heading.""" + text_node = ADFText("Main Title") + heading = ADFHeading(1, [text_node]) + assert heading.type == "heading" + assert heading.attrs == {"level": 1} + assert len(heading.content) == 1 + + def test_create_heading_level_6(self): + """Test creating a level 6 heading.""" + text_node = ADFText("Subtitle") + heading = ADFHeading(6, [text_node]) + assert heading.type == "heading" + assert heading.attrs == {"level": 6} + + def test_create_heading_invalid_level_low(self): + """Test creating heading with invalid level (too low).""" + with pytest.raises(ValueError, match="Heading level must be between 1 and 6"): + ADFHeading(0, []) + + def test_create_heading_invalid_level_high(self): + """Test creating heading with invalid level (too high).""" + with pytest.raises(ValueError, match="Heading level must be between 1 and 6"): + ADFHeading(7, []) + + def test_heading_to_dict(self): + """Test converting heading to dictionary.""" + text_node = ADFText("Chapter Title") + heading = ADFHeading(2, [text_node]) + result = heading.to_dict() + expected = {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Chapter Title"}]} + assert result == expected + + +class TestADFUtilityFunctions: + """Test cases for ADF utility functions.""" + + def test_create_simple_adf_document(self): + """Test creating a simple ADF document from text.""" + text = "Hello, World!" + doc = create_simple_adf_document(text) + + assert isinstance(doc, ADFDocument) + assert doc.version == 1 + assert doc.type == "doc" + assert len(doc.content) == 1 + + paragraph = doc.content[0] + assert isinstance(paragraph, ADFParagraph) + assert len(paragraph.content) == 1 + + text_node = paragraph.content[0] + assert isinstance(text_node, ADFText) + assert text_node.text == text + + def test_validate_adf_document_valid(self): + """Test validating a valid ADF document.""" + valid_adf = { + "version": 1, + "type": "doc", + "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello, World!"}]}], + } + assert validate_adf_document(valid_adf) is True + + def test_validate_adf_document_invalid_type(self): + """Test validating ADF document with invalid type.""" + invalid_adf = {"version": 1, "type": "invalid", "content": []} + assert validate_adf_document(invalid_adf) is False + + def test_validate_adf_document_invalid_version(self): + """Test validating ADF document with invalid version.""" + invalid_adf = {"version": 2, "type": "doc", "content": []} + assert validate_adf_document(invalid_adf) is False + + def test_validate_adf_document_missing_content(self): + """Test validating ADF document missing content field.""" + invalid_adf = {"version": 1, "type": "doc"} + assert validate_adf_document(invalid_adf) is False + + def test_validate_adf_document_invalid_content_type(self): + """Test validating ADF document with invalid content type.""" + invalid_adf = {"version": 1, "type": "doc", "content": "not a list"} + assert validate_adf_document(invalid_adf) is False + + def test_validate_adf_document_not_dict(self): + """Test validating non-dictionary input.""" + assert validate_adf_document("not a dict") is False + assert validate_adf_document(None) is False + assert validate_adf_document([]) is False + + def test_convert_text_to_adf(self): + """Test converting plain text to ADF format.""" + text = "Hello, World!" + result = convert_text_to_adf(text) + + assert validate_adf_document(result) + assert result["version"] == 1 + assert result["type"] == "doc" + assert len(result["content"]) == 1 + + paragraph = result["content"][0] + assert paragraph["type"] == "paragraph" + assert len(paragraph["content"]) == 1 + + text_node = paragraph["content"][0] + assert text_node["type"] == "text" + assert text_node["text"] == text + + def test_convert_empty_text_to_adf(self): + """Test converting empty text to ADF format.""" + result = convert_text_to_adf("") + + assert validate_adf_document(result) + # Should still create a valid document structure + assert result["version"] == 1 + assert result["type"] == "doc" + + def test_convert_storage_to_adf_simple(self): + """Test converting simple storage format to ADF.""" + storage_content = "Hello, World!
" + result = convert_storage_to_adf(storage_content) + + assert validate_adf_document(result) + assert result["version"] == 1 + assert result["type"] == "doc" + + def test_convert_storage_to_adf_with_tags(self): + """Test converting storage format with HTML tags to ADF.""" + storage_content = "Hello, World!
" + result = convert_storage_to_adf(storage_content) + + assert validate_adf_document(result) + # Basic conversion should strip HTML tags + assert result["version"] == 1 + assert result["type"] == "doc" + + def test_convert_storage_to_adf_empty(self): + """Test converting empty storage format to ADF.""" + storage_content = "" + result = convert_storage_to_adf(storage_content) + + assert validate_adf_document(result) + assert result["version"] == 1 + assert result["type"] == "doc" + + def test_convert_adf_to_storage_simple(self): + """Test converting simple ADF to storage format.""" + adf_content = { + "version": 1, + "type": "doc", + "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello, World!"}]}], + } + result = convert_adf_to_storage(adf_content) + + assert isinstance(result, str) + assert "Hello, World!" in result + assert result.startswith("") + assert result.endswith("
") + + def test_convert_adf_to_storage_multiple_paragraphs(self): + """Test converting ADF with multiple paragraphs to storage format.""" + adf_content = { + "version": 1, + "type": "doc", + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "First paragraph"}]}, + {"type": "paragraph", "content": [{"type": "text", "text": "Second paragraph"}]}, + ], + } + result = convert_adf_to_storage(adf_content) + + assert isinstance(result, str) + assert "First paragraph" in result + assert "Second paragraph" in result + + def test_convert_adf_to_storage_invalid_adf(self): + """Test converting invalid ADF to storage format raises error.""" + invalid_adf = {"type": "invalid", "content": []} + + with pytest.raises(ValueError, match="Invalid ADF document structure"): + convert_adf_to_storage(invalid_adf) + + def test_convert_adf_to_storage_empty_content(self): + """Test converting ADF with empty content to storage format.""" + adf_content = {"version": 1, "type": "doc", "content": []} + result = convert_adf_to_storage(adf_content) + + assert isinstance(result, str) + assert "Empty content" in result + + def test_validate_content_format_adf(self): + """Test validating ADF content format.""" + adf_content = {"version": 1, "type": "doc", "content": []} + assert validate_content_format(adf_content, "adf") is True + assert validate_content_format(adf_content, "storage") is False + assert validate_content_format(adf_content, "wiki") is False + + def test_validate_content_format_storage(self): + """Test validating storage content format.""" + storage_content = "Hello, World!
" + assert validate_content_format(storage_content, "storage") is True + assert validate_content_format(storage_content, "adf") is False + assert validate_content_format(storage_content, "wiki") is True # Also valid as wiki + + def test_validate_content_format_wiki(self): + """Test validating wiki content format.""" + wiki_content = "h1. Hello, World!" + assert validate_content_format(wiki_content, "wiki") is True + assert validate_content_format(wiki_content, "adf") is False + assert validate_content_format(wiki_content, "storage") is False + + def test_validate_content_format_invalid(self): + """Test validating content with invalid format.""" + content = "Hello, World!" + assert validate_content_format(content, "invalid") is False + + def test_validate_content_format_wrong_type(self): + """Test validating content with wrong type for format.""" + # String content for ADF format should fail + assert validate_content_format("Hello", "adf") is False + + # Dict content for storage format should fail + assert validate_content_format({"test": "data"}, "storage") is False + + +class TestADFComplexScenarios: + """Test cases for complex ADF scenarios.""" + + def test_complex_adf_document(self): + """Test creating and validating a complex ADF document.""" + # Create a document with heading, paragraph, and formatted text + heading_text = ADFText("Chapter 1: Introduction") + heading = ADFHeading(1, [heading_text]) + + plain_text = ADFText("This is a regular paragraph with ") + bold_text = ADFText("bold text", [{"type": "strong"}]) + more_text = ADFText(" and some more content.") + paragraph = ADFParagraph([plain_text, bold_text, more_text]) + + doc = ADFDocument([heading, paragraph]) + result = doc.to_dict() + + # Validate the structure + assert validate_adf_document(result) + assert len(result["content"]) == 2 + + # Check heading + heading_dict = result["content"][0] + assert heading_dict["type"] == "heading" + assert heading_dict["attrs"]["level"] == 1 + + # Check paragraph + paragraph_dict = result["content"][1] + assert paragraph_dict["type"] == "paragraph" + assert len(paragraph_dict["content"]) == 3 + + # Check formatted text + bold_text_dict = paragraph_dict["content"][1] + assert bold_text_dict["type"] == "text" + assert bold_text_dict["text"] == "bold text" + assert bold_text_dict["marks"] == [{"type": "strong"}] + + def test_nested_adf_conversion(self): + """Test converting nested ADF structures.""" + # Create nested structure and convert back and forth + original_text = "Hello, World!" + adf_doc = convert_text_to_adf(original_text) + storage_content = convert_adf_to_storage(adf_doc) + + # Should contain the original text + assert original_text in storage_content + assert storage_content.startswith("") + assert storage_content.endswith("
") + + def test_adf_roundtrip_conversion(self): + """Test roundtrip conversion between formats.""" + # Start with storage format + original_storage = "Test content for roundtrip
" + + # Convert to ADF + adf_content = convert_storage_to_adf(original_storage) + assert validate_adf_document(adf_content) + + # Convert back to storage + final_storage = convert_adf_to_storage(adf_content) + + # Should contain the essential content + assert "Test content for roundtrip" in final_storage + assert final_storage.startswith("") + assert final_storage.endswith("
") diff --git a/tests/test_jira.py b/tests/test_jira.py index 1edeb0de3..64f7a4c73 100644 --- a/tests/test_jira.py +++ b/tests/test_jira.py @@ -1,5 +1,6 @@ # coding: utf8 """Tests for Jira Modules""" + from unittest import TestCase from atlassian import jira from .mockup import mockup_server diff --git a/tests/test_request_utils_v2.py b/tests/test_request_utils_v2.py new file mode 100644 index 000000000..d335229a2 --- /dev/null +++ b/tests/test_request_utils_v2.py @@ -0,0 +1,330 @@ +# coding=utf-8 +""" +Test cases for request utilities used by v2 API implementation. + +This test suite covers content format detection and validation utilities +that support the Confluence Cloud v2 API. +""" + +from atlassian.request_utils import ( + is_adf_content, + validate_adf_structure, + get_content_type_header, + detect_content_format, +) + + +class TestADFContentDetection: + """Test cases for ADF content detection.""" + + def test_is_adf_content_valid_adf(self): + """Test detecting valid ADF content.""" + adf_content = { + "version": 1, + "type": "doc", + "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello, World!"}]}], + } + assert is_adf_content(adf_content) is True + + def test_is_adf_content_invalid_type(self): + """Test detecting content with invalid type field.""" + invalid_content = {"version": 1, "type": "invalid", "content": []} + assert is_adf_content(invalid_content) is False + + def test_is_adf_content_invalid_version(self): + """Test detecting content with invalid version field.""" + invalid_content = {"version": 2, "type": "doc", "content": []} + assert is_adf_content(invalid_content) is False + + def test_is_adf_content_missing_content(self): + """Test detecting content missing content field.""" + invalid_content = {"version": 1, "type": "doc"} + assert is_adf_content(invalid_content) is False + + def test_is_adf_content_string_input(self): + """Test detecting ADF content with string input.""" + assert is_adf_content("Hello, World!") is False + assert is_adf_content("Hello, World!
") is False + + def test_is_adf_content_none_input(self): + """Test detecting ADF content with None input.""" + assert is_adf_content(None) is False + + def test_is_adf_content_list_input(self): + """Test detecting ADF content with list input.""" + assert is_adf_content([]) is False + assert is_adf_content([{"type": "doc"}]) is False + + def test_is_adf_content_empty_dict(self): + """Test detecting ADF content with empty dictionary.""" + assert is_adf_content({}) is False + + +class TestADFStructureValidation: + """Test cases for ADF structure validation.""" + + def test_validate_adf_structure_valid(self): + """Test validating valid ADF structure.""" + valid_adf = { + "version": 1, + "type": "doc", + "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello, World!"}]}], + } + assert validate_adf_structure(valid_adf) is True + + def test_validate_adf_structure_minimal_valid(self): + """Test validating minimal valid ADF structure.""" + minimal_adf = {"version": 1, "type": "doc", "content": []} + assert validate_adf_structure(minimal_adf) is True + + def test_validate_adf_structure_invalid_type_field(self): + """Test validating ADF structure with invalid type field.""" + invalid_adf = {"version": 1, "type": "paragraph", "content": []} # Should be "doc" + assert validate_adf_structure(invalid_adf) is False + + def test_validate_adf_structure_invalid_version_field(self): + """Test validating ADF structure with invalid version field.""" + invalid_adf = {"version": 2, "type": "doc", "content": []} # Should be 1 + assert validate_adf_structure(invalid_adf) is False + + def test_validate_adf_structure_missing_version(self): + """Test validating ADF structure missing version field.""" + invalid_adf = {"type": "doc", "content": []} + assert validate_adf_structure(invalid_adf) is False + + def test_validate_adf_structure_missing_type(self): + """Test validating ADF structure missing type field.""" + invalid_adf = {"version": 1, "content": []} + assert validate_adf_structure(invalid_adf) is False + + def test_validate_adf_structure_missing_content(self): + """Test validating ADF structure missing content field.""" + invalid_adf = {"version": 1, "type": "doc"} + assert validate_adf_structure(invalid_adf) is False + + def test_validate_adf_structure_invalid_content_type(self): + """Test validating ADF structure with invalid content type.""" + invalid_adf = {"version": 1, "type": "doc", "content": "not a list"} + assert validate_adf_structure(invalid_adf) is False + + def test_validate_adf_structure_non_dict_input(self): + """Test validating non-dictionary input.""" + assert validate_adf_structure("not a dict") is False + assert validate_adf_structure(None) is False + assert validate_adf_structure([]) is False + assert validate_adf_structure(123) is False + + +class TestContentTypeHeader: + """Test cases for content type header generation.""" + + def test_get_content_type_header_adf_content(self): + """Test getting content type header for ADF content.""" + adf_content = {"version": 1, "type": "doc", "content": []} + result = get_content_type_header(adf_content) + assert result == "application/json" + + def test_get_content_type_header_string_content(self): + """Test getting content type header for string content.""" + string_content = "Hello, World!" + result = get_content_type_header(string_content) + assert result == "application/json" + + def test_get_content_type_header_storage_content(self): + """Test getting content type header for storage format content.""" + storage_content = "Hello, World!
" + result = get_content_type_header(storage_content) + assert result == "application/json" + + def test_get_content_type_header_dict_content(self): + """Test getting content type header for dictionary content.""" + dict_content = {"key": "value"} + result = get_content_type_header(dict_content) + assert result == "application/json" + + def test_get_content_type_header_none_content(self): + """Test getting content type header for None content.""" + result = get_content_type_header(None) + assert result == "application/json" + + +class TestContentFormatDetection: + """Test cases for content format detection.""" + + def test_detect_content_format_adf(self): + """Test detecting ADF content format.""" + adf_content = { + "version": 1, + "type": "doc", + "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello, World!"}]}], + } + result = detect_content_format(adf_content) + assert result == "adf" + + def test_detect_content_format_storage(self): + """Test detecting storage format content.""" + storage_content = "Hello, World!
" + result = detect_content_format(storage_content) + assert result == "storage" + + def test_detect_content_format_storage_complex(self): + """Test detecting complex storage format content.""" + storage_content = "Hello, World!
Storage format content
", "storage", False), + ("Plain text content", "wiki", False), + ({"custom": "data"}, "unknown", False), + ] + + for content, expected_format, should_be_adf in test_cases: + # Test format detection + detected_format = detect_content_format(content) + assert detected_format == expected_format, f"Failed for content: {content}" + + # Test ADF detection + is_adf = is_adf_content(content) + assert is_adf == should_be_adf, f"ADF detection failed for content: {content}" + + # Test content type header (should always be JSON for our use cases) + content_type = get_content_type_header(content) + assert content_type == "application/json" + + def test_adf_validation_consistency(self): + """Test consistency between ADF detection and validation.""" + test_contents = [ + # Valid ADF + {"version": 1, "type": "doc", "content": []}, + # Invalid ADF - wrong type + {"version": 1, "type": "paragraph", "content": []}, + # Invalid ADF - wrong version + {"version": 2, "type": "doc", "content": []}, + # Invalid ADF - missing content + {"version": 1, "type": "doc"}, + # Non-dict content + "not a dict", + None, + [], + ] + + for content in test_contents: + is_adf = is_adf_content(content) + is_valid_structure = validate_adf_structure(content) if isinstance(content, dict) else False + + # If content is detected as ADF, it should have valid structure + if is_adf: + assert is_valid_structure, f"ADF content should have valid structure: {content}" + + # If structure is valid, it should be detected as ADF + if is_valid_structure: + assert is_adf, f"Valid ADF structure should be detected as ADF: {content}" diff --git a/tests/test_rest_client.py b/tests/test_rest_client.py index e91d0e81c..8df253d8c 100644 --- a/tests/test_rest_client.py +++ b/tests/test_rest_client.py @@ -421,6 +421,7 @@ def fake_sleep(delay): def test_retry_handler_skips_invalid_header(self, monkeypatch): """Ensure invalid Retry-After headers fall back to regular logic.""" + def fake_sleep(_): raise AssertionError("sleep should not be called for invalid header") diff --git a/v2-implementation-analysis.md b/v2-implementation-analysis.md new file mode 100644 index 000000000..3cc867c76 --- /dev/null +++ b/v2-implementation-analysis.md @@ -0,0 +1,214 @@ +# Analysis of Existing v2 Implementation Attempts (PRs #1522, #1523) + +## Executive Summary + +After analyzing the existing Confluence v2 implementation attempts in PRs #1522 and #1523, I've identified key patterns, challenges, and lessons learned that will inform our current implementation approach. + +## PR Analysis + +### PR #1522: "Confluence api v2 and Jira api v3 added" by batzel +**Status**: Appears to be stalled/incomplete + +**Key Findings:** + +- **Scope Creep**: The PR attempted to implement both Confluence v2 AND Jira v3 APIs simultaneously + +- **AI-Generated Content**: The PR description contains AI-generated instructions rather than actual implementation details + +- **Missing Implementation**: No actual code changes were found in the current codebase + +- **Overly Ambitious**: Tried to tackle multiple major API versions at once + +**Lessons Learned:** + +1. **Focus on Single API**: Attempting both Confluence v2 and Jira v3 simultaneously was too ambitious + +2. **Clear Implementation Plan**: The PR lacked a concrete implementation plan with specific deliverables + +3. **Incremental Approach**: Should have started with minimal viable implementation + +### PR #1523: "Confluence v2 implementation" by gonchik (maintainer) +**Status**: Stalled with community questions + +**Key Findings:** + +- **Maintainer Involvement**: Created by the project maintainer (gonchik) + +- **Community Confusion**: Community members asking about status and relationship to PR #1522 + +- **Unclear Status**: No clear indication of why this PR stalled + +- **Missing Implementation**: No actual code changes found in current codebase + +**Community Concerns:** + +- Users questioning if the library is usable with Confluence Cloud v2 API + +- Confusion about which PR (if any) would actually deliver v2 support + +- Need for clarity on implementation timeline and approach + +## Current State Analysis + +### What Already Exists +Based on my analysis of the current codebase, there IS already some v2 API infrastructure: + +1. **Basic v2 Support in ConfluenceCloud**: + ```python + + # atlassian/confluence/cloud/__init__.py + kwargs["api_version"] = "2" + kwargs["api_root"] = "wiki/api/v2" + ``` + +2. **Comprehensive v2 Method Implementation**: + + - The current `ConfluenceCloud` class already implements many v2-compatible methods + + - Methods like `get_content()`, `create_content()`, `get_spaces()` etc. are already present + + - Cursor-based pagination is partially implemented in `ConfluenceCloudBase._get_paged()` + +3. **Test Coverage**: + + - Tests confirm v2 API version and root path are set correctly + + - Existing test suite validates v2 endpoint structure + +### What's Missing + +1. **ADF (Atlassian Document Format) Support**: Limited ADF handling capabilities + +2. **Complete v2 Endpoint Coverage**: Some v2-specific endpoints may be missing + +3. **v2-Specific Error Handling**: Enhanced error handling for v2 API responses + +4. **Documentation**: Clear documentation about v2 support and migration + +## Why Previous Attempts Stalled + +### Technical Challenges + +1. **Complexity Underestimation**: Both PRs underestimated the scope of v2 implementation + +2. **Lack of Incremental Approach**: Attempted comprehensive implementation rather than MVP + +3. **Missing Foundation**: Didn't build on existing v2 infrastructure already in place + +### Process Issues + +1. **Poor Communication**: Limited communication about implementation approach + +2. **Scope Creep**: PR #1522 tried to implement multiple APIs simultaneously + +3. **Lack of Testing Strategy**: No clear testing approach for v2 functionality + +4. **Missing Documentation**: No clear migration path or usage documentation + +### Community Factors + +1. **Maintainer Bandwidth**: Maintainer may have limited time for large implementations + +2. **Community Confusion**: Multiple competing PRs created confusion + +3. **Unclear Requirements**: No clear specification of what v2 support should include + +## Successful Patterns Identified + +### Existing Architecture Strengths + +1. **Modular Design**: Current Cloud/Server separation provides good foundation + +2. **Inheritance Hierarchy**: Base classes allow for clean v2 extension + +3. **Configuration Flexibility**: API version and root can be easily configured + +4. **Pagination Framework**: Base pagination framework supports cursor-based pagination + +### Implementation Patterns to Follow + +1. **Incremental Enhancement**: Build on existing ConfluenceCloud rather than replacing + +2. **Backward Compatibility**: Maintain existing method signatures + +3. **Configuration-Driven**: Use configuration to enable v2 features + +4. **Test-Driven**: Validate each enhancement with tests + +## Recommendations for Current Implementation + +### 1. Build on Existing Foundation + +- **Don't Start from Scratch**: The current ConfluenceCloud already has v2 infrastructure + +- **Enhance, Don't Replace**: Add missing v2 features to existing implementation + +- **Leverage Existing Tests**: Build on current test suite rather than rewriting + +### 2. Minimal Viable Implementation + +- **Focus on Core Gaps**: Identify and fill specific v2 functionality gaps + +- **ADF Support**: Add minimal ADF handling for content creation/updates + +- **Enhanced Error Handling**: Improve v2-specific error responses + +- **Documentation**: Clear v2 usage examples and migration guidance + +### 3. Avoid Previous Mistakes + +- **Single Focus**: Only implement Confluence v2 (not Jira v3 simultaneously) + +- **Clear Scope**: Define specific deliverables and success criteria + +- **Incremental Delivery**: Implement and test features incrementally + +- **Community Communication**: Provide clear status updates and documentation + +### 4. Implementation Strategy + +1. **Phase 1**: Audit existing v2 support and identify gaps + +2. **Phase 2**: Implement missing v2-specific features (ADF, enhanced pagination) + +3. **Phase 3**: Add comprehensive error handling and validation + +4. **Phase 4**: Create documentation and migration examples + +## Key Success Factors + +### Technical + +- Build incrementally on existing v2 infrastructure + +- Focus on specific missing features rather than comprehensive rewrite + +- Maintain backward compatibility throughout + +- Implement comprehensive testing for new features + +### Process + +- Clear, focused scope (Confluence v2 only) + +- Regular progress updates and community communication + +- Incremental delivery with validation at each step + +- Proper documentation and examples + +### Community + +- Address community concerns about v2 API usability + +- Provide clear migration guidance from v1 to v2 + +- Maintain transparency about implementation progress + +- Ensure maintainer alignment and support + +## Conclusion + +The previous v2 implementation attempts stalled due to overly ambitious scope, lack of incremental approach, and insufficient building on existing infrastructure. Our current implementation should focus on enhancing the existing ConfluenceCloud class with missing v2 features rather than creating a completely new implementation. + +The good news is that significant v2 infrastructure already exists in the codebase - we need to identify and fill specific gaps rather than starting from scratch. \ No newline at end of file