Skip to main content

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 _id accessors, pk, reverse-relation accessors — with __-traversal into related models. Suggestions insert as name= so the caret lands at the value. At a recognised call site we claim exclusivity, so ty's "any local variable near em" noise stays out of the list.
  • Typo diagnostics ty can't see. django-unknown-orm-lookup warnings 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_related strings, Q(...) / F('...') expressions — full __-traversal through relations and reverse relations.
  • No more Item.objects false positives. ty's unresolved-attribute diagnostics 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 unused nags on views. Django view functions take request whether they read it or not — ty's hint is dropped when request is the first parameter (or first after self/cls on a class-based view). Other unused params still flag, and an unused local request variable still flags.
  • Built-in models + abstract inheritance. django.contrib.auth / contenttypes / sessions models are stubbed so they work out of the box, and abstract-base fields propagate to concrete subclasses (so a custom User(AbstractUser) resolves email / username / etc.).

iommi

  • Refinable autocomplete inside Class(kw__chain=...) calls. Table(c‸ (where is the cursor) suggests columns__, cell__, query__, …; containers get a trailing __ and scalars get =. Chains walk the iommi refinable graph, so Table(columns__name__‸ offers the configurable surface of Column.
  • auto__ namespace. Always surfaces model / rows / instance / include / exclude whether or not the graph reflects it, since iommi's default Namespace() is empty.
  • Django field bridging. Table(auto__model=User, columns__‸) suggests User's fields (insert as username__, email__, … so you can keep configuring the auto-generated column). The same works inside auto__include=['‸'] / auto__exclude=['‸'] string literals.
  • iommi-unknown-refinable diagnostics. Invalid chains in Class(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:

  1. In-process if iommi is importable from iommi_lsp's interpreter (i.e. installed alongside it: uv tool install --with iommi iommi_lsp).
  2. Subprocess against the workspace's .venv / venv Python, when iommi_lsp is installed there too.
  3. Synthesized stubs for the well-known iommi classes (Table, Form, Query, Page) as a last resort — enough that auto__… 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 — the attrs special with class (str→bool) and style (str→str) sub-namespaces
  • class_ref — chain steps into another refinable class (annotation wins over runtime default, so bulk: Optional[Form] resolves to Form)
  • traditional_class — steps into a non-refinable class whose configurable surface is its __init__'s self.X = … assignments (e.g. Column.cell / Table.cell configuring a Cell instance)
  • namespace — structured with a small set of known sub-keys
  • open_namespace — empty Namespace default; any keys allowed
  • evaluated_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 namesTable(c‸ suggests columns__, cell__, query__, … with container refinables getting a trailing __ and scalars getting =.
  • auto__ namespace — synthesised as a known namespace with model / rows / instance / include / exclude, even when the reflected graph records auto as an open namespace.
  • Django field names inside auto__include=[...] / auto__exclude=[...] string literals, and after columns__ / fields__ / filters__ / parts__ when the call carries auto__model=Model (or auto__rows=Model.objects.…). This is the bridge that turns Table(auto__model=User, columns__‸) into a member-name list drawn from the User model'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:

  1. Read the file at the diagnostic's range and find the <receiver>.<attr> AST node.
  2. Resolve the receiver type by syntactic match (User.objects) or same-function local flow (u = User.objects.get(...); u.pk).
  3. 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 nested Q(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 run iommi_lsp graph build.
  • No type-checker arbitrage. This proxies one backend at a time; you still pick ty (or eventually mypy / pyright once those backends are wired in).
  • Astral may absorb this. If/when ty ships 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


Download files

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

Source Distribution

iommi_lsp-0.0.1.tar.gz (110.2 kB view details)

Uploaded Source

Built Distribution

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

iommi_lsp-0.0.1-py3-none-any.whl (72.8 kB view details)

Uploaded Python 3

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

Hashes for iommi_lsp-0.0.1.tar.gz
Algorithm Hash digest
SHA256 3d7cff8739535a6d8cd3f788b95e9974a0e8193e3b1d572d1d0147e21a888a98
MD5 60b8e727d07f21030d85986c61dd0c9a
BLAKE2b-256 0966e08527f66f75f4c97bda24f3fa0539ba9b395893fd8ebd259060e6d9644b

See more details on using hashes here.

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

Hashes for iommi_lsp-0.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 368e7dc83358f69cb2f066023e0fe45f28ffa5112ba2f12ccf8bce49479f3542
MD5 e34de16991af434b4e0fea70a605c0be
BLAKE2b-256 fb6a5de510579af4843be25bdaa6b4136e90f46ba50dca646e11da0339e609e7

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