Skip to content

editor support

the by language server backs the editor experience: completions, inlay hints, an outline, and the rest of the language-server protocol. this page covers the parts that are basedpython's own — the ones you would not find by guessing from python

postfix templates

writing . after an expression offers a set of templates that rewrite the whole expression rather than reading as attribute access. accepting .print on xs replaces xs.print with print(xs):

xs.print        # → print(xs)
template rewrites x into
.print print(x)
.not not x
.type type(x)
.repr repr(x)
.list list(x)
.par (x)
.return return x
.raise raise x
.if an if statement
.for a for loop
.match a match
.let .var a binding

the templates from .return down are statements, so they are only offered where the expression is the whole statement — f(xs.print) offers .print but not .for. the ones that open a suite or need a name to bind place the cursor for you, when your editor supports snippets

.let and .var spell basedpython bindings; the rest are valid python and are offered in .py files too

postfix await

.await is not a template — it is real syntax, so it completes as an ordinary word. it appears inside an async def, on an expression that can actually be awaited

completions

the entry point

at module level, main completes to the whole entry point definition, with async main beside it. once the module defines one, the name completes to it like any other

keywords

every keyword a statement may open with is offered wherever it is valid, whether it is spelled with one word or several. the single words are basedpython's own — extension, let, var, and init inside a class body — and a construct spelled with more than one keyword is offered whole: async def, data class, frozen data class, enum class, override def, static var, and the rest of the modifiers. the method modifiers only appear inside a class body, and async for / async with only inside an async def

they are offered at the start of a statement only — after async the plain def keyword completes the rest

overriding

in a class body, every superclass member the class does not define is offered as the whole header an override would be written with:

class B(A):
    override def greet(self, name: str) -> str:

object's members are left out

types

a type position offers the words basedpython spells types with — literal, final, dynamic, some, protocol, typeof — and drops the statement keywords, which never read there. a type parameter offers its modifiers before the name: in, out, in out, overlapping, reified

common aliases

a name that is conventionally an alias of a module completes as that module, in a file that has not imported it yet. np. offers what numpy has, and accepting one of those completions writes the import that binds the name:

import numpy as np

np.arange

the alias itself completes the same way — typing n offers np, and taking it writes import numpy as np

only a module the project actually has is offered, and only where the name is free: a file that binds np to something of its own means that, and gets nothing from numpy

the aliases are the ones the python ecosystem already writes — np, pd, plt, dt, and the rest. a project spells aliases of its own in its configuration, keyed by the alias:

[tool.ty.editor.common-aliases]
npt = "numpy.typing"

an alias configured under a name ty already knows replaces it

a name left unimported reports as unresolved, and the quick fix on that diagnostic writes the same import

these are auto-imports, so turning off ty.completions.autoImport turns them off too

unimported names

a name the file has not imported still completes past the dot. Asdf. offers the members of the Asdf an import would bind, and accepting one writes that import:

from mod import Asdf

Asdf.name

a module reads the same way — mod. offers what import mod reaches — and a longer chain follows every step, so mod.Asdf. lands on those same members. a package's submodules are among what it offers, and a submodule is imported from its package, since import pkg.sub binds pkg rather than the sub the file wrote:

from pkg import sub

sub.Asdf

a name that two modules each define of their own is two offers, one per module, each carrying its own import. one class that several modules re-export is a single offer, since the copies would differ only by an import you cannot see

a name the file already binds means what the file says it means and gets nothing, and a chain that starts from anything but a name — Asdf(). — has no name for an import to bind. as with any auto-import, the name has to be one you have begun to write: a bare mod. in a file that never imported mod offers nothing, because every symbol spelled mod anywhere would qualify

like the aliases above, these are auto-imports, and turning off ty.completions.autoImport turns them off too

names inside a string

a name written inside a plain string's braces completes as a name, and taking one turns the string into the f-string that reads it — the f and the closing brace are written for you:

name = "john"

"hello {na"     # → f"hello {name}"

the rest of the string is left exactly as it is written, so any other brace in it starts meaning what an f-string reads it to mean once the prefix goes on

a docstring, a case pattern and a str.format template are not offered the conversion, and neither is a string written where a type belongs: an f-string is a different thing in each of those places, or no longer legal at all

enum members and extensions

a bare enum member is offered where the expected type admits one — the value of a declared assignment, and a case pattern:

a: Color = Red

attribute completions include the members any extension block in scope declares on the receiver, alongside the type's own

callables

accepting a completion for anything callable writes its parentheses and leaves the caret between them, ready for the first argument. set ty.completions.completeFunctionParentheses to false to insert the bare name instead

inlay hints

each kind of hint can be turned off on its own through the ty.inlayHints.<name> setting your editor passes to the server. all default to on

setting shows
variableTypes the type of a variable the source does not annotate
callArgumentNames the parameter each positional argument fills
inferredRaises the exception set of an undeclared def
inferredVariance the variance inferred for a type parameter
inferredReification reified on a parameter the body reifies
inferredOverride override on a method that overrides without saying so
inferredReads the observables a def reads while composing (basedpython-ui)
parameterStability unstable on a composable parameter the runtime cannot compare
derivedDependencies what a derived(...) computation depends on
inferredInvalidations the composables and derived values a state write re-runs
callTypeArguments the type arguments inferred for a generic call
typeArgumentNames the parameter a positional type argument fills
numericPromotions the arms numeric promotion adds to float and complex
revealedTypes what a reveal_type call reveals, and what it narrowed
implicitParameters a trailing lambda's it
implicitSelf the self an init(...) binds
lambdaParameterTypes the type of an unannotated lambda parameter
inheritedParameterTypes the type a parameter takes from the method it overrides
inheritedParameterDefaults the default it takes from that method
inferredReturnTypes the return type of a def that leaves it out
propertyTypes the type a property leaves to its accessors
implicitArguments the context arguments a call fills
enumValues the value an enum member takes implicitly
templateBindingTypes a django template {% for %} binding's element type
resolvedTemplates the file a django {% extends %} name resolves to

every hint also carries its setting's name as data.kind — {"kind": "revealedTypes"} — so an editor can show each kind in its own way, or only while a key is held, without telling kinds apart by their text. lsp's own kind is only Type or Parameter, and a revealed list[int] and a call's [int] differ by a bracket

a hint is shown or hidden by its own setting alone, but one setting also changes what other hints say: in a .by file, typeArgumentNames names the type arguments of the types that variableTypes and callTypeArguments hints show, so x = identity({"a": 1}) is hinted : dict[Key=str, Value=int] with it on and : dict[str, int] with it off

keeping a hand-aligned block aligned

a hint takes up room. drawn after the name, it pushes the rest of the line along and a column of = the author lined up by hand stops being one:

let a     = [1, 2]      # let a: list[int]     = [1, 2]
let basdf = 1           # unchanged: a hint for a bare literal is suppressed

the editor cannot repair that by narrowing the hint, because the padding the author wrote can be narrower than the hint that displaced it. restoring the column means widening the other lines too, and basdf is the line that has to move — the line with no hint to hang the answer off. so the server answers which lines were meant to be read together, with by/alignmentGroups:

{
  "textDocument": { "uri": "file:///src/main.by" },
  "range": {
    "start": { "line": 0, "character": 0 },
    "end": { "line": 1, "character": 13 }
  },
  "tabSize": 4
}

tabSize is required. whether two = share a column depends on it as soon as a tab comes before either, and it is the client's setting rather than anything the source says, so there is no value the server could assume

the reply is the blocks whose lines the range reaches. gapStart is where the padding before the = begins and gapEnd is the = itself, so the difference between them is the room the author left:

[
  {
    "members": [
      {
        "gapStart": { "line": 0, "character": 5 },
        "gapEnd": { "line": 0, "character": 10 },
        "gapStartColumn": 5,
        "gapEndColumn": 10
      },
      {
        "gapStart": { "line": 1, "character": 9 },
        "gapEnd": { "line": 1, "character": 10 },
        "gapStartColumn": 9,
        "gapEndColumn": 10
      }
    ]
  }
]

gapStart and gapEnd are positions, counted in the negotiated encoding's code units. gapStartColumn and gapEndColumn are display columns, counted the way a fixed-width editor lays the line out — a wide character takes two, a combining mark none, and a tab reaches the next multiple of tabSize. those are the numbers the grouping itself was decided by, and the ones to lay hints out in

a block is a run of assignments that are siblings in one suite, unseparated by a blank line, already sharing a column, and padded — at least one of them written with two or more spaces before its =. that last condition is what keeps ordinary code out: x = 1 over y = 2 shares a column only because the names are the same length, and padding those apart would be injecting space the author never wrote

how wide anything ends up is the editor's to decide, because only the editor knows which hints are on screen at a given instant — a kind can be switched off, and push-to-hint draws a hint only while a key is held. what displaces a line is every hint drawn on it at or before gapEnd added together, which is more than one hint when the line unpacks: a, b = 1, 2 is hinted after a and again after b

a block is reported whole even when the range reaches only one of its lines, since the column is sized against every line at once

outline

a property is one member in the source, so it is one entry in the outline — not the getter, backing field and setter it lowers into. enum variants and an extension's methods appear under their declarations

refactorings

the server offers refactorings as code actions. each is worked out from what the checker reads the file to mean, not from its text: inlining x replaces the reads of that variable, never a same-named parameter of another function, an attribute obj.x or a keyword argument f(x=1)

a refactoring either keeps the program meaning what it meant or is refused, and a refusal says why. an editor that asks for refactorings explicitly shows the reason; one that asks while the caret moves is only offered what applies

refactoring kind offered on
inline variable refactor.inline.variable a variable assigned once
extract variable refactor.extract.variable a selected expression
introduce constant refactor.extract.constant a selected expression of literals
extract function refactor.extract.function selected statements
add return annotation refactor.rewrite.returnAnnotation a def header with no ->
convert to data class refactor.rewrite.toDataClass a class header
convert from data class refactor.rewrite.fromDataClass a data class header

moving an expression can change when it runs, so that is what most refusals are about. a value with an effect is only inlined into a single read in the very next statement, with nothing that could observe the move evaluated before it; an expression is only extracted above its statement when it runs exactly once each time the statement does — not in a loop condition, a conditional branch, a lambda or after a call

while queue.pending():  # extracting `queue.pending()` is refused: it runs every iteration
    queue.pop()

extracting statements from a method makes a method of the same class, called through the receiver. what the statements read from the function becomes a parameter, and what they assign that is read afterwards is returned

a constant goes further than a variable: it is bound at the top of the module, so it is evaluated as the module loads whether or not the expression would have been evaluated at all. only an expression made of literals that cannot raise is hoisted, and whether an operation over literals raises is asked of the checker rather than read off its spelling — 60 * 60 * 24 is offered because ty folds it to 86400, while 1 / 0 and 1 + "a" are not offered at all

a client that can resolve a code action's edit gets the action without one, and the edit is computed when the action is chosen — against the document version it was offered for, and refused as content modified if the document changed since. this defers sending the edit, not working it out: whether a refactoring applies is decided by working out the rewrite, so the actions a textDocument/codeAction reply offers have each been computed to answer it. send only to narrow that — a request that does not ask for refactor kinds does no refactoring work at all

keyword highlighting

textDocument/documentHighlight on a keyword answers the keywords that belong with it rather than the occurrences of a symbol: the elifs and else of an if, the except, else and finally of a try, the returns of a def. the pairing is read off the parse tree, so match = 1 is an assignment and nothing lights up on it. a position that is not a keyword is answered the way it always was

go to super

textDocument/implementation on a method goes down, to the methods that override it. by/superMembers goes up: given a position on the name a class member is declared with — a def or a nested class, a name the class body assigns, annotates or captures, or an import's alias — it answers the superclass members that member overrides, each with its class, its file and the name to land on. a class goes up with typeHierarchy/supertypes

what counts as an override is the override checks' own walk up the MRO, from the class as it is written rather than as a decorator returns it. of what that walk finds, a member goes to the nearest declaration along each branch, in MRO order: a method overriding B.f, where B.f overrides A.f, goes to B.f, and a class with two bases that each declare the method goes to both. the first of them is the one missing-override-decorator names. a private member overrides nothing, since it is emitted under a name of its own, and a member a superclass synthesizes rather than writes — a dataclass's __init__ — goes to that superclass, marked synthesized. each is marked abstract when it is abstract where it is declared — an @abstractmethod, or a protocol method with no implementation — so that a member overriding it implements it. the answer is null when no class member is declared at the position, and an empty list for one that overrides nothing

an answer depends on more than the text of the document it is about: on every file the document's classes inherit from, and the server announces no change to it. a client that keeps one asks again after it opens, edits or closes any document of the workspace, not only that one, and on workspace/inlayHint/refresh, which the server sends, to a client that supports it, after a change on disk or to the environment changes what it answers from

by/documentSuperMembers answers the same for a whole document in one request, from the same code: every class member that overrides something, with its class, the range of the name it is declared with, whether it is abstract itself, and its superMembers as by/superMembers gives them. it is what an editor asks to draw an "overrides" marker beside each member, on every pass over the document. a property with get and set blocks is one member, listed once. constructors and the methods they call — __init__, __new__, __post_init__, __init_subclass__ — are left out, since neither invalid-method-override nor missing-override-decorator holds one to what it overrides, and by/superMembers still answers one. like the other document requests it takes textHash, and answers a document the client has not opened from the file on disk

the whole project's diagnostics

with diagnosticMode set to workspace, the server checks every file in the project, open or not, and reports what it finds two ways:

request answers
workspace/diagnostic once something differs from the result ids sent — held open until then
by/checkWorkspace as soon as the check is done, whether or not anything differs

both take the same parameters and report the same thing, file by file, with an unchanged report for a file whose result id still matches. workspace/diagnostic is the one an editor keeps a problems list current with: it asks again as soon as it is answered, and the server answers when an edit or a change on disk changes something. by/checkWorkspace is for a client that asks once — an inspection over the whole project, a batch run with no editor — and would otherwise wait forever on a project with nothing wrong in it. it is refused, rather than answered empty, when the diagnostic mode does not check the workspace

they differ in how the report arrives. workspace/diagnostic streams it as partial results when the client sends a partialResultToken, and reports progress under the client's workDoneToken. by/checkWorkspace ignores both tokens: its report comes whole, in the response, and its progress, when the client supports window/workDoneProgress, comes under a token the server creates

by/checkWorkspace can take a while to answer, for two reasons:

  • an edit or a change on disk that lands while the workspace is being checked starts the check again, on the changed workspace. the answer describes the workspace as it was when a check finished, and a workspace that never stops changing is never answered
  • while a PEP 723 script's environment is being set up for the first time, the workspace is not checked, and the request waits until the environment is there. the first setup installs the script's dependencies, so it can take as long as that does

the program model

a run configuration, a test list and a "go to generated file" are all questions about the project rather than about a position in a file, and each used to be an editor's own guess at a layout the build decides. the server answers them instead, from the same reading the transpiler and the checker use:

request answers
by/entryPoint whether a module has a main, and the command line the transpiler gives it
by/testItems the tests pytest collects from a document, with their node ids
by/runModules the modules by run can be given, and the one run.main names
by/buildOutput where a project's build writes, and which output came from which source
by/syntaxOutline a document's suites, clause keywords, call statements and string parts

by/buildOutput maps both ways: given a .by it names the file the build writes it to, given a file in the build directory it names the source. the path inside the build follows the module tree, not the directory tree, so a src-layout project's src/pkg/main.by is build/pkg/main.py — which is why this is the build's answer rather than a path an editor can assemble

by/syntaxOutline answers one document whole, on every revision the client wants to serve features from: it is what an editor needs to type in an indentation-delimited language — which line opens a suite, where a compound statement ends, what a string literal's escapes and interpolations are, and how much indentation a triple-quoted string has stripped

.by test files are collected under the same names as python ones — test_*.by and *_test.by — because a .by transpiles to the .py of the same stem, and that is the file pytest sees

asking about the text you have

a client that keeps an answer against its own revision of a document can say which text a document request is about, by adding textHash to the request's params: FNV-1a, 64 bits, over the text's UTF-16 code units with every line ending counted as one \n, written as sixteen lowercase hex digits. for a notebook cell that is the cell's own text. the request is then answered about that text and no other. when the server already holds it — the open buffer, or for by/syntaxOutline, by/injections, by/superMembers, by/documentSuperMembers, textDocument/semanticTokens/full and textDocument/documentSymbol the file on disk of a document the client has not opened — it answers at once; otherwise the request waits for the didOpen, didChange or file write that brings the text, and is answered with ServerCancelled if nothing has after ten seconds. so a client need not know whether its didOpen has gone out before it asks, and an answer never describes text it did not ask about. a request without textHash is answered as before, and refused for a document that is not open

debugger facts

while a program is stopped, an editor knows something no checker does: what the names in the current frame actually hold. the server takes those readings and answers what the code below the stop line will do, rather than what it could do

given a debugger stopped on line 5 holding limit = 5:

def compute() -> int: ...

limit = compute()
# stopped here
if limit > 100:     # = false
    over = 1        # will not run

nothing in the source decides that branch — compute() returns an int and any int is possible. the reading of limit is what settles it

this is the checker's ordinary reachability analysis, reading the file under one extra assumption. it does not change the diagnostics you already see: a seeded reading and an unseeded one are separate questions, and the ordinary one is what the squiggles come from

the editor asks with a custom request, by/dataFlowAt:

{
  "textDocument": { "uri": "file:///src/main.by" },
  "line": 5,
  "observations": [{ "name": "limit", "observed": "isInt", "text": "5" }]
}

line is one-based and is the line the program is stopped on. that line is answered along with everything below it, because nothing on it has run yet. a name may be a dotted path — self.limit — spelled as the source spells it

observed carries means
isNone the value is None
isBool value exactly this bool
isInt text, decimal exactly this integer
isStr text exactly this string
isBytes bytes, an array of numbers exactly these bytes
isExactly module, qualname type(value) is this
isEnumMember module, qualname, member this member of this enum

the answer is a list of findings, each with a range, a kind of condition or unreachable, a taken for a condition, and a label to draw

an empty answer is the ordinary case. only readings the server can express as a type produce anything, and only where nothing can have changed the name in between:

  • a binding at or below the stop line is the program's own assignment, and wins over a reading taken before it
  • a name a loop around the stop line rebinds is refused, because the reading is true of this iteration and not of the next
  • an observation applies only to the scope the program is actually stopped in. one frame's limit is not another's, and a name that scope does not itself bind — a global it only reads, an attribute it never assigns — is a value nothing in that scope can vouch for

renaming a module

renaming util.by to helpers.by renames the module alpha.util, and every from alpha.util import thing in the project now names a module that is not there. an editor cannot find those on its own — it would have to resolve every import against the same search paths the checker uses — so it asks first

the request is the protocol's own workspace/willRenameFiles, sent before the file moves, and the answer is the edits that keep the project working. the ordering is what makes the answer computable: the old path still holds the file, so the module it is today can be resolved, and the new path is a path to read a name out of

both a file and a directory are asked about, because renaming a directory renames every module under it — the client sends only the directory, never its contents

what gets rewritten:

import alpha.util              # the dotted name
import alpha.util as util      # ... with an alias, which is left alone
from alpha.util import thing   # the module a symbol comes from
from alpha import util         # the module imported as a name
from .util import thing        # a relative import, after the dots

and the uses of a name an import binds, when that name changes. import alpha.util binds alpha, so alpha.util.thing() in the body is part of the rename too. those are found by asking what each expression is rather than by matching text — a local called util in a file that also imports a module of that name is not a reference to the module, and is not touched

a relative import inside a package that is being renamed as a whole comes out unchanged, which is the truth: nothing about from .util import thing stops working because its package was renamed around it

two things are deliberately not rewritten:

  • a module named as a string — importlib.import_module("alpha.util"), an INSTALLED_APPS entry, an entry point in pyproject.toml. django's own module strings are handled by the django rename, and the rest are not distinguishable from any other string
  • an import that would have to change shape — moving alpha.util to beta.util leaves from alpha import util needing a different statement, not a different word. those are reported in the server log rather than rewritten into something that does not mean the same thing