Pure-Python Less CSS → CSS compiler
Project description
lessish
A pure-Python compiler for the Less CSS
language. Zero runtime dependencies — stdlib only. Oracle-tested
against the upstream less.js v4.6.3 corpus, byte-for-byte parity on
the in-scope fixture suite.
Install
pip install lessish
Why
LESS is wonderful. Since it landed, writing plain CSS feels like a step backwards — nesting alone is worth the price of admission. But for a Python project, dragging in a Node toolchain just to compile a few hundred lines of CSS is overkill.
For years lesscpy had us covered. It just worked: you stayed in Python, you wrote LESS, you got CSS. Beautiful.
Then LLMs happened.
Suddenly the budget for CSS-per-feature went up. Where a project used to ship a couple of plain layouts, an LLM can now comfortably produce a polished UI — mixins, nested selectors, color math, the works. Turns out lesscpy only covers a chunk of LESS, and every time the model hits a feature lesscpy stumbles on, it burns tokens contorting around the limitation.
OK, so: study the LESS surface, build test datasets, sketch out something resembling a spec, and write a compiler that follows less.js as closely as practical.
lessish isn't a successor to lesscpy. It's not trying to be an independent LESS implementation — its goal is to mirror upstream less.js faithfully, so the LLM doesn't have to know it isn't running on Node.
Speed? It's about as slow as lesscpy — up to ~3× slower than less.js. Turns out that's a non-issue: even big real-world projects compile in well under a second.
A second goal was a clean embedding API. lesscpy made this painful; lessish does it properly — you can wire LESS compilation into a Python tool without contortions.
One thing cut on purpose: JavaScript execution and plugins. JS-in-CSS is an obvious RCE story and stays out. Python plugins could in principle exist, but who's going to write LESS plugins in Python? Nobody. So: skipped.
Status
Active development (pre-1.0). Byte-for-byte parity with lessc on
every in-scope upstream fixture and across 11 curated real-world LESS
codebases (Bootstrap v3, UIkit, AdminLTE, Font Awesome v4/v5, WeUI,
intro.js, Hover.css, Milligram, normalize.less, toastr).
Conformance lives in a separate repo and is not pulled into this package.
Use
from lessish import Lessish
ls = Lessish()
css = ls.compile('@c: red; .a { color: @c; }')
# → '.a {\n color: red;\n}\n'
Lower-level entry points (lexer / parser only) live on the same instance:
source = '@c: red; .a { color: @c; }'
tokens = ls.tokenize(source) # TokenStream (iterable / len / index of Token)
ast = ls.parse(source) # Ruleset (structural, values still raw text)
ast = ls.parse(tokens) # skip the lex pass — reuse the stream above
Useful for tooling — editors, linters, language servers — that wants
a position-aware tokenization or AST without paying for full compile.
parse() accepts the TokenStream returned by tokenize() so a
language server that's already tokenised for syntax highlighting
doesn't pay the regex cost twice.
API
Lessish is the only top-level class. compile() runs the full
pipeline; each stage is also a public method you can call alone or chain
— useful for tooling that needs just one:
| Method | Stage | Input → Output |
|---|---|---|
tokenize(source) |
lex | str → TokenStream |
parse(source) |
parse | str → Ruleset (AST, declaration values still raw text) |
evaluate(root, src=…) |
eval | Ruleset → Ruleset (@import, variables, mixins, :extend, at-rule bubbling) |
emit(root, src=…) |
emit | Ruleset → CSS string (or SourceMapResult) |
compile(source) |
parse + eval + emit | str → CSS string |
compile_with_source_map(source) |
+ source map | str → SourceMapResult |
Each stage is overridable on a subclass (compile() honours the
override), and per-compile state is never held on the instance, so
independent or parallel compiles never race. Subclassing, the
copy_input reuse flag, and thread-safety are covered in the
embedding guide.
Options
Constructor stores defaults; per-call kwargs override (names mirror
less.js, snake-cased). The full set — compress, math, source maps,
the file_io sandbox, the DoS budgets (mixin count/output/input size,
plus an evaluation-time wall-clock deadline), and the opt-in hardening switches
(disabled_functions / RESTRICTED_FUNCTIONS, neutralize_escape) for
untrusted input — lives in the options guide.
A plain Lessish() mirrors less.js and is not the configuration for
untrusted input: its file_io='jail' still reads files under the working
directory, escapes are emitted raw, and replace/range are enabled. For
Less you do not control, use Lessish.hardened() — it bundles every safety
switch in one constructor (file_io='deny', restricted functions, escape
neutralisation, output/input ceilings, and an evaluation time budget) and
locks those security options against per-call weakening:
from lessish import Lessish
ls = Lessish.hardened()
css = ls.compile('@c: red; .a { color: @c; }')
# => '.a {\n color: red;\n}\n'
Errors
All exceptions inherit from LessError and carry a .location
(SourceLocation(filename, line, column, index)) plus a .snippet
with a caret indicator:
ParseError— lexer / parser refused the input.EvalError(and subclasses) —UndefinedNameError,OperationError,TypeMismatchError,ArgumentError.UnsupportedFeatureError— see Out of scope.FileError—@importtarget not found.SecurityError—file_iopolicy refused a read (see the options guide).
Linter & formatter
lessish ships with a Less linter and code formatter, available as
lessish lint and lessish format on the CLI plus a programmatic
API:
lessish format src/**/*.less # canonical multi-line layout in place
lessish lint src/**/*.less # diagnostics; exit 1 on findings
lessish lint --fix src/**/*.less # apply safe autofixes
45 rules across four safety tiers. Cache is on by default — re-runs
on unchanged files are typically 5–9× faster. Configurable via
[tool.lessish.lint] in pyproject.toml.
Full rule catalog, configuration options, inline directives, and programmatic API: see the linter guide.
Documentation
Full docs live in docs/. They cover the parts specific
to lessish — they don't re-document the Less language itself (for that,
lesscss.org is the reference; lessish mirrors
less.js). Three pillars:
- Embedding guide — the
LessishAPI, options, source maps, the addressable pipeline, and errors. - Linter & formatter — rules, configuration, inline directives, and the programmatic API.
- CLI reference —
compile,lint,format.
Plus the design rationale. Every example in the docs
is executable and checked by docs/check_examples.py.
Out of scope
Two things lessish deliberately does not run:
- JavaScript execution —
`…`backtick expressions evaluate arbitrary JS in the less.js implementation. lessish doesn't run JS. @plugin "…"Node plugins — they execute arbitrary code at compile time. Same reason. A Python-plugin shim was considered and rejected — nobody writes LESS plugins in Python.
These constructs lex and parse fine — the lexer emits the
appropriate token kinds, and the parser builds an AST without
complaining. Rejection happens at evaluate() time as a
UnsupportedFeatureError (subclass of LessError). This means
tokenize() and parse() stay usable on input that contains them —
handy for editors, syntax highlighters, and linters that want a full
token / AST view without enforcing the runtime restrictions.
Also out: remote @import url(http…) (SSRF-shaped; same
UnsupportedFeatureError at eval time) and dumpLineNumbers
debug-comment output (a legacy lessc-CLI feature, superseded by
source maps — never emitted).
Anything else from the upstream less.js surface that lessish
doesn't yet handle is a bug — please file an issue with a minimal
repro.
License
Apache-2.0. See LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file lessish-0.1.0.tar.gz.
File metadata
- Download URL: lessish-0.1.0.tar.gz
- Upload date:
- Size: 309.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.10.0 {"installer":{"name":"uv","version":"0.10.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Linux Mint","version":"22.2","id":"zara","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8e1c7429c1914f912a4c41066b8221b90c4748e1258f98fd3763641cecc8ba57
|
|
| MD5 |
77111cf9ec9fd0619572f769b0006317
|
|
| BLAKE2b-256 |
e75a18edbed2236334e79f947ea7512cbd96fe9fe163154cd7ceaf9257b63c6b
|
File details
Details for the file lessish-0.1.0-py3-none-any.whl.
File metadata
- Download URL: lessish-0.1.0-py3-none-any.whl
- Upload date:
- Size: 370.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.10.0 {"installer":{"name":"uv","version":"0.10.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Linux Mint","version":"22.2","id":"zara","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6e55199df221b35009e0db70c2e480b549e878723e6f41ab0fbf891540d64027
|
|
| MD5 |
970d79bd4e950531bdf85e34bff9fe0f
|
|
| BLAKE2b-256 |
2e661d38d0356dbc835b0237f6a24486bcd369d0a44aca5811d20d566a1239bb
|