From ba55c5b68dd3bb11219cf21a4ab54cddc3fa089e Mon Sep 17 00:00:00 2001 From: Martin Mahner Date: Fri, 2 Oct 2026 15:49:41 +0200 Subject: [PATCH] Speed up class sorting and startup, drop the 60-second run limit The sorting hot path read its lookup tables through Dynaconf, whose DataDict containers scan every key on each membership test; more than half of a steady-state run was spent there. The tables now live in a plain Lookups object that is rebuilt whenever the configuration changes. Sort keys are cached per class name and built from a precomputed component table instead of an if/elif chain, sorted attribute values are cached as well (bounded to 8,192 entries), and classes without arbitrary values are split with str.split. Rich, wcmatch, difflib and the package metadata lookup are imported only when needed, and the console is created only in file mode, which takes a single-file stdin run from 88ms to 64ms. as_completed() was called with timeout=60, a limit on the whole run rather than per file, so any run longer than a minute died with a TimeoutError. Measured: a dry run over 1,650 already sorted files drops from 2.3s to 0.4s; three real projects run 2.1 to 2.6 times faster. Output is byte-identical across that corpus and 388 class lists under 7 configurations. Co-Authored-By: Claude --- CHANGELOG.md | 9 ++ tailwhip/__init__.py | 14 ++- tailwhip/cli.py | 26 ++--- tailwhip/configuration.py | 96 +++++++++++++------ tailwhip/files.py | 19 ++-- tailwhip/process.py | 48 +++++++--- tailwhip/sorting.py | 196 +++++++++++++++++--------------------- 7 files changed, 232 insertions(+), 176 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08dd176..9707ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Performance**: Sorting is 2 to 5 times faster on large codebases. The lookup tables used while parsing a class are no longer read through Dynaconf, whose containers scan every key on each membership test; they live in a plain `Lookups` object that is rebuilt whenever the configuration changes + - Sort keys are cached per class name and built from a precomputed component table instead of an if/elif chain; sorted class attribute values are cached as well (bounded to 8,192 entries). The separate parse cache is no longer needed + - Splitting a class without arbitrary values uses `str.split` instead of a character loop + - A dry run over 1,650 already sorted files drops from 2.3s to 0.4s; three real projects run 2.1 to 2.6 times faster +- **Performance**: Faster startup for stdin and `--version`. Rich, wcmatch, difflib and the package metadata lookup are imported only when needed, and the console is created only in file mode (one file on stdin: 88ms to 64ms) + ### Fixed +- **Reliability**: Runs longer than 60 seconds no longer abort with a `TimeoutError`; the thread pool waited with a total timeout instead of a per-file one - **CI**: The documentation workflow no longer runs for pull requests; GitHub Pages' environment protection rejects deployments from anything but `main`, so every pull request showed a failed check ## [0.14.0] - 2026-09-29 diff --git a/tailwhip/__init__.py b/tailwhip/__init__.py index d28c57b..9ae1f34 100644 --- a/tailwhip/__init__.py +++ b/tailwhip/__init__.py @@ -2,10 +2,18 @@ from __future__ import annotations -from importlib import metadata - from tailwhip.cli import main -__version__ = metadata.version("tailwhip") __author__ = "Martin Mahner" __all__ = ["__author__", "__version__", "main"] + + +def __getattr__(name: str) -> str: + """Resolve the package version on first use; reading the metadata is slow.""" + if name == "__version__": + from importlib import metadata # noqa: PLC0415 + + return metadata.version("tailwhip") + + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) diff --git a/tailwhip/cli.py b/tailwhip/cli.py index 461119a..116b8f2 100644 --- a/tailwhip/cli.py +++ b/tailwhip/cli.py @@ -4,17 +4,15 @@ import sys import time -from importlib import metadata from pathlib import Path from typing import Annotated import typer -from rich.console import Console from tailwhip.configuration import ( - CONSOLE_THEME, VerbosityLevel, config, + create_console, get_pyproject_toml_data, update_configuration, ) @@ -25,6 +23,8 @@ def version_callback(value: bool) -> None: """Print version and exit.""" if value: + from importlib import metadata # noqa: PLC0415 + version = metadata.version("tailwhip") typer.echo(f"tailwhip {version}") raise typer.Exit @@ -149,20 +149,20 @@ def run( # noqa: PLR0913, PLR0917 } ) - # Setup helper tools --------------------------------------------------------------- + # Handle stdin mode ---------------------------------------------------------------- - config.console = Console(quiet=quiet, theme=CONSOLE_THEME) + # Without paths and with stdin being piped (not a TTY), process stdin to stdout + if not paths and not sys.stdin.isatty(): + input_text = sys.stdin.read() + output_text = process_text(input_text) + sys.stdout.write(output_text) + return - # Handle stdin mode ---------------------------------------------------------------- + # Setup helper tools --------------------------------------------------------------- - if not paths: - # Check if stdin is being piped (not a TTY) - if not sys.stdin.isatty(): - input_text = sys.stdin.read() - output_text = process_text(input_text) - sys.stdout.write(output_text) - return + config.console = create_console(quiet=quiet) + if not paths: # No paths and no piped input config.console.print( "[red]Error: No paths provided. Provide file paths or pipe content to stdin.[/red]" diff --git a/tailwhip/configuration.py b/tailwhip/configuration.py index 109ab7a..9ecec8f 100644 --- a/tailwhip/configuration.py +++ b/tailwhip/configuration.py @@ -4,29 +4,41 @@ import re import tomllib -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import IntEnum from pathlib import Path from typing import TYPE_CHECKING import dynaconf -import rich if TYPE_CHECKING: from collections.abc import Callable + import rich.console + # Path to default configuration file BASE_CONFIGURATION_FILE = Path(__file__).parent / "configuration.toml" -# Console theme for rich.Console output -CONSOLE_THEME = rich.theme.Theme( - { - "important": "white on deep_pink4", - "highlight": "yellow1", - "filename": "white", - "bold": "sky_blue1", - } -) +# Styles for the rich.Console output +CONSOLE_STYLES = { + "important": "white on deep_pink4", + "highlight": "yellow1", + "filename": "white", + "bold": "sky_blue1", +} + + +def create_console(*, quiet: bool) -> rich.console.Console: + """ + Create the console used for all output. + + Rich is imported here rather than at module level: importing it is a + noticeable part of the startup time, and stdin mode never prints through it. + """ + from rich.console import Console # noqa: PLC0415 + from rich.theme import Theme # noqa: PLC0415 + + return Console(quiet=quiet, theme=Theme(CONSOLE_STYLES)) @dataclass @@ -38,6 +50,33 @@ class Pattern: template: str +@dataclass(slots=True) +class Lookups: + """ + Plain lookup tables derived from the configuration lists. + + Values stored on the Dynaconf settings object are wrapped in node types whose + membership tests scan every key, far too slow for the sorting hot path. These + plain structures are refreshed in place by _rebuild_lookups() whenever the + configuration changes, so modules can keep a reference to the single instance. + """ + + variant_separator: str = ":" + skip_expressions: tuple[str, ...] = () + component_order: tuple[str, ...] = () + variant_index: dict[str, int] = field(default_factory=dict) + prefix_index: dict[str, int] = field(default_factory=dict) + direction_index: dict[str, int] = field(default_factory=dict) + size_index: dict[str, int] = field(default_factory=dict) + value_index: dict[str, int] = field(default_factory=dict) + color_index: dict[str, int] = field(default_factory=dict) + shade_index: dict[str, int] = field(default_factory=dict) + alpha_index: dict[str, int] = field(default_factory=dict) + + +lookups = Lookups() + + def get_pyproject_toml_data(start_path: Path) -> Path | None: """Search for pyproject.toml starting at the given path.""" pyproject_path = None @@ -87,19 +126,24 @@ def update_configuration(data: dict | Path) -> None: def _rebuild_lookups() -> None: - """Rebuild lookup dictionaries and compile patterns.""" + """Rebuild the lookup tables and compile patterns.""" + # Plain copies of the settings read for every class or attribute + lookups.variant_separator = config.variant_separator + lookups.skip_expressions = tuple(config.skip_expressions) + lookups.component_order = tuple(config.component_order) + # Build lookup dicts for O(1) index access - config.variant_index = {v: i for i, v in enumerate(config.variants)} - config.prefix_index = {p: i for i, p in enumerate(config.prefixes)} - config.direction_index = {d: i for i, d in enumerate(config.directions)} - config.size_index = {s: i for i, s in enumerate(config.sizes)} - config.value_index = {v: i for i, v in enumerate(config.numerics)} - config.shade_index = {s: i for i, s in enumerate(config.shades)} - config.alpha_index = {a: i for i, a in enumerate(config.alphas)} + lookups.variant_index = {v: i for i, v in enumerate(config.variants)} + lookups.prefix_index = {p: i for i, p in enumerate(config.prefixes)} + lookups.direction_index = {d: i for i, d in enumerate(config.directions)} + lookups.size_index = {s: i for i, s in enumerate(config.sizes)} + lookups.value_index = {v: i for i, v in enumerate(config.numerics)} + lookups.shade_index = {s: i for i, s in enumerate(config.shades)} + lookups.alpha_index = {a: i for i, a in enumerate(config.alphas)} # Combine and sort colors alphabetically all_colors_sorted = sorted({*config.colors, *config.custom_colors}) - config.color_index = {c: i for i, c in enumerate(all_colors_sorted)} + lookups.color_index = {c: i for i, c in enumerate(all_colors_sorted)} # Compile class_patterns into Pattern objects with compiled regexes config.APPLY_PATTERNS = [ @@ -111,7 +155,7 @@ def _rebuild_lookups() -> None: for pattern in config.class_patterns ] - # Cached parse results depend on the lookups above, so drop them + # Cached results derived from the configuration are stale now, so drop them for clear in _cache_clearers: clear() @@ -152,16 +196,6 @@ class TailwhipConfig(dynaconf.Dynaconf): shades: list[str] alphas: list[str] - # Lookup dictionaries (built at runtime for O(1) access) - variant_index: dict[str, int] - prefix_index: dict[str, int] - direction_index: dict[str, int] - size_index: dict[str, int] - value_index: dict[str, int] - color_index: dict[str, int] - shade_index: dict[str, int] - alpha_index: dict[str, int] - # Compiled patterns APPLY_PATTERNS: list[Pattern] diff --git a/tailwhip/files.py b/tailwhip/files.py index cce7321..2ac1086 100644 --- a/tailwhip/files.py +++ b/tailwhip/files.py @@ -2,22 +2,19 @@ from __future__ import annotations -import difflib from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING -from rich.padding import Padding -from rich.syntax import Syntax -from wcmatch import glob - from tailwhip.configuration import VerbosityLevel, config from tailwhip.process import process_text if TYPE_CHECKING: from collections.abc import Generator, Iterable + from rich.syntax import Syntax + @dataclass(slots=True) class FileResult: @@ -55,6 +52,9 @@ def find_files(*, paths: list[Path]) -> Generator[Path]: [PosixPath('home.html'), PosixPath('static/app.css')] """ + # Imported on demand: stdin mode never needs it, and it is slow to import + from wcmatch import glob # noqa: PLC0415 + seen = set() flags = glob.GLOBSTAR | glob.BRACE | glob.EXTGLOB | glob.DOTGLOB @@ -86,6 +86,11 @@ def find_files(*, paths: list[Path]) -> Generator[Path]: def get_diff(path: Path, old_text: str, new_text: str) -> Syntax: """Show a nice diff using Rich.""" + # Imported on demand: only the diff verbosity needs them + import difflib # noqa: PLC0415 + + from rich.syntax import Syntax # noqa: PLC0415 + # Create a text diff between old and new text diff = difflib.unified_diff( old_text.splitlines(), @@ -143,6 +148,8 @@ def _process_file(f: Path) -> FileResult: config.console.print(f"[dim]Would update[/dim] [filename]{f}[/filename]") if config.verbosity >= VerbosityLevel.DIFF: + from rich.padding import Padding # noqa: PLC0415 + diff = get_diff(f, old_text, new_text) config.console.print(Padding(diff, (1, 0, 1, 4))) @@ -180,7 +187,7 @@ def apply_changes(*, targets: Iterable[Path]) -> tuple[bool, int, int]: with ThreadPoolExecutor() as executor: futures = [executor.submit(_process_file, f) for f in targets] - for future in as_completed(futures, timeout=60): + for future in as_completed(futures): found_any = True result = future.result() diff --git a/tailwhip/process.py b/tailwhip/process.py index 68e652c..94d991a 100644 --- a/tailwhip/process.py +++ b/tailwhip/process.py @@ -2,9 +2,10 @@ from __future__ import annotations +from functools import lru_cache from typing import TYPE_CHECKING -from tailwhip.configuration import config +from tailwhip.configuration import config, lookups, register_cache_clearer from tailwhip.sorting import sort_classes if TYPE_CHECKING: @@ -41,7 +42,29 @@ def split_classes(s: str) -> list[str]: [] """ - return s.strip().split() + return s.split() + + +@lru_cache(maxsize=8192) +def _sort_class_string(classes_str: str) -> str | None: + """ + Sort the classes of a class attribute value, or return None to leave it as is. + + The same attribute values repeat all over a codebase, so results are cached. + Values containing a template expression, or no classes at all, yield None. + """ + # Skip if a template expression appears inside the class attribute + for skip_expr in lookups.skip_expressions: + if skip_expr in classes_str: + return None + + classes = split_classes(classes_str) + + # Skip if no classes were found + if not classes: + return None + + return " ".join(sort_classes(classes)) def process_pattern(match: re.Match[str], template: str) -> str: @@ -74,24 +97,16 @@ def process_pattern(match: re.Match[str], template: str) -> str: >>> # Output: class="text-{{ color }}-500 flex" # Unchanged """ - classes_str = match.group("classes") + sorted_classes = _sort_class_string(match.group("classes")) - # Skip if a template expression appears inside the class attribute - if any(skip_expr in classes_str for skip_expr in config.skip_expressions): + # Skip template expressions and empty attributes + if sorted_classes is None: return match.group(0) - classes = split_classes(classes_str) - - # Skip if no classes were found - if not classes: - return match.group(0) - - sorted_classes = sort_classes(classes) - # Get all named groups from the match as context context = match.groupdict() # Override 'classes' with sorted version - context["classes"] = " ".join(sorted_classes) + context["classes"] = sorted_classes # Use template to reconstruct return template.format(**context) @@ -129,3 +144,8 @@ def process_text(text: str) -> str: text, ) return text + + +# Sorted class strings depend on the configuration, so the cache must be dropped +# whenever the configuration changes. +register_cache_clearer(_sort_class_string.cache_clear) diff --git a/tailwhip/sorting.py b/tailwhip/sorting.py index 8ee19fb..71d5553 100644 --- a/tailwhip/sorting.py +++ b/tailwhip/sorting.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from functools import cache -from tailwhip.configuration import config, register_cache_clearer +from tailwhip.configuration import lookups, register_cache_clearer @dataclass(slots=True) @@ -33,12 +33,13 @@ def _extract_prefix_and_tokens(tokens: list[str]) -> tuple[str, list[str]]: prefix = tokens[0] remaining = tokens[1:] + prefix_index = lookups.prefix_index # Try to find the longest matching prefix # e.g., "inline-flex" should match as prefix, not "inline" + value "flex" for i in range(len(remaining), 0, -1): candidate = "-".join([prefix, *remaining[:i]]) - if candidate in config.prefix_index: + if candidate in prefix_index: return candidate, remaining[i:] return prefix, remaining @@ -56,15 +57,15 @@ def _parse_component_tokens( suffix_parts = [] for token in tokens: - if direction is None and token in config.direction_index: + if direction is None and token in lookups.direction_index: direction = token - elif size is None and token in config.size_index: + elif size is None and token in lookups.size_index: size = token - elif value is None and token in config.value_index: + elif value is None and token in lookups.value_index: value = token - elif color is None and token in config.color_index: + elif color is None and token in lookups.color_index: color = token - elif shade is None and token in config.shade_index: + elif shade is None and token in lookups.shade_index: shade = token else: suffix_parts.append(token) @@ -72,7 +73,6 @@ def _parse_component_tokens( return direction, size, value, color, shade, "-".join(suffix_parts) -@cache def parse_class(classname: str) -> ParsedClass: """ Parse a Tailwind CSS class into its components. @@ -95,7 +95,7 @@ def parse_class(classname: str) -> ParsedClass: # 2. Split variants from the utility. Separators inside [...] or (...) # belong to an arbitrary value, e.g. "supports-[display:grid]:grid". - parts = _split_top_level(remaining, config.variant_separator) + parts = _split_top_level(remaining, lookups.variant_separator) variants = parts[:-1] # All but the last are variants utility = parts[-1] # Last part is the utility @@ -143,6 +143,11 @@ def _split_top_level(text: str, separator: str) -> list[str]: ("supports-[display:grid]:") and CSS variable shorthands ("bg-(--brand)") contain characters that would otherwise be mistaken for separators. """ + # Without an opening bracket nothing can be nested, so a plain split is + # equivalent and much faster than walking the string character by character. + if "[" not in text and "(" not in text: + return text.split(separator) + parts: list[str] = [] current: list[str] = [] depth = 0 @@ -197,91 +202,42 @@ def _is_base_utility(parsed: ParsedClass) -> bool: _MAX_RANK = 999999 # For unknown values not in any list - -def _component_rank(component_type: str, parsed: ParsedClass) -> tuple[int, str]: - """ - Get the sort rank for a component type from a parsed class. - - Returns a tuple of (index, value) where: - - index is the position in the component's list - - value is the string value for secondary sorting - - Direction: no-direction sorts first (-1), e.g., border-1 before border-t-1. - Other components: no-value sorts last (max_rank), e.g., border-1 before border-red. - Unknown prefixes get -1 (sort first, non-Tailwind classes before Tailwind). - Unknown values (not in the list) get max_rank (sort last). - """ - # Get component data: (value, index_map, none_rank, unknown_rank) - # Using if/elif chain instead of dict lookup for performance - if component_type == "prefix": - value, index_map, none_rank, unknown_rank = ( - parsed.prefix, - config.prefix_index, - -1, - -1, - ) - elif component_type == "direction": - value, index_map, none_rank, unknown_rank = ( - parsed.direction, - config.direction_index, - -1, - _MAX_RANK, - ) - elif component_type == "size": - value, index_map, none_rank, unknown_rank = ( - parsed.size, - config.size_index, - _MAX_RANK, - _MAX_RANK, - ) - elif component_type == "value": - value, index_map, none_rank, unknown_rank = ( - parsed.value, - config.value_index, - _MAX_RANK, - _MAX_RANK, - ) - elif component_type == "color": - value, index_map, none_rank, unknown_rank = ( - parsed.color, - config.color_index, - _MAX_RANK, - _MAX_RANK, - ) - elif component_type == "shade": - value, index_map, none_rank, unknown_rank = ( - parsed.shade, - config.shade_index, - _MAX_RANK, - _MAX_RANK, - ) - elif component_type == "alpha": - value, index_map, none_rank, unknown_rank = ( - parsed.alpha, - config.alpha_index, - _MAX_RANK, - _MAX_RANK, - ) - else: - return (_MAX_RANK, "") - - if value is None: - return (none_rank, "") - - idx = index_map.get(value, unknown_rank) - return (idx, value) +# The components compared after variants and prefix, keyed by their name in the +# component_order configuration. Each entry holds the ParsedClass attribute, the +# lookup table on `lookups`, the rank used when a class has no such component and +# the rank used when the value is not in the list: +# +# - Direction: no-direction sorts first (-1), e.g., border-1 before border-t-1. +# - Other components: no-value sorts last (max rank), e.g., border-1 before border-red. +# - Unknown values (not in the list) get max rank (sort last). +_COMPONENTS: dict[str, tuple[str, str, int, int]] = { + "direction": ("direction", "direction_index", -1, _MAX_RANK), + "size": ("size", "size_index", _MAX_RANK, _MAX_RANK), + "value": ("value", "value_index", _MAX_RANK, _MAX_RANK), + "color": ("color", "color_index", _MAX_RANK, _MAX_RANK), + "shade": ("shade", "shade_index", _MAX_RANK, _MAX_RANK), + "alpha": ("alpha", "alpha_index", _MAX_RANK, _MAX_RANK), +} + +# Tables derived from the configuration by _refresh_tables(): the components to +# compare in configured order as (attribute, index, none_rank, unknown_rank), and +# the ("name-", "name[") prefixes with their rank for variants that take an +# argument, e.g. "min-[320px]". +_component_ranks: list[tuple[str, dict[str, int], int, int]] = [] +_variant_prefixes: list[tuple[tuple[str, str], int]] = [] def _get_variant_rank(variant: str) -> int: """Get the sort rank for a single variant, supporting prefix matching.""" # Try exact match first (fast path) - if variant in config.variant_index: - return config.variant_index[variant] + rank = lookups.variant_index.get(variant) + if rank is not None: + return rank # Try prefix match (e.g., "min-[320px]" matches "min") - for known_variant, idx in config.variant_index.items(): - if variant.startswith((known_variant + "-", known_variant + "[")): - return idx + for prefixes, rank in _variant_prefixes: + if variant.startswith(prefixes): + return rank return _MAX_RANK @@ -295,6 +251,7 @@ def _variant_sort_key(variants: list[str]) -> tuple: return tuple((_get_variant_rank(v), v) for v in variants) +@cache def sort_key(classname: str) -> tuple: """ Generate a sort key for a Tailwind CSS class. @@ -305,28 +262,29 @@ def sort_key(classname: str) -> tuple: 3. Base utility flag (base utilities sort first within same prefix) 4. Direction, Size, Value, Color, Shade, Alpha (by component_order) 5. Suffix (arbitrary values sort last) + + Keys are cached per class name, as the same classes appear over and over + across a codebase. The cache is dropped whenever the configuration changes. """ parsed = parse_class(classname) - # Build the sort key - key_parts: list = [] - - # 1. Variants - key_parts.append(_variant_sort_key(parsed.variants)) - - # 2. Prefix - key_parts.append(_component_rank("prefix", parsed)) - - # 3. Base utility flag (base utilities sort first within same prefix) - # This ensures "border" < "border-t" and "blur" < "blur-sm" - key_parts.append(0 if _is_base_utility(parsed) else 1) - - # 4. Components in order (skip variant and prefix) - key_parts.extend( - _component_rank(component_type, parsed) - for component_type in config.component_order - if component_type not in ("variant", "prefix") - ) + key_parts: list = [ + # 1. Variants + _variant_sort_key(parsed.variants), + # 2. Prefix. Unknown prefixes get -1, so non-Tailwind classes sort first. + (lookups.prefix_index.get(parsed.prefix, -1), parsed.prefix), + # 3. Base utility flag (base utilities sort first within same prefix) + # This ensures "border" < "border-t" and "blur" < "blur-sm" + 0 if _is_base_utility(parsed) else 1, + ] + + # 4. Components in configured order + for attribute, index, none_rank, unknown_rank in _component_ranks: + value = getattr(parsed, attribute) + if value is None: + key_parts.append((none_rank, "")) + else: + key_parts.append((index.get(value, unknown_rank), value)) # 5. Suffix (arbitrary values sort last within same component structure) key_parts.append((0 if not parsed.suffix else 1, parsed.suffix)) @@ -349,6 +307,26 @@ def sort_classes(class_list: list[str]) -> list[str]: return sorted(deduped, key=sort_key) -# Parsed classes depend on the configuration lists, so the cache must be dropped -# whenever the configuration changes. -register_cache_clearer(parse_class.cache_clear) +def _refresh_tables() -> None: + """Rebuild the sorting tables from the configuration and drop cached sort keys.""" + ranks = [] + for component in lookups.component_order: + # Unknown component names are ignored; "variant" and "prefix" always + # come first and are handled separately in sort_key() + if component in _COMPONENTS: + attribute, table, none_rank, unknown_rank = _COMPONENTS[component] + ranks.append((attribute, getattr(lookups, table), none_rank, unknown_rank)) + _component_ranks[:] = ranks + + _variant_prefixes[:] = [ + ((variant + "-", variant + "["), rank) + for variant, rank in lookups.variant_index.items() + ] + + sort_key.cache_clear() + + +# Sort keys depend on the configuration lists, so the tables and the cache must be +# rebuilt whenever the configuration changes. +register_cache_clearer(_refresh_tables) +_refresh_tables()