Source code for docpack_confluence.selector

# -*- coding: utf-8 -*-

"""
Confluence page selector with include/exclude filter rules.

This module implements the filter language defined in the documentation,
supporting URL-based matching with wildcard syntax.
"""

import typing as T
import re
import enum
import dataclasses

from .type_hint import T_ID_PATH


[docs] class MatchMode(str, enum.Enum): """ Defines how a pattern matches against a page's path. - SELF: Match only the node itself (no wildcard) - DESCENDANTS: Match all descendants, excluding the node itself (/*) - RECURSIVE: Match the node and all its descendants (/**) """ SELF = "self" DESCENDANTS = "descendants" RECURSIVE = "recursive"
[docs] def is_self(self) -> bool: # pragma: no cover """Check if the mode is SELF.""" return self == MatchMode.SELF
[docs] def is_descendant(self) -> bool: # pragma: no cover """Check if the mode is DESCENDANTS.""" return self == MatchMode.DESCENDANTS
[docs] def is_recursive(self) -> bool: # pragma: no cover """Check if the mode is RECURSIVE.""" return self == MatchMode.RECURSIVE
[docs] @dataclasses.dataclass(frozen=True) class Pattern: """ A matching pattern parsed from a Confluence URL. :param id: The page or folder ID extracted from the URL :param mode: The matching mode (SELF, DESCENDANTS, or RECURSIVE) """ id: str mode: MatchMode def __repr__(self) -> str: suffix = { MatchMode.SELF: "", MatchMode.DESCENDANTS: "/*", MatchMode.RECURSIVE: "/**", }[self.mode] return f"Pattern({self.id!r}{suffix})"
# Regex patterns for parsing Confluence URLs # Page URL: https://{domain}/wiki/spaces/{space_key}/pages/{page_id}/{title} # Folder URL: https://{domain}/wiki/spaces/{space_key}/folder/{folder_id}?{params} _PAGE_URL_PATTERN = re.compile( r"https?://[^/]+/wiki/spaces/[^/]+/pages/(\d+)(?:/[^/*]*)?" ) _FOLDER_URL_PATTERN = re.compile( r"https?://[^/]+/wiki/spaces/[^/]+/folder/(\d+)(?:\?[^/*]*)?" )
[docs] def parse_pattern(url: str) -> Pattern: """ Parse a Confluence URL into a Pattern object. Supports three formats: - ``{url}`` -> MatchMode.SELF (match only the node) - ``{url}/*`` -> MatchMode.DESCENDANTS (match all descendants) - ``{url}/**`` -> MatchMode.RECURSIVE (match node and all descendants) :param url: Confluence page or folder URL, optionally with /* or /** suffix :returns: Parsed Pattern object :raises ValueError: If the URL format is not recognized """ # Determine match mode from suffix if url.endswith("/**"): mode = MatchMode.RECURSIVE url = url[:-3] elif url.endswith("/*"): mode = MatchMode.DESCENDANTS url = url[:-2] else: mode = MatchMode.SELF # Try to match page URL match = _PAGE_URL_PATTERN.match(url) if match: return Pattern(id=match.group(1), mode=mode) # Try to match folder URL match = _FOLDER_URL_PATTERN.match(url) if match: return Pattern(id=match.group(1), mode=mode) raise ValueError(f"Invalid Confluence URL format: {url}")
[docs] def is_match(pattern: Pattern, id_path: T_ID_PATH) -> bool: """ Check if a page's ID path matches the given pattern. :param pattern: The pattern to match against :param id_path: The page's full ID path from root to current node, e.g., ["p1", "f1", "p2"] means the page is at p1 -> f1 -> p2 :returns: True if the path matches the pattern, False otherwise **Matching rules**: - SELF: The page's ID (last element) must equal pattern.id - DESCENDANTS: pattern.id must be an ancestor (not the page itself) - RECURSIVE: pattern.id must be in the path (ancestor or self) **Examples**:: >>> path = ["p1", "f1", "p2"] # page p2 under f1 under p1 >>> is_match(Pattern("p2", MatchMode.SELF), path) True # p2 is the page itself >>> is_match(Pattern("p1", MatchMode.SELF), path) False # p1 is not the page itself >>> is_match(Pattern("p1", MatchMode.DESCENDANTS), path) True # p2 is a descendant of p1 >>> is_match(Pattern("p2", MatchMode.DESCENDANTS), path) False # p2 has no descendants here >>> is_match(Pattern("p1", MatchMode.RECURSIVE), path) True # p2 is under p1 >>> is_match(Pattern("p2", MatchMode.RECURSIVE), path) True # p2 is itself """ if not id_path: return False target_id = pattern.id current_id = id_path[-1] is_in_path = target_id in id_path is_self = current_id == target_id if pattern.mode == MatchMode.SELF: # Only match if the current node IS the target return is_self elif pattern.mode == MatchMode.DESCENDANTS: # Match if target is an ancestor (in path but not the current node) return is_in_path and not is_self elif pattern.mode == MatchMode.RECURSIVE: # Match if target is in path (ancestor or self) return is_in_path else: return False
[docs] @dataclasses.dataclass class Selector: """ Page selector with include/exclude filter rules. :param include: List of URL patterns to include. Empty list means include all. :param exclude: List of URL patterns to exclude. Empty list means exclude nothing. **Priority**: exclude > include. If a page matches both, it is excluded. **Example**:: selector = Selector( include=["https://example.atlassian.net/wiki/spaces/DEMO/pages/123/Title/**"], exclude=["https://example.atlassian.net/wiki/spaces/DEMO/pages/456/Other/*"], ) # Check if a page should be included if selector.should_include(page_id_path): # Process this page ... """ include: list[str] = dataclasses.field(default_factory=list) exclude: list[str] = dataclasses.field(default_factory=list) def __post_init__(self): # Parse URL strings into Pattern objects self._include_patterns: list[Pattern] = [ parse_pattern(url) for url in self.include ] self._exclude_patterns: list[Pattern] = [ parse_pattern(url) for url in self.exclude ] def _matches_any(self, patterns: list[Pattern], id_path: T_ID_PATH) -> bool: """Check if id_path matches any of the patterns.""" return any(is_match(pattern, id_path) for pattern in patterns)
[docs] def should_include(self, id_path: T_ID_PATH) -> bool: """ Determine if a page should be included based on filter rules. :param id_path: The page's full ID path from root to current node :returns: True if the page should be included, False otherwise **Logic**: 1. If exclude patterns exist and page matches any -> excluded 2. If include patterns are empty -> included (include all) 3. If include patterns exist and page matches any -> included 4. Otherwise -> excluded """ # Check exclude first (higher priority) if self._exclude_patterns and self._matches_any( self._exclude_patterns, id_path ): return False # Empty include means include all if not self._include_patterns: return True # Check if matches any include pattern return self._matches_any(self._include_patterns, id_path)
[docs] def select( self, pages: T.Iterable[tuple[str, T_ID_PATH]], ) -> T.Iterator[tuple[str, T_ID_PATH]]: """ Filter pages based on include/exclude rules. :param pages: Iterable of (page_id, id_path) tuples :returns: Iterator of pages that pass the filter """ for page_id, id_path in pages: if self.should_include(id_path): yield page_id, id_path