Wrapper LSP that proxies ty and filters Django/iommi false-positive diagnostics.
Project description
iommi_lsp
A Django and iommi language server that proxies to
ty for broad Python support.
What you get
Django
- Real autocomplete for ORM kwargs. Inside
User.objects.filter(‸,.exclude(‸,.get(‸,.update(‸,.create(‸,.get_or_create(‸,.update_or_create(‸(where‸is the cursor) you get the model's queryable names — declared fields, FK_idaccessors,pk, reverse-relation accessors — with__-traversal into related models. Suggestions insert asname=so the caret lands at the value. At a recognised call site we claim exclusivity, so ty's "any local variable nearem" noise stays out of the list. - Typo diagnostics ty can't see.
django-unknown-orm-lookupwarnings fire on kwargs and string field paths that don't resolve against the workspace model index. Covers.filter(...)/.exclude(...)/.get(...)and friends,order_by/values/values_list/only/defer/distinct/select_related/prefetch_relatedstrings,Q(...)/F('...')expressions — full__-traversal through relations and reverse relations. - No more
Item.objectsfalse positives. ty'sunresolved-attributediagnostics on Django metaclass magic (objects,_meta,pk/id,<fk>_id, reverse relations,DoesNotExist, …) are dropped before they reach the editor. Real bugs survive. - No more
`request` is unusednags on views. Django view functions takerequestwhether they read it or not — ty's hint is dropped whenrequestis the first parameter (or first afterself/clson a class-based view). Other unused params still flag, and an unused localrequestvariable still flags. - Built-in models + abstract inheritance.
django.contrib.auth/contenttypes/sessionsmodels are stubbed so they work out of the box, and abstract-base fields propagate to concrete subclasses (so a customUser(AbstractUser)resolvesemail/username/ etc.).
iommi
- Refinable autocomplete inside
Class(kw__chain=...)calls.Table(c‸(where‸is the cursor) suggestscolumns__,cell__,query__, …; containers get a trailing__and scalars get=. Chains walk the iommi refinable graph, soTable(columns__name__‸offers the configurable surface ofColumn. auto__namespace. Always surfacesmodel/rows/instance/include/excludewhether or not the graph reflects it, since iommi's defaultNamespace()is empty.- Django field bridging.
Table(auto__model=User, columns__‸)suggestsUser's fields (insert asusername__,email__, … so you can keep configuring the auto-generated column). The same works insideauto__include=['‸']/auto__exclude=['‸']string literals. iommi-unknown-refinablediagnostics. Invalid chains inClass(kw__chain=...)calls flag the first dead-end segment.- Zero-setup defaults. Synthesised stubs cover the public iommi
classes (
Table,Form,Query,Page) so all of the above works before any graph build succeeds; the project's own iommi subclasses light up once a real graph is built (automatically, in most setups — see below).
It speaks plain LSP, runs on stdio, and is configured into your editor
in place of ty server. See DESIGN.md for the
architecture.
Status
Pre-1.0. Pinned against a narrow ty range — bumps are gated by a
contract test suite (tests/test_contract_real_ty.py).
Install
uv tool install iommi_lsp # or: pipx install iommi_lsp
ty is a hard dependency and is installed alongside iommi_lsp into
the same environment, so the default just works — no editor-side
--ty-command plumbing required.
Run
iommi_lsp # spawns the bundled `ty server`
iommi_lsp --ty-command "uvx ty server" # override (e.g. pin a different ty)
iommi_lsp --workspace ./myproject # eager indexing for debugging
iommi_lsp index ./myproject # dump the Django model index and exit
iommi_lsp graph build ./myproject # reflect installed iommi -> .iommi_lsp-graph.json
For the iommi analyzer, the graph at .iommi_lsp-graph.json is built
automatically when the workspace is opened:
- In-process if
iommiis importable fromiommi_lsp's interpreter (i.e. installed alongside it:uv tool install --with iommi iommi_lsp). - Subprocess against the workspace's
.venv/venvPython, wheniommi_lspis installed there too. - Synthesized stubs for the well-known iommi classes (
Table,Form,Query,Page) as a last resort — enough thatauto__…and members-name completion still work before any graph build succeeds.
Running iommi_lsp graph build by hand is still supported and is the
fastest way to force a rebuild after upgrading iommi. The graph is a few
hundred KB JSON in your workspace root; check it in or .gitignore it
as you prefer.
iommi_lsp writes diagnostics-side stderr logs; tune via
IOMMI_LSP_LOG=DEBUG or --log-level DEBUG.
Editor configuration
Neovim (nvim-lspconfig style)
local lspconfig = require("lspconfig")
local configs = require("lspconfig.configs")
if not configs.iommi_lsp then
configs.iommi_lsp = {
default_config = {
cmd = { "iommi_lsp" },
filetypes = { "python" },
root_dir = lspconfig.util.root_pattern("pyproject.toml", ".git"),
single_file_support = false,
},
}
end
lspconfig.iommi_lsp.setup({})
Helix (languages.toml)
[language-server.iommi_lsp]
command = "iommi_lsp"
[[language]]
name = "python"
language-servers = ["iommi_lsp"]
Zed (settings.json under lsp)
{
"lsp": {
"iommi_lsp": {
"binary": { "path": "iommi_lsp" }
}
},
"languages": {
"Python": {
"language_servers": ["iommi_lsp"]
}
}
}
VS Code
There's no first-party VS Code extension yet. The simplest path is the
vscode-generic-lsp-client
pattern: install a generic LSP-client extension and point it at
iommi_lsp. A first-party extension is on the roadmap.
How the iommi analyzer works
iommi_lsp graph build (or the auto-build at startup) imports iommi
in your venv and walks each Refinable-declaring class (Table,
Column, Form, Field, …) transitively through class_ref and
members edges. Every refinable gets one of these kinds:
members— open dict of typed values (columns: Dict[str, Column])html_attrs— theattrsspecial withclass(str→bool) andstyle(str→str) sub-namespacesclass_ref— chain steps into another refinable class (annotation wins over runtime default, sobulk: Optional[Form]resolves to Form)traditional_class— steps into a non-refinable class whose configurable surface is its__init__'sself.X = …assignments (e.g.Column.cell/Table.cellconfiguring aCellinstance)namespace— structured with a small set of known sub-keysopen_namespace— empty Namespace default; any keys allowedevaluated_scalar/scalar— leaf; chain ends here
Diagnostics
At LSP time the analyzer finds every Class(kw__chain=...) call, splits
the kwarg name on __, and walks the chain through the graph. Dead
ends become iommi-unknown-refinable warnings pinned to the offending
segment. Bias is toward false negatives — if anything is ambiguous
(unknown root class, member with no typed value, custom user subclass
not in the graph), we pass silently.
Completions
At a recognised iommi-call kwarg position the LSP claims exclusivity: ty's free-form variable suggestions are dropped so you only see real refinables. Three flavours of completion fire from the same position:
- Refinable names —
Table(c‸suggestscolumns__,cell__,query__, … with container refinables getting a trailing__and scalars getting=. auto__namespace — synthesised as a known namespace withmodel/rows/instance/include/exclude, even when the reflected graph recordsautoas an open namespace.- Django field names inside
auto__include=[...]/auto__exclude=[...]string literals, and aftercolumns__/fields__/filters__/parts__when the call carriesauto__model=Model(orauto__rows=Model.objects.…). This is the bridge that turnsTable(auto__model=User, columns__‸)into a member-name list drawn from theUsermodel's fields.
Synthesised stubs cover Table, Form, Query, and Page so the
above all works before iommi_lsp graph build ever succeeds; the
project's own iommi subclasses light up once a real graph is available.
How the Django filter works
For each unresolved-attribute diagnostic from ty:
- Read the file at the diagnostic's range and find the
<receiver>.<attr>AST node. - Resolve the receiver type by syntactic match (
User.objects) or same-function local flow (u = User.objects.get(...); u.pk). - If the receiver is a known model from the workspace's AST-only Django
index and the attribute is metaclass-injected (
objects,_meta,pk/id,<fk>_id, a known reverse relation, etc.), drop. Otherwise forward unchanged.
Bias is explicitly toward false negatives — better to leak a bit of noise
than to suppress a real bug. The index is rebuilt incrementally on
didChange/didSave and never imports the user's code.
Unknown ORM field/lookup diagnostics
On top of subtracting ty's false positives, the Django analyzer emits its
own django-unknown-orm-lookup warnings when it spots a kwarg or string
path that does not resolve against the workspace index. The intent is to
catch typos that the type checker can't see — User.objects.filter(eemail='x')
is a valid Python call, but a SQL error at runtime.
Covered call shapes:
- kwargs on
filter/exclude/get/get_or_create/update_or_create/update/create, including__-traversal through relation fields and reverse relations; - string field paths in
order_by/values/values_list/only/defer/distinct/select_related/prefetch_related(order_by('-foo')strips the leading-;'?'is recognised); Q(field=…)/models.Q(…)reachable through|/&/~composition, including nestedQ(Q(…), …);F('field__path')anywhere in the call's args or kwargs.
Receivers we recognise (anything else is silent):
Model.objects.…and friends (_default_manager,_base_manager);pkg.Model.objects.…/myapp.models.Model.objects.…— the rightmost attribute segment is matched against the index by simple name, so it inherits the index's natural ambiguity protection (two models with the same simple name → silent);- local variables previously assigned from any of the above within the
same function (or at module scope), including chained reassignments
like
qs = User.objects.all(); qs = qs.filter(...); qs.filter(...).
Bias is the same as the subtractive filter — when the receiver is unknown, ambiguous, or comes from a parameter / queryset method we don't model, we say nothing rather than risk a false positive. Disable entirely with:
[tool.iommi_lsp]
disabled_rules = ["orm_lookup"]
ORM-kwarg completion
When the cursor is inside Model.objects.<lookup_method>(...) (the
same method set the diagnostic covers — filter/exclude/get/
update/create/get_or_create/update_or_create) and you've typed
the start of a kwarg name, the LSP suggests every queryable name on
the model: declared fields, FK _id accessors, the pk alias, and
reverse-relation accessors. Each suggestion inserts as name= so the
caret lands inside the value position.
Triggered by ( and , (and continuous-completion mode in most
editors). The receiver-resolution rules match the diagnostic path —
direct Model.objects.…, module-qualified pkg.Model.objects.…, and
local queryset variables (qs = User.objects.all(); qs.filter(em‸)).
At a recognised position we claim exclusivity: ty's items are
dropped from the response so the user doesn't see noise like em
matching any random local variable next to our email=. Empty +
exclusive is intentional — if the partial matches no field, the
editor shows nothing rather than back-filling with ty's free-form
name list. When the receiver doesn't resolve (qs.filter(em‸ where
we can't tell what qs is) we step back and let ty handle it. If
ty errored on the completion request we substitute our own response;
if ty responded normally we either replace (exclusive) or merge
(non-exclusive).
Built-in models and inheritance
The index bundles a static stub of the Django contrib models so projects
that import django.contrib.auth.models.User, Group, Permission,
django.contrib.contenttypes.models.ContentType, or
django.contrib.sessions.models.Session get validation without us
having to import site-packages. A workspace model with the same simple
name (e.g. a custom User via AUTH_USER_MODEL) shadows the builtin
during name resolution.
Abstract-base fields propagate to concrete subclasses. So
class User(AbstractUser): ... correctly resolves email /
username / etc., and your own class Timestamped(models.Model): class Meta: abstract = True lets a Book(Timestamped) filter on
created without a false positive.
Per-project configuration
Add a [tool.iommi_lsp] table to your pyproject.toml:
[tool.iommi_lsp]
enabled = true # master switch
disabled_rules = ["pk", "reverse"] # skip rule groups for this project
[tool.iommi_lsp.extra_magic_attrs]
manager = ["mongo", "search"] # treat these as Manager-like attrs
Recognised rule groups: manager, meta, pk, exception, fk_id,
reverse, orm_lookup, unused_request_param. Unknown groups in
disabled_rules are ignored with a stderr warning rather than silently
breaking the filter.
A missing or malformed pyproject.toml falls back to defaults; the
proxy never crashes on a bad config.
Caveats
- Pre-1.0 ty. Diagnostic codes and message text will change. The
contract suite (
tests/test_contract_real_ty.py) catches breakage when you bump ty. - iommi graph requires iommi to be importable somewhere. Either in
the same venv as
iommi_lsp, or in the workspace's.venv/venv. Without that, the synthesised stubs cover the public iommi classes but project-specific subclasses (and their refinables) stay invisible until you runiommi_lsp graph build. - No type-checker arbitrage. This proxies one backend at a time; you
still pick
ty(or eventuallymypy/pyrightonce those backends are wired in). - Astral may absorb this. If/when
tyships first-class library support, the Django filter becomes mostly redundant. The proxy and the iommi layer remain useful.
Development
uv venv
uv pip install -e ".[dev]"
uv run pytest
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 iommi_lsp-0.0.1.tar.gz.
File metadata
- Download URL: iommi_lsp-0.0.1.tar.gz
- Upload date:
- Size: 110.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.18 {"installer":{"name":"uv","version":"0.9.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"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 |
3d7cff8739535a6d8cd3f788b95e9974a0e8193e3b1d572d1d0147e21a888a98
|
|
| MD5 |
60b8e727d07f21030d85986c61dd0c9a
|
|
| BLAKE2b-256 |
0966e08527f66f75f4c97bda24f3fa0539ba9b395893fd8ebd259060e6d9644b
|
File details
Details for the file iommi_lsp-0.0.1-py3-none-any.whl.
File metadata
- Download URL: iommi_lsp-0.0.1-py3-none-any.whl
- Upload date:
- Size: 72.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.18 {"installer":{"name":"uv","version":"0.9.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"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 |
368e7dc83358f69cb2f066023e0fe45f28ffa5112ba2f12ccf8bce49479f3542
|
|
| MD5 |
e34de16991af434b4e0fea70a605c0be
|
|
| BLAKE2b-256 |
fb6a5de510579af4843be25bdaa6b4136e90f46ba50dca646e11da0339e609e7
|