Repository navigation
Conversation
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 <noreply@anthropic.com>
This branch had an error being deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Most of tailwhip's time was not spent sorting classes. The sorting hot path read its lookup tables through Dynaconf, whose containers scan every key on each membership test; every class and attribute was re-sorted on every occurrence although real codebases repeat them almost entirely; and a 60-second limit in the thread pool aborted any run that took longer. This PR fixes those three things with byte-identical output, and defers the imports that a single-file stdin run never needs.
-q(the pre-commit / CI case)node_modules/.venv/.gitFull CLI wall time, best of three, including interpreter startup, Python 3.14.7 on Apple Silicon. Peak RSS rises by about 4 MB on the synthetic corpus (54 → 58 MB) because of the bounded attribute cache; it drops slightly on the real projects.
What changed
configuration.py: a plainLookupsdataclass holds the derived tables (*_indexdicts,variant_separator,skip_expressions,component_order)._rebuild_lookups()fills it in place whenever the configuration changes, so modules keep a reference to the single instance. The Dynaconf object keeps every user-facing setting and the compiled patterns; only derived data moves out. Rich is created throughcreate_console()on demand.sorting.py:sort_keyis@cached per class name and built from a precomputed component table (_COMPONENTS/_component_ranks) instead of the string-dispatching_component_rank()chain._refresh_tables()rebuilds the table and drops the cache through the existingregister_cache_clearerhook._split_top_level()falls back tostr.split()when a class contains no[or(, which is equivalent and about ten times faster. The separateparse_classcache is gone: a class is parsed exactly once when its key is first built.process.py:_sort_class_string()caches the sorted form of whole attribute values (lru_cache(maxsize=8192)), returningNonefor values with template expressions so they are left alone.files.py:as_completed(futures)without the timeout;wcmatch,difflib,rich.syntaxandrich.paddingare imported where they are used.cli.py: stdin mode runs before any console exists;importlib.metadatais imported inside the--versioncallback.__init__.py:__version__is resolved lazily through a module__getattr__.CHANGELOG.md: two Performance entries under Changed, one Reliability entry under Fixed.Verification
ruff checkandruff format --checkare clean.process_text()output before and after, compared by SHA-256.supports-[display:grid]:grid,bg-[url(/img/bg.png)],!-mt-4,w-1/2) sort identically under seven configurations: default, reversedcomponent_order, a partial order containing an unknown name, a duplicated entry, custom colors, a two-character variant separator, and no skip expressions.tailwhip.__version__still resolves and still appears throughfrom tailwhip import *.Finding 1: the hot path read its lookup tables through Dynaconf
Sorting a class means tokenizing it and asking, per token, whether it is a direction, size, numeric value, color or shade. Each question was a membership test against a dict stored on the Dynaconf settings object, and Dynaconf 3.3 wraps any dict stored on it in its
DataDictnode type:DataDict.__contains__does not do a hash lookup. It materializes a tuple of every key, scans it, and on a miss lowercases each key twice looking for a case-insensitive match. Misses are the normal case: a token like500is tested against the direction, size and value tables before it reaches the shade table.'x' in plain_dictplain_dict.get(missing)config.prefix_indexattribute read'x' in DataDict, key present'x' in DataDict, key missingDataDict.get(missing)config.prefix_indexattribute read with dynaconf 3.2.12 (locked by 0.13.0)Profile of the steady-state run (sorted corpus, sequential, cProfile, 10.33 s profiled for 2.35 s real):
dynaconf find_the_correct_casingstr.lowerstr.replacesorting._component_ranksorting.sort_keydynaconf LazySettings.__getattr__DataDict.__contains__re.Pattern.sub(the regex scan)io.openThe dependency bump is recent (0.13.0 locked dynaconf 3.2.12, 0.14.0 moved to 3.3.5), but it is not a regression story: with 3.2.12 membership tests are fast and every attribute read costs 48.6 µs instead, and the same corpus takes a projected 89 seconds. Reading configuration inside the per-token loop was the design flaw; the Dynaconf version only decided which operation paid for it.
After: the
LookupsobjectFinding 2: every occurrence was sorted from scratch
Parsing was cached, but
sort_key()was not. Warm, it cost 5.2 µs per class and ran 255,722 times for the sorted corpus, which contains 12,783 distinct classes. Whole attribute values repeat too. The synthetic corpus, built from random combinations, is the worst case at 5.6%; real projects on this machine repeat between 79% and 99.9% of their class attribute strings:Before and after:
sort_keyBefore, per occurrence:
After, once per distinct class:
And the attribute-level cache in
process.py:Finding 3: a 60-second limit on the whole run
as_completed(futures, timeout=60)limits the total time from the first call, not the time per file. Any run longer than 60 seconds ended with aTimeoutErrorand a traceback, with files left half processed in write mode. Reproduction:I hit it on the corpus while measuring with dynaconf 3.2.12 installed:
TimeoutError: 461 (of 1650) futures unfinished.The pool itself does not speed anything up. The work is regex matching and Python-level sorting under the GIL, so wall time equals CPU time with or without threads, and after this change the pool is measurably slower than a plain loop:
This PR only removes the timeout; replacing the pool is a follow-up because it also changes the order in which files are reported.
Finding 4: startup is the whole cost of a single-file run
Piping one small file through tailwhip took 88 ms, 18 ms of which is the interpreter. The rest was imports and module-level work:
importlib.metadata.version()ran at import time in__init__.pyalthough only--versionneeds it, and Rich, wcmatch, difflib and the diff renderer were imported although stdin mode never prints through Rich or scans a directory. Typer does not import Rich itself, so deferring it is a real saving.create_console()--versionand__getattr__find_files()Result over a bare interpreter: stdin 70 → 50 ms,
--version74 → 54 ms,import tailwhip68 → 48 ms. What remains is Dynaconf and Typer.Not changed, measured anyway
IGNORECASEand\bwould save a third but changes which attributes match.console.printof a "Would update" line costs about 90 µs (60 µs withhighlight=False). For the 1,636 lines of a corpus dry run that is 0.15 to 0.25 s, now roughly 40% of the run._rebuild_lookups()takes 0.7 ms and runs twice per start; it only looks expensive under cProfile.benchmark.pyreports the cached path after its first iteration (median 2.4 ms, maximum 27 ms); it is a regression guard, not a cold-run measure.Follow-ups, in order of value
find_files()walks the tree once per default glob and descends into.git,node_modules, virtualenvs and coverage reports; on Project C that is 4.4 s of the remaining 6.6 s, andtailwhip . --writeat a project root rewrites vendored CSS. wcmatch'sexcludeparameter filters after the walk and does not help. A prunedos.scandirwalk with a compiled wcmatch matcher takes 418 ms there (71 ms with a plain suffix check) instead of 4,190 ms. This changes which files are touched, so it is a separate decision.highlight=Falseon the per-file lines, as the "Already sorted" line already does: a third of the output cost.