Skip to main content

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 RulesetRuleset (@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@import target not found.
  • SecurityErrorfile_io policy 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:

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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

lessish-0.1.0.tar.gz (309.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

lessish-0.1.0-py3-none-any.whl (370.8 kB view details)

Uploaded Python 3

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

Hashes for lessish-0.1.0.tar.gz
Algorithm Hash digest
SHA256 8e1c7429c1914f912a4c41066b8221b90c4748e1258f98fd3763641cecc8ba57
MD5 77111cf9ec9fd0619572f769b0006317
BLAKE2b-256 e75a18edbed2236334e79f947ea7512cbd96fe9fe163154cd7ceaf9257b63c6b

See more details on using hashes here.

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

Hashes for lessish-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 6e55199df221b35009e0db70c2e480b549e878723e6f41ab0fbf891540d64027
MD5 970d79bd4e950531bdf85e34bff9fe0f
BLAKE2b-256 2e661d38d0356dbc835b0237f6a24486bcd369d0a44aca5811d20d566a1239bb

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page