optics-framework-lsp
A language server and linter for optics-framework test suites.
An optics suite is CSV or YAML files referring to each other by name, and nothing checks that those names line up. This does, across the whole project and across both formats, over LSP while you type or as one command. Its rules come from optics-framework's own readers, so it agrees with the runtime.
Install
uv tool install optics-framework-lsp
Or from a checkout, for development:
uv sync
In an editor
Two packaged clients bundle the server's dependencies, so neither needs the steps above:
- VS Code —
editors/code - IntelliJ, PyCharm and other JetBrains IDEs —
editors/intellij
Both need a Python 3.12+ interpreter and attach to every *.csv, *.yaml and *.yml in the
project without claiming those extensions, so whatever editor you already use for them is
untouched. A file that is not a suite gets nothing back.
The linter
optics-lsp lint reports every problem in a suite in one shot. Point it at a project:
$ optics-lsp lint ~/projects/my_project
Line numbers are 1-based, matching your editor's status bar. A whole project takes about 60 ms including process start, because nothing on this path imports any LSP machinery.
The exit code is 0 whenever validation ran, findings or not. Non-zero means the input could
not be read at all. Which severities should block a pipeline is your policy, so read status
if you want that decision made for you.
JSON, for another program
Add --json for the machine-readable form. A caller holding uploaded files in memory, with
nothing on disk, can pipe them in instead and skip the filesystem entirely:
$ echo '{"files": [{"name": "modules.csv", "content": "module_name,module_step\nM,Sleep\n"}]}' \
| optics-lsp lint
Reading on stdin always answers JSON:
{
"status": "FAIL",
"analyzed": { "modules.csv": "modules" },
"skipped": [],
"diagnostics": [
{
"uri": "modules.csv",
"severity": "error",
"code": "keyword-arity",
"message": "'Sleep' takes 1-1 params, got 0",
"row": 2,
"range": { "startLine": 1, "startColumn": 0, "endLine": 2, "endColumn": 0 },
"source": "optics"
}
]
}
row is 1-based for a person, range is 0-based for an editor. Diagnostics arrive sorted by
file then row, so a caller need not re-sort.
Shelling out from Node: pass the arguments as an array with the payload on stdin rather than
interpolating either into a shell string, and raise maxBuffer past its 1 MB default.
analyzed and skipped: which files were even looked at
File kind comes from the contents, not the filename: a csv's header row, a yaml's top-level
keys. analyzed says what each file became (test_cases, modules, elements,
error_definitions, and for a yaml holding several, all of them); skipped lists the files that
matched none.
optics ignores those files too, so a skipped file is usually a dataset and fine. But a
test_cases file with a typo in its header lands there as well, and would otherwise be
indistinguishable from a clean one.
What it reports
Two severities, and the line between them is one question: does optics fail, or does it run and do something you probably didn't mean?
Errors are a name that resolves to nothing, or a call the keyword rejects:
| code | meaning |
|---|---|
module-not-found |
a test step names a module no file defines |
keyword-not-found |
a module step is neither a keyword nor another module |
keyword-arity |
a keyword is missing a required param, or got more than it takes |
keyword-param-repeated |
a name=value param given twice, or given by position as well |
element-not-found |
a ${ref} has no element and nothing binds it |
Warnings mean it loads, but a row you wrote isn't doing what it looks like:
| code | meaning |
|---|---|
duplicate-module, duplicate-test-case |
the later definition wins, wherever the files sit |
duplicate-element |
a file repeats an entire element row |
duplicate-error-code, duplicate-match-string |
error definitions merge across files, so these clash |
error-definition-incomplete |
a row missing a column, so it never matches |
csv-too-few-columns |
the reader skips the row |
csv-too-many-columns |
the extra cells are dropped |
csv-whitespace-line |
a row of nothing but separators |
YAML is a peer of CSV at run time but a rough one, and every way it goes wrong goes wrong silently — the reader logs and carries on with an empty section, so the run fails somewhere that says nothing about the cause. These are errors:
| code | meaning |
|---|---|
yaml-section-key-case |
test_cases: where the reader looks up Test Cases, so the section loads empty |
yaml-section-shape |
Test Cases or Modules as a mapping rather than a list of single-key mappings, which aborts the run |
yaml-step-not-a-string |
anything else as a step, which aborts the run |
yaml-parse-error |
malformed yaml, which optics reads as an empty file |
yaml-error-definitions-unread |
a warning: error definitions have no yaml form, only csv |
The language server
Run with no arguments and it speaks LSP over stdio. Unknown flags are ignored rather than
rejected, so a client that insists on passing --stdio needs no special handling.
One workspace folder is one project. Cross-file rules mean a single edit can change
diagnostics anywhere in the suite, so the whole folder is revalidated on every change, and the
server asks the client to watch **/*.{csv,yaml,yml} so a git checkout is picked up too. Open
buffers override what is on disk. Dot folders are never descended into, because a project's
.venv holds optics-framework's own sample suites and images, which would invent element and
module names the project does not have.
Features
Diagnostics
Everything from the linter section above, live, across the whole project. Because resolution
is a project-wide question, defining an element in one file clears the element-not-found in
another without touching it.
Completion
Aware of where you are, because in a CSV the column is the context — and in a YAML the section and the depth are.
| where the cursor is | what is offered |
|---|---|
| an empty file | the four header rows, or the three yaml section keys, since either decides the file's kind |
module_step, or a yaml Modules step |
every keyword and every module in the project, since a step can be either |
| a param cell | what that particular keyword accepts at that position |
test_step, module_name |
modules that already exist, since both columns continue a block |
element_name |
names used somewhere but never defined |
any element_id*, or a yaml locator |
template image filenames found anywhere in the project |
test_case |
existing test cases, plus the lifecycle names (Suite Setup, …) not yet used |
In a YAML the section and the indent stand in for the column, and a step's params are the words
after the keyword rather than cells of their own. A half-typed multi-word keyword is replaced
whole, so Press El completes to Press Element rather than nesting.
Param completion is specific rather than generic. Read Data's second param offers the
project's data files; Invoke API's first offers collection.api identifiers parsed out of
the project's YAML; Run Loop and Execute Module offer modules, written bare. Params with a
documented set of values offer exactly those. direction gives up, down, left, right;
rule gives any, all; element_state gives visible, invisible, enabled, disabled.
Condition alternates condition and target, so it offers modules in the target slots and
modules-or-variables in the others, with ! inversion preserved.
Accepting a param the header does not yet cover widens the header in the same edit, because
csv.DictReader drops cells the header does not name and the param would otherwise silently
vanish. A YAML has no header to widen.
Hover
A keyword's real signature and the framework's own docstring, which is where the accepted values are actually written:
Press Element(element, repeat='1', offset_x='0', offset_y='0', index='0',
aoi_x='0', aoi_y='0', aoi_width='100', aoi_height='100', event_name=None)
Press a specified element.
:param element: The element to be pressed (text, xpath or image).
:param repeat: Number of times to repeat the press.
:param index: Index of the element if multiple matches are found.
:param aoi_width: Width percentage of Area of Interest (0-100). Default: 100.
Defaults are rendered as name='value', so what an omitted cell falls back to is visible
without reading the source.
Signature help
The keyword's params with the column you are in marked active, retriggering on every comma, which is how you tell param 4 from param 5 in a row of commas.
Goto definition
From a step to the module it runs, or from a ${ref} to the elements it reads. Every ${name}
in the cell is offered, not just one: a fallback element is several rows, and so is
${a} == ${b}.
Two details it gets right. A module_step naming something that is also a keyword resolves
to the keyword, so a same-named module is not offered, mirroring what the runner does. And
Condition's !Name inversion is stripped the same way the runner strips it.
Find references
Every place a name is used across the project, optionally including where it is bound. Works on test cases, modules, elements and error codes, the things the project owns. A keyword belongs to the framework, and an image, data file or API identifier names something outside the CSVs, so neither comes back from here.
Rename
Renames across every file that writes the name, with prepare-rename so the client can reject an invalid position before you start typing. This is the feature that most needs to be project-wide: the runner keys everything by name, so a rename that misses one cell quietly changes what runs instead of failing loudly.
Document symbols
One file's outline: what it defines, and the rows making each one up:
Open App [Function] 2 steps
Launch App [Method]
Sleep [Method] 2
Login [Function] 1 step
Press Element [Method] ${btn}
Workspace symbols
Every name the project declares, searchable from anywhere — "Go to Symbol in Workspace" in
VS Code, workspace/symbol on the wire. Declarations only: a module's steps and an element's
locators belong to the file outline, and repeating them here would bury the handful of names
being searched for under every row that mentions them.
| kind | what it is |
|---|---|
Class |
a test case |
Function |
a module |
Variable |
an element, once, however many locator rows it has |
Constant |
an error code |
The match is a case-insensitive substring, and each result carries the kind its file's contents
made it (modules, test_cases, …) as the container, since that is the only thing telling a
module from a test case of the same name.
Semantic tokens
Highlighting neither grammar can express, because the meaning of a piece of a suite depends on where it sits and on the rest of the project:
| token | what it marks |
|---|---|
keyword |
the header row, or a yaml's section keys: the names that decide what the file is |
class |
a test case |
function |
a module, wherever it is named |
method |
a step that resolves to a framework keyword |
variable |
${name}, and an element_name that defines one |
string |
a locator, a data file, an API identifier |
enumMember |
an error code, and a param with a documented set of values |
operator |
the ! that inverts a Condition |
A framework keyword also carries the defaultLibrary modifier, so themes colour it apart from
a module of the same shape, which they would otherwise draw identically.
AI agents
Both editor clients offer the server over MCP, so an agent can
ask it about the whole project rather than only the open files. Automatic in VS Code; Tools |
Configure Optics LSP MCP Server in JetBrains IDEs. See
editors/code and editors/intellij.
For anything with a shell, optics-lsp lint . --json needs no bridge at all.
Native scripts
optics-lsp generate rewrites a suite as a script that drives the device itself, with no
optics-framework at run time:
$ optics-lsp generate ~/projects/my_project > suite.py
17 steps did not translate
$ python suite.py
PASS test_sign_in
Two targets: uiautomator2 (the default, python) and --target xcuitest (swift). The
script goes to stdout and the report of what did not come across to stderr, so the redirect
above leaves a runnable file. --json answers both together.
It is a different product, not a faster optics. An element's fallback locators do come across and are tried in the suite's order, but once every one of them misses the step fails, because what the framework does next — ocr, image templates, self-heal — is not there. That is what the report is for. Every step it could not translate is named with its file, its row and the reason, so a partial translation is never mistaken for a whole one.
One case writes no script at all: a step using an element none of whose locators the target can carry. Leaving that step out would give a script that passes while testing less than the suite does, so the command prints the report, writes nothing to stdout and exits 1. An element nothing uses is reported and does not stop it.
A test asserts that every keyword in the catalog is either translated or explained, per target, so a keyword a new optics release adds cannot be quietly missed.
What the two targets can express
| keywords translated | refused | |
|---|---|---|
| uiautomator2 | 39 | 12 |
| xcuitest | 37 | 14 |
press keycode and get app version are the two the android target has and the ios one
does not: android keycodes have no ios concept, and a ui test runs in its own process and
cannot read the app's bundle.
Locators differ more than keywords do. uiautomator2 takes an xpath, so a locator is passed through and resolved on the device. XCUITest has no xpath — Appium appears to offer one because WebDriverAgent builds its own element tree, and XCTest itself has only element queries. No query is a translation of an xpath either: an xpath index counts within each parent where a query flattens every match into one list, and the tree a query walks is not the tree a page source dumps.
So the ios target does not translate a locator. It carries it into the file as text and
evaluates it there, against a snapshot of the live hierarchy — the tree it was written for —
then walks the match back to an element by its chain of child indexes. Everything an xpath
can say it can say: last(), a predicate mid-path, contains(), a grouped index. What is
refused is refused at generation time, so it is named in the report: a locator kind with no
native form (an image, a css selector, text_only:, id:), and a path naming a type or
attribute iOS does not have, which is how an android locator in an ios suite is caught rather
than silently matching nothing. A refused locator drops out of its element on its own; the
element is refused only when none of its locators is left.
The keyword catalog
src/optics_framework_lsp/keywords.py is generated and holds 49 keywords from
optics-framework 1.9.3: each one's params, which are required, the defaults, and the
framework's own docstring, which is where hover text and the param values come from.
It is compiled in rather than read from your environment. Importing optics_framework.api.*
pulls numpy, cv2, pandas and skimage through common.optics_builder, needing a 342 MB install
and failing outright if any one of those is broken, leaving a project with no keyword
diagnostics at all and no explanation why.
To refresh it for a new optics release:
python scripts/update_catalog.py ~/src/optics-framework
python scripts/update_catalog.py --check # exits 1 if the committed table is stale
Signatures are read with ast, never imported, which is what lets the script run against a
bare checkout or a --no-deps install and makes it usable for diffing two releases. It refuses
to run if one of the four API classes gains a base class, since that would mean keywords it
cannot see. A test runs --check, so a stale table fails the suite.
Development
uv run pytest tests/ -q
One test needs optics-framework itself and skips without it; point OPTICS_PROBE_VENV at a
virtualenv that has it to run that one.
The engine is deliberately protocol-free. validate() returns plain Finding objects and each
transport converts at its own boundary: server.py to lsprotocol diagnostics, lint.py to
JSON. import lsprotocol.types alone costs ~290 ms, which a long-lived server pays once and a
per-call command would pay every time. tests/test_lint.py asserts the lint path imports
neither pygls nor lsprotocol, so it stays that way.
License
Apache 2.0.
Metadata
Release files for optics-framework-lsp 1.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| optics_framework_lsp-1.4.0.tar.gz | 150.1 kB | With provenance | Details for optics_framework_lsp-1.4.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| optics_framework_lsp-1.4.0-py3-none-any.whl | Python 3 | none | any | Details for optics_framework_lsp-1.4.0-py3-none-any.whl |
Total release size: 317.8 kB
Release files / optics_framework_lsp-1.4.0.tar.gz
| Download URL | optics_framework_lsp-1.4.0.tar.gz |
|---|---|
| Size | 150.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
17fad65f4eca489257ccdcc54ecc001c2ce7609102a3bd64202ab41d1b83abbe
|
|
BLAKE2b-256 checksum How to use checksums |
a0e79e8a0941a80def57aa0d62265ac77606a6762d33359c8c49e15ad88eb654
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 26, 2026.
Transparency logRelease files / optics_framework_lsp-1.4.0-py3-none-any.whl
| Download URL | optics_framework_lsp-1.4.0-py3-none-any.whl |
|---|---|
| Size | 167.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
66734412a62acf1bdf0a169c5e3981bcc482f0fe817bedb47d7e9293868e5e6d
|
|
BLAKE2b-256 checksum How to use checksums |
0131d27d6534cdfdec651c5e315b3d6c8b0773e9b16c6f54852b975502b0c503
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 26, 2026.
Transparency logSecurity for optics-framework-lsp 1.4.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for optics_framework_lsp-1.4.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 26, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.