Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions tailwhip/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
26 changes: 13 additions & 13 deletions tailwhip/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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
Expand Down Expand Up @@ -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]"
Expand Down
96 changes: 65 additions & 31 deletions tailwhip/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 = [
Expand All @@ -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()

Expand Down Expand Up @@ -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]

Expand Down
19 changes: 13 additions & 6 deletions tailwhip/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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)))

Expand Down Expand Up @@ -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()

Expand Down
48 changes: 34 additions & 14 deletions tailwhip/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Loading
Loading