diff --git a/main.py b/main.py index c835ff1b..847c9ced 100644 --- a/main.py +++ b/main.py @@ -94,12 +94,32 @@ class ColoredFormatter(logging.Formatter): } def __init__(self, fmt=None, datefmt=None, style="%", validate=True): + """Initialize the formatter with a fixed delegate formatter for timestamps. + + Args: + fmt: Log format string (unused; delegate formatter controls layout). + datefmt: Date format string (unused; delegate uses %H:%M:%S). + style: Format style character (default: "%"). + validate: Whether to validate the format string (default: True). + """ super().__init__(fmt, datefmt, style, validate) self.delegate_formatter = logging.Formatter( "%(asctime)s | %(levelname)s | %(message)s", datefmt="%H:%M:%S" ) def format(self, record): + """Format a log record, injecting ANSI color codes around the level name. + + Temporarily replaces ``record.levelname`` with a color-padded version, + delegates to the internal formatter, then restores the original level + name so the record is not mutated for downstream handlers. + + Args: + record: The :class:`logging.LogRecord` to format. + + Returns: + Formatted log string with color codes applied to the level name. + """ original_levelname = record.levelname color = self.LEVEL_COLORS.get(record.levelno, Colors.ENDC) padded_level = f"{original_levelname:<8}" @@ -346,10 +366,30 @@ def print_summary_table(results: List[Dict[str, Any]], dry_run: bool) -> None: } def _print_separator(left, mid, right): + """Print a horizontal table separator using box-drawing characters. + + Args: + left: Key for the left-corner character in ``chars``. + mid: Key for the junction character between column segments. + right: Key for the right-corner character in ``chars``. + """ segments = [chars["h"] * (width + 2) for width in col_widths.values()] print(f"{chars[left]}{chars[mid].join(segments)}{chars[right]}") def _print_row(profile, folders, rules, duration, status, is_header=False): + """Print a single data row with column values padded to fixed widths. + + Applies ANSI bold formatting to every cell when ``is_header`` is True + and colors are enabled. + + Args: + profile: Profile ID string for the first column. + folders: Folder count value for the second column. + rules: Rule count value for the third column. + duration: Elapsed-time string for the fourth column. + status: Status label for the fifth column. + is_header: When True, wraps all cells in bold ANSI codes. + """ v = chars["v"] # 1. Pad raw strings first (so padding is calculated on visible chars) @@ -631,6 +671,15 @@ def get_password( # 2. Clients # --------------------------------------------------------------------------- # def _api_client() -> httpx.Client: + """Create and return an authenticated Control D API HTTP client. + + Configures standard JSON ``Accept`` and ``Authorization: Bearer`` headers + using the module-level ``TOKEN`` constant, sets a 30-second timeout, and + disables automatic redirect following to avoid silent data loss. + + Returns: + A configured :class:`httpx.Client` ready for Control D API requests. + """ return httpx.Client( headers={ "Accept": "application/json", @@ -1121,6 +1170,21 @@ def validate_folder_data(data: Dict[str, Any], url: str) -> bool: def _api_get(client: httpx.Client, url: str) -> httpx.Response: + """Send an authenticated GET request to the Control D API with retry logic. + + Increments the ``control_d_api_calls`` counter under a thread-safe lock + before delegating to :func:`_retry_request`. + + Args: + client: Authenticated :class:`httpx.Client` created by :func:`_api_client`. + url: Full URL of the Control D API endpoint to call. + + Returns: + The :class:`httpx.Response` from the API. + + Raises: + httpx.HTTPError: After all retry attempts are exhausted. + """ global _api_stats with _api_stats_lock: _api_stats["control_d_api_calls"] += 1 @@ -1128,6 +1192,21 @@ def _api_get(client: httpx.Client, url: str) -> httpx.Response: def _api_delete(client: httpx.Client, url: str) -> httpx.Response: + """Send an authenticated DELETE request to the Control D API with retry logic. + + Increments the ``control_d_api_calls`` counter under a thread-safe lock + before delegating to :func:`_retry_request`. + + Args: + client: Authenticated :class:`httpx.Client` created by :func:`_api_client`. + url: Full URL of the Control D API endpoint to call. + + Returns: + The :class:`httpx.Response` from the API. + + Raises: + httpx.HTTPError: After all retry attempts are exhausted. + """ global _api_stats with _api_stats_lock: _api_stats["control_d_api_calls"] += 1 @@ -1135,6 +1214,22 @@ def _api_delete(client: httpx.Client, url: str) -> httpx.Response: def _api_post(client: httpx.Client, url: str, data: Dict) -> httpx.Response: + """Send an authenticated POST request with JSON body to the Control D API. + + Increments the ``control_d_api_calls`` counter under a thread-safe lock + before delegating to :func:`_retry_request`. + + Args: + client: Authenticated :class:`httpx.Client` created by :func:`_api_client`. + url: Full URL of the Control D API endpoint to call. + data: Dictionary payload to encode as the request body. + + Returns: + The :class:`httpx.Response` from the API. + + Raises: + httpx.HTTPError: After all retry attempts are exhausted. + """ global _api_stats with _api_stats_lock: _api_stats["control_d_api_calls"] += 1 @@ -1142,6 +1237,23 @@ def _api_post(client: httpx.Client, url: str, data: Dict) -> httpx.Response: def _api_post_form(client: httpx.Client, url: str, data: Dict) -> httpx.Response: + """Send an authenticated form-encoded POST request to the Control D API. + + Sets ``Content-Type: application/x-www-form-urlencoded`` explicitly and + increments the ``control_d_api_calls`` counter under a thread-safe lock + before delegating to :func:`_retry_request`. + + Args: + client: Authenticated :class:`httpx.Client` created by :func:`_api_client`. + url: Full URL of the Control D API endpoint to call. + data: Dictionary payload to URL-encode as the request body. + + Returns: + The :class:`httpx.Response` from the API. + + Raises: + httpx.HTTPError: After all retry attempts are exhausted. + """ global _api_stats with _api_stats_lock: _api_stats["control_d_api_calls"] += 1 @@ -1666,6 +1778,18 @@ def get_all_existing_rules( all_rules = set() def _fetch_folder_rules(folder_id: str) -> List[str]: + """Fetch all rule primary keys for a single folder from the API. + + Silently returns an empty list on HTTP or other errors so that + a single folder failure does not abort the entire sync. + + Args: + folder_id: The Control D folder identifier to query. + + Returns: + List of rule PK strings contained in the folder, or ``[]`` on + any error. + """ try: data = _api_get(client, f"{API_BASE}/{profile_id}/rules/{folder_id}").json() folder_rules = data.get("body", {}).get("rules", []) @@ -1758,6 +1882,18 @@ def warm_up_cache(urls: Sequence[str]) -> None: # OPTIMIZATION: Combine validation (DNS) and fetching (HTTP) in one task # to allow validation latency to be parallelized. def _validate_and_fetch(url: str): + """Validate a folder URL and fetch its content in a single step. + + Combines DNS/URL validation with HTTP fetching so that both + operations can be parallelized across multiple URLs in a thread pool. + + Args: + url: Remote URL to validate and fetch. + + Returns: + Parsed JSON dict returned by :func:`_gh_get`, or ``None`` if + validation fails. + """ if validate_folder_url(url): return _gh_get(url) return None @@ -2094,6 +2230,28 @@ def _process_single_folder( client: httpx.Client, batch_executor: Optional[concurrent.futures.Executor] = None, ) -> bool: + """Create a Control D folder and push all its rules to the given profile. + + Reads folder metadata and action settings from ``folder_data``, creates the + folder via the API, then pushes rules either from nested ``rule_groups`` or + directly from the top-level ``rules`` list. An optional shared + ``batch_executor`` may be passed in to parallelize rule-push batches across + folders. + + Args: + folder_data: Parsed JSON dict describing one folder (group name, + action flags, and rule list). + profile_id: Control D profile identifier to create the folder in. + existing_rules: Set of rule PKs already present in the profile, used + to skip duplicate pushes. + client: Authenticated :class:`httpx.Client` for all API calls. + batch_executor: Optional thread-pool executor shared across folder + workers; when ``None`` a temporary executor is created internally. + + Returns: + ``True`` if the folder and all its rules were processed successfully, + ``False`` if folder creation or any rule-push batch failed. + """ grp = folder_data["group"] name = grp["group"].strip() @@ -2171,6 +2329,18 @@ def sync_profile( # OPTIMIZATION: Move validation inside the thread pool to parallelize DNS lookups. # Previously, sequential validation blocked the main thread. def _fetch_if_valid(url: str): + """Return folder data from cache or fetch it after URL validation. + + Checks the in-memory cache first to avoid redundant HTTP requests + when :func:`warm_up_cache` has already fetched the content. Falls + back to validating the URL then calling :func:`fetch_folder_data`. + + Args: + url: Remote folder JSON URL to retrieve. + + Returns: + Parsed folder data dict, or ``None`` if URL validation fails. + """ # Optimization: If we already have the content in cache, return it directly. # The content was validated at the time of fetch (warm_up_cache). # Read directly from cache to avoid calling fetch_folder_data while holding lock. @@ -2358,6 +2528,21 @@ def _fetch_if_valid(url: str): def print_summary_table( sync_results: List[Dict[str, Any]], success_count: int, total: int, dry_run: bool ) -> None: + """Print a formatted summary table of sync results for all profiles. + + Renders either a Unicode box-drawing table (when colors are enabled) or a + plain ASCII table (fallback). Includes per-profile rows with folder/rule + counts and durations, plus a totals footer row. + + Args: + sync_results: List of per-profile result dicts, each containing at + least ``profile``, ``folders``, ``rules``, ``duration``, + ``status_label``, and ``success`` keys. + success_count: Number of profiles that completed without errors. + total: Total number of profiles that were processed. + dry_run: When ``True``, labels the table header as "DRY RUN SUMMARY" + and uses a cyan title color instead of the default header color. + """ # 1. Setup Data max_p = max((len(r["profile"]) for r in sync_results), default=25) w = [max(25, max_p), 10, 12, 10, 15] @@ -2619,6 +2804,20 @@ def validate_profile_input(value: str) -> bool: w_status = 15 def make_col_separator(left, mid, right, horiz): + """Build a full-width horizontal separator string for the results table. + + Constructs a separator by joining fixed-width column segments with a + junction character and capping both ends with corner characters. + + Args: + left: Left-corner box-drawing character (e.g. ``Box.TL``). + mid: Junction character between column segments (e.g. ``Box.T``). + right: Right-corner box-drawing character (e.g. ``Box.TR``). + horiz: Horizontal fill character repeated per column (e.g. ``Box.H``). + + Returns: + Complete separator string ready to print. + """ parts = [ horiz * (w_profile + 2), horiz * (w_folders + 2), diff --git a/uv.lock b/uv.lock index 53e9b387..ebe769ce 100644 --- a/uv.lock +++ b/uv.lock @@ -45,6 +45,7 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "pytest" }, + { name = "pytest-benchmark" }, { name = "pytest-mock" }, { name = "pytest-xdist" }, ] @@ -53,6 +54,7 @@ dev = [ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "pytest-benchmark", marker = "extra == 'dev'", specifier = ">=4.0.0" }, { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.10.0" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.0.0" }, { name = "python-dotenv", specifier = ">=1.1.1" }, @@ -141,6 +143,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -166,6 +177,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-benchmark" +version = "5.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/34/9f732b76456d64faffbef6232f1f9dbec7a7c4999ff46282fa418bd1af66/pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779", size = 341340, upload-time = "2025-11-09T18:48:43.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803", size = 45255, upload-time = "2025-11-09T18:48:39.765Z" }, +] + [[package]] name = "pytest-mock" version = "3.15.1"