Compare commits

...
66 Commits
Author SHA1 Message Date
Christoph e5c1ba1908 docs(changelog): add release notes through 2026.9.700
build_and_puplish.yml / build_and_publish (release) Successful in 30s
Populate CHANGELOG with detailed release entries up to 2026.9.700 and tidy historical notes. The new content documents recent features and fixes (notably DEF block template/address handling, the BLOCK_LIST/ADDR_LIST completion keywords, comment spacing/formatting changes, and the persistent workspace index), and cleans up formatting and older release headings for readability.
2026-09-24 15:17:02 +02:00
Christoph 81fe58e023 docs: document DEF block templates, addresses, and formatting changes
Update the README and CHANGELOG to describe newly documented language-server behaviors and editor UX details.

- Read BLOCK_TEMPLATE and ADDRESS names from .def files listed under <DefinedEvents> in PSC layers and refresh on PSC/DEF changes; completion suggestions for MOM_do_template, MOM_ask_address_value, MOM_force, and MOM_suppress
- Insert quoted block templates/addresses and Always|Once|Off modes (replace auto-closed/typed quotes instead of doubling) and insert variables with a leading `$`
- Add BLOCK_LIST and ADDR_LIST completion keywords to list all loaded block templates or addresses
- Document formatting/linting of uplevel bodies as Tcl and normalization of comment spacing (#Comment → # Comment) while preserving ##, #!, and already spaced comments
- Note that the Python debug bootstrap now stops with a clear error instead of hanging when no debugpy listener or only a stale one is available

These changes are reflected under "Unreleased" in the changelog and in the README section about DEF block templates and addresses.
2026-09-24 14:34:23 +02:00
Christoph 4c1837c557 Update version to 2026.9.700 2026-09-24 12:28:41 +00:00
Christoph a1eccf90f4 Merge pull request 'Complete BLOCK_LIST/ADDR_LIST completions and format comment spacing' (#45) from dynamic_snippets into main
build_and_puplish.yml / build_and_publish (release) Successful in 29s
2026-09-24 12:27:55 +00:00
Christoph da9091fa38 feat: complete BLOCK_LIST/ADDR_LIST keywords and space comments
Add keyword-driven completions for BLOCK_LIST and ADDR_LIST in the LSP server.
- While typing a prefix of these keywords the server returns the keyword(s)
  as incomplete snippet suggestions that trigger a re-request.
- Once the keyword is typed the server replaces it with a full list of the
  corresponding loaded block templates or addresses (quoted), and sets each
  item's filter_text to include the keyword so further typing narrows results.
- Integrate this flow into on_completion and factor the symbol-list logic
  into a helper that returns either incomplete keyword suggestions or the
  completed symbol list.

Also implement NxFormatter.format_comment to ensure a single space after a
leading '#' for comments that don't already start with whitespace, while
leaving sequences of '#' (separators), shebangs, and already-spaced comments
unchanged. This affects both standalone and inline comments.

Add/rename tests to cover the new completions and comment-spacing behavior.
2026-09-24 13:57:19 +02:00
Christoph 439c700226 Update version to 2026.9.600 2026-09-24 05:57:44 +00:00
Christoph fa13531668 Merge pull request 'Add persistent index, incremental reparse, and .def language support' (#44) from performance into main
build_and_puplish.yml / build_and_publish (release) Successful in 34s
2026-09-24 05:54:10 +00:00
Christoph 4f11ed7ecf chore(vscode): add Linux debug launch configurations and tasks, update lockfile 2026-09-24 07:53:15 +02:00
Christoph c1355970bf test(server): add tests for .def completions and uplevel formatting 2026-09-24 07:53:15 +02:00
Christoph 3167c18bea fix(server/debug): robust debugpy attach with bounded wait and tests update 2026-09-24 07:53:15 +02:00
Christoph 0666a87d15 feat(client/server): watch .def files in the client file watcher 2026-09-24 07:53:15 +02:00
Christoph ca7c23a0f6 feat(tools/parser): parse uplevel bodies for formatting and linting 2026-09-24 07:53:15 +02:00
Christoph 5d23df8da7 feat(server/lsp_server): include .def file changes and offer quoted def/variable completions 2026-09-24 07:53:15 +02:00
Christoph de310a6604 feat(tools/tcl_command_completion): support def symbols and value completions 2026-09-24 07:53:15 +02:00
Christoph abf32a5e50 feat(server/lsp_tclserver): index .def symbols and provide completion items 2026-09-24 07:53:15 +02:00
Christoph aa0780dd51 feat(tools/file_sourcing): read PSC DefinedEvents and expose .def resolution 2026-09-24 07:53:15 +02:00
Christoph b2656599b2 feat(tools/def_symbols): add parser for .def block templates and addresses 2026-09-24 07:53:15 +02:00
Christoph Brandau 01e8670cc1 feat(indexing): add persistent index cache and incremental reparse
- Pass extension storage path to the server (client/ changes) so the
  server can persist a workspace index.
- Introduce IndexCache (server/tools/index_cache.py) and load/save it on
  initialization and after background indexing. Index entries are stored
  only when the file's stat hasn't changed while being read.
- Add incremental reparse logic (server/tools/incremental_parse.py) and
  use a per-file _last_parse cache in the language server to reparse only
  the top-level Tcl commands touched by an edit, falling back to a full
  parse when necessary.
- Use a new _FileIndex dataclass and _build_file_index helper to unify
  what is stored/loaded for a file; update update_poco_completion_for_file
  to use the persistent cache for disk-read files (from_disk/source_stat).
- Keep background indexing non-blocking and persist the index at the
  end of the run. Add basic unit tests for incremental parse and index cache.

Before: edits and background work always required full parsing of files
and no persistent cross-restart index. After: some edits reuse previous
ASTs and files read from disk can use a persisted index to skip
re-indexing across restarts.
2026-09-23 13:20:09 +02:00
Christoph Brandau b2e6e9d250 perf(server): avoid unnecessary reparses and cache navigation definitions
Introduce several changes to reduce full-document reparses, lock contention and
redundant work when handling TclOO analysis and navigation:

- Add a cheap may_contain_classes pre-check and several cursor/receiver
  heuristics so completions, signature help and tcloo definitions skip the
  expensive marker reparse when the document cannot contain useful OO info.
- Allow passing an existing parsed tree into tcloo completion/signature/definition
  helpers; update callers to use the server's cached tree when available.
- Use a thread-local parser for request-time parse_source to avoid blocking the
  shared parser during background indexing, and add navigation_state() which
  returns cached definition identities (invalidated on index generation changes).
- Add a cheap name pre-filter (_may_resolve_to) for symbol matching and only
  run class highlighting when classes may exist.

These changes reduce contention and repeated parsing, improve responsiveness for
requests during background indexing, and cache navigation definition identities.
Tests were added/updated to assert caching and non-blocking behavior.
2026-09-23 12:04:36 +02:00
Christoph d9d619c1dc Update version to 2026.9.501 2026-09-21 19:14:25 +00:00
Christoph b6228503fc Merge pull request 'Highlight braced stored-proc names in lappend arguments' (#42) from #41 into main
build_and_puplish.yml / build_and_publish (release) Successful in 32s
2026-09-21 19:13:31 +00:00
Christoph Brandau 4de05caac7 fix(semantic_tokens): highlight braced stored-proc names in lappend
Treat braced lappend arguments that match custom or standard procedure names as
function tokens for semantic highlighting. This applies the same visual
highlighting as calls but does not treat the literal as executable Tcl. Previously
such braced stored-proc names were not highlighted.
2026-09-21 21:12:47 +02:00
Christoph 5c3ebc4c82 Update version to 2026.9.500 2026-09-21 18:54:48 +00:00
Christoph 5e5c7dcc42 Merge pull request 'Index PSC scripts and add TclOO cross-file navigation/completions; handle CDL TOGGLE Off' (#40) from add_class_support into main
build_and_puplish.yml / build_and_publish (release) Successful in 38s
2026-09-21 18:53:52 +00:00
Christoph Brandau 84821d2852 feat(cdl): allow INVALID in CATEGORY pattern
Accept "INVALID" as a valid token in the CATEGORY grammar rule (both the initial
and repeated entries). Also compacted several JSON objects (pattern includes
and "captures" entries) in syntaxes/cdl.tmLanguage.json; these formatting
changes are non-functional.
2026-09-21 20:52:53 +02:00
Christoph Brandau 547da644ce feat(cdlEventHandler): detect TOGGLE Off and emit *_defined globals
Record parameters marked with "TOGGLE Off" by adding an optional
toggleOffParameterNames field and including an extra <var>_defined global
for each such parameter in createCdlEventHandlerSnippet.

To support this, the parser was changed from a brace-delta approach to a
token-based scanner so nesting and the association between a PARAM and its
TOGGLE can be tracked reliably. Added unit tests that verify detection,
case-insensitivity, and ignoring TOGGLE occurrences inside comments/strings
or other events.
2026-09-21 20:51:02 +02:00
Christoph Brandau 757b885f28 feat(server): index PSC scripts and provide cross-file TclOO navigation/completions
Add PSC (.psc) indexing and share TclOO class metadata across files so class
definitions discovered via PSC layers can be used for completions, signature
help, inlay hints, and "go to definition". Key behavior changes:

- Client file watcher now includes *.psc and .vscode launch paths/tests updated
  to use the postprocessor test folder; .gitignore updated to ignore that folder.
- Server watches .psc changes and refreshes a PSC script index; new
  tools/tcloo_navigation.py exposes tcloo_definition used by the language server
  to resolve cross-file class/constructor/method definitions.
- Language server uses class_snapshot(document.path) when producing TclOO
  completions, signature help, and inlay hints so resolved class metadata is
  available across files.

Also includes related docs/changelog updates, minor code formatting cleanups,
and added tests for PSC/TclOO behavior.
2026-09-21 20:47:51 +02:00
Christoph 3762a01d40 Update version to 2026.9.400 2026-09-11 21:40:33 +00:00
Christoph 91b86f29cb Merge pull request 'Add TclOO class completions, signature help and inlay hints' (#39) from add_class_support into main
build_and_puplish.yml / build_and_publish (release) Successful in 39s
2026-09-11 21:39:34 +00:00
Christoph Brandau f88a50d4ab feat(tcloo): add document-local TclOO completions, signature help and hints
Add conservative, document-local TclOO type inference and tooling so the server
can offer method completions, signature help, and inlay parameter hints for
statically resolvable TclOO receivers (including `new`/`create`, `my`, and
simple return-chains). Also surface class names as completion items and emit
semantic tokens for class declarations/references.

Notable changes:
- New tcloo_* tools: completion, symbols, and argument parsing; integrated into
  on_completion, signature_help, inlay hint generation and semantic token
  highlighting. Completions are returned early when an OO receiver context is
  detected.
- Use a completion-friendly parser fallback when the main AST fails (TclSyntaxError)
  so editing-in-progress code still yields useful completions.
- Add CompletionItemKind.Class to command kinds, exclude class items from the
  poco completion name cache, and include new unit tests for the TclOO helpers.
2026-09-11 23:38:42 +02:00
Christoph c28836933c Update version to 2026.9.300 2026-09-10 08:31:25 +00:00
Christoph 91c8c4aff3 Merge pull request 'Check Array Paramter' (#38) from #150 into main
build_and_puplish.yml / build_and_publish (release) Successful in 34s
2026-09-10 08:29:22 +00:00
Christoph Brandau d8611d7aea feat(tcl): add static variable name extraction and array key completion
This adds tooling to statically extract Tcl variable names from syntax
trees without evaluating substitutions, enabling better completions
for array elements and plain variables. The new helpers are wired
into the completion and navigation flows and are supported by tests
covering array keys and substitutions.

- Introduce variable_names.py with variable_name() and array_key_parts()
- Wire static name extraction into completion and symbol indexing
- Add tests for array key completion with substitutions
2026-09-10 09:07:18 +02:00
Christoph Brandau 7e9a4359cd feat(cdl): include args in generated event handler snippet
The generated event handler snippet now declares the proc with an args block.
This enables parameters to be referenced within the handler body.
The change makes the snippet compatible with events that pass arguments.

- Include an args block in the proc declaration for event handlers.
2026-09-10 08:45:25 +02:00
Christoph b7f28ab1a3 Update version to 2026.9.220 2026-09-08 19:22:31 +00:00
Christoph 9b368ba761 Merge pull request 'fix(completion): scan nested commands in unfinished braced args' (#37) from #36 into main
build_and_puplish.yml / build_and_publish (release) Successful in 34s
Reviewed-on: #37
2026-09-08 19:21:49 +00:00
Christoph 82e13cf7ce fix(completion): scan nested commands in unfinished braced args
Make the completion parser inspect nested command segments when an
unfinished braced argument is the active context, instead of treating the
entire braced body as opaque. This resolves cases where inner commands
(e.g. command substitutions) were ignored and improves suggestion
accuracy. Tests were added to cover nested commands inside braced
conditions and to ensure closed braced arguments do not change context.
Also add Linux-specific launch and task configurations to IDE settings
to simplify local extension development and debugging.

- Recursively scan inner context when a braced body is unfinished
- Add tests for nested commands and closed-brace behavior
- Add Linux launch/task entries for easier development
2026-09-08 21:16:58 +02:00
Christoph d104906508 Update version to 2026.9.210 2026-09-04 11:46:55 +00:00
Christoph a5ff3b55ff Merge pull request 'feat(syntax): add escaped-quoted-strings token to def syntax' (#35) from new_completions into main
build_and_puplish.yml / build_and_publish (release) Successful in 7m35s
Reviewed-on: #35
2026-09-04 11:39:08 +00:00
Christoph Brandau 26225eb8ed feat(syntax): add escaped-quoted-strings token to def syntax
Introduces an escaped-quoted-strings pattern for the def syntax.
It enables proper highlighting of strings with escapes and vars.
The change adds the new pattern to the syntax and fixes the EOF newline.

- Adds escaped-quoted-strings for escapes and embedded vars
- Registers new pattern in repository for proper highlighting
- Ensures trailing newline in the file
2026-09-04 08:39:46 +02:00
Christoph 844138b383 Update version to 2026.9.200
build_and_puplish.yml / build_and_publish (release) Canceled after 33s
2026-09-03 08:39:22 +00:00
Christoph d7eb72f417 Merge pull request 'New completions' (#34) from new_completions into main
build_and_puplish.yml / build_and_publish (release) Successful in 27s
Reviewed-on: #34
2026-09-03 08:36:00 +00:00
Christoph Brandau af5acfc946 feat(tcl): add dynamic argument completion and snippets
The changes add dynamic, semantic argument completion for Tcl
commands and snippet support.

- Introduces DynamicCompletionKind, TclArgumentCompletion, and dynamic rules
  for Tcl to provide variable, procedure, namespace, and path suggestions.
- Adds snippet-backed commands and arguments for Tcl blocks and paths.
- Refactors tcl_argument_completion and updates the LSP to use dynamic path,
  variable, and namespace completions with snippet kinds.
2026-09-03 10:35:39 +02:00
Christoph Brandau 20f76b6a20 feat(lsp): add Tcl command-aware completion and integration
- Introduced a Tcl command-aware completion engine and wired to LSP.
- Added a new module with Tcl commands, options, and contextual matching.
- Updated completion to favor command-aware items and args.
2026-09-03 10:17:16 +02:00
Christoph 081b488fe3 Update version to 2026.9.100 2026-09-03 07:38:12 +00:00
Christoph b37eea43d9 Merge pull request 'New features' (#33) from new_features into main
build_and_puplish.yml / build_and_publish (release) Successful in 34s
Reviewed-on: #33
2026-09-03 07:37:09 +00:00
Christoph Brandau 89add18273 feat(lsp): add context-aware completion ranking and document highlights
Adds context-aware completion ranking and document highlights for Tcl.
Adds completion_context to distinguish variables and commands.
Wires per-file completion snapshots and ranking into the flow.
Adds document_highlight provider support and tests for highlights.

- Context-aware ranking of completion items using per-file snapshots
- Document highlight provider wired into initialization and tests
- Tests for completion context, ranking, and document highlights
2026-09-03 09:19:50 +02:00
Christoph e47c3bda69 Merge pull request 'Update pygls' (#32) from update_pygls into main
Reviewed-on: #32
2026-09-03 06:52:54 +00:00
Christoph Brandau 41d117fe2c feat(lsp): add call hierarchy support for TCL and MOM
Adds LSP call hierarchy support for TCL procedures and MOM events.
A symbol index and identity logic underpin incoming and outgoing calls.
LSP call hierarchy features are wired and changelog/README updated.

- Implement data encoding for call hierarchy items and identity restoration
- Wire LSP server to expose prepare_call_hierarchy, incoming_calls, and outgoing_calls
- Add tests for cross-file calls and edge cases
2026-09-03 08:52:03 +02:00
Christoph Brandau 53ebc5d055 chore(lsprotocol): migrate to 2025.0.0 and cleanup artifacts
The changes align the project with the 2025.0.0 lsprotocol
release, removing the old backport and updating type hints
in the protocol hooks to use Sequence where appropriate. The
dist-info and packaging metadata for older lsprotocol
versions are replaced with the new 2025.0.0 artifacts.

- Remove exceptiongroup backport used on Python <3.11
- Use Sequence instead of List in LS protocol hooks
- Replace old dist-info with 2025.0.0 metadata
2026-09-03 08:39:12 +02:00
Christoph Brandau a0a0d38fe5 docs(readme): remove development/debugging section and license note
This change removes outdated development and debugging guidance
from the README and streamlines the licensing note. It also deletes
the embedded debugger license file and relies on the main LICENSE
file for terms.

- Remove outdated debugging guidance from README
- Delete embedded debugger license file and rely on LICENSE
2026-09-03 07:49:10 +02:00
Christoph 52b81b3dcf Update version to 2026.8.300 2026-08-28 20:42:34 +00:00
Christoph Brandau c3d5116885 feat(debugger): integrate NX Tcl Remote Debugger into NX Postprocessor
build_and_puplish.yml / build_and_publish (release) Successful in 35s
Embed the NX Tcl Remote Debugger into the NX Postprocessor extension.
Add a new debugging client, protocol, and adapter logic to
drive attach/launch, breakpoints, stepping, and evaluation
via the runtime adapter.

- Introduced a client debugger with a new adapter and protocol
- Wired attach/launch, breakpoints, stepping, and evaluation
- Updated docs and licensing to reflect the embedded debugger
2026-08-28 22:36:18 +02:00
Christoph 07ccd2d26a Update version to 2026.8.200 2026-08-19 11:51:21 +00:00
Christoph Brandau af195a577b refactor(lsp): cache analysis results and debounce diagnostics
build_and_puplish.yml / build_and_publish (release) Successful in 29s
This change adds cached and incremental analysis for the LSP
server to improve responsiveness. The client now debounces
diagnostic updates to avoid excessive recomputation. The
server introduces per-document line caches and various
caches for completions, inlay hints, and metadata to
support faster, incremental updates.

- Debounce diagnostics on text changes to reduce noise.
- Add caches for completions, inlay hints, and metadata.
- Introduce incremental analysis with per-document line caches.
2026-08-19 13:41:53 +02:00
Christoph Brandau ecb50be2b8 feat(inlay-hints): add configurable parameter name hints
Adds configurable inlay hints for TCL procedures and merges signatures from built-ins and workspace files. The feature supports parameterNames and suppressWhenArgumentMatchesName and respects an optional range filter and current-file priority.

- Introduces built-in and custom inlay hint builders
- Honors inlayHints parameterNames and suppression options
- Adds tests validating hints, ranges, and priority rules
2026-08-19 12:59:51 +02:00
Christoph 61d4785775 Update version to 2026.8.100 2026-08-19 07:47:31 +00:00
Christoph Brandau 541704f45a feat(cdl): add CDL event handler parsing and snippet support
build_and_puplish.yml / build_and_publish (release) Successful in 36s
This adds a small DSL-aware helper to extract CDL event
declarations and generate a ready-to-use snippet. It also
extends hover support to show the snippet and parameter
hints for CDL events, improving developer productivity.

- Generate a MOM event handler scaffold and local vars
- Hover shows the generated snippet and mom_ parameter hints
2026-08-19 09:45:57 +02:00
Christoph Brandau 33cf282b0a feat(debug): enable Python debug workflow and debug server integration
Adds an end-to-end Python debug workflow for the extension.
Includes a new prepare-debug.ps1 script and a VS Code task.
Extends the Python server and extension to coordinate a debug session and safe startup.

- Add prepare-debug.ps1 and a VS Code task to build the debug bundle
- Enable Python debug wiring in the server and tests
- Ensure a single stable Python debug session during startup
2026-08-17 11:02:22 +02:00
Christoph Brandau 35a4357551 feat(navigation): add symbol index and LSP navigation features
The changes introduce a Tcl symbol index powering LSP navigation
features across the workspace. A navigation API exposes
snapshots and update hooks, enabling goto-definition,
references, and rename using the index. Background indexing
now watches Tcl files and rebuilds the index to stay in sync.

- Add Tcl symbol index and navigation snapshot API
- Wire go-to-definition, references, and rename using the index
- Watch Tcl files and refresh the index in the background
2026-08-17 09:24:45 +02:00
Christoph Brandau f5bd79f067 feat(lsp): add incremental indexing and file ops support
Adds a thread-safe incremental index and snapshot API for LSP.
Introduces cache invalidation and file operation hooks for delete
and rename. This keeps indices in sync with disk changes.
Supports reindexing TCL files from disk when needed.

- Adds workspace file change handlers to sync indices on delete/rename.
- Introduces locking and snapshot helpers to safely access shared state.
- Refactors to invalidate caches on edits and reindex TCL files.
2026-08-17 08:45:09 +02:00
Christoph Brandau a39aee1b9d feat(server): add TCL signature help support
Adds a signature help system for TCL commands.
The LSP server now exposes signature help for custom and
built-in MOM procedures, enabling parameter hints while editing.
Tests and documentation were added to cover common usage.

- Adds signature_help module to parse and present signatures
- Integrates with LSP server to provide signature help on the client
- Adds tests for custom and built-in procedures
2026-08-17 08:31:15 +02:00
Christoph 4ca1b0cce9 Update version to 2026.6.201 2026-06-22 06:50:56 +00:00
Christoph 6051260a8d Merge pull request 'Fix single-line brace formatting to preserve internal whitespace' (#31) from bug_fixing into main
/ build_and_publish (release) Successful in 33s
Reviewed-on: #31
2026-06-22 06:50:16 +00:00
Christoph Brandau dfe15dd3db Fix single-line brace formatting to preserve internal whitespace
The formatter now calculates and passes explicit leading and trailing whitespace around expression content to the `_brace` method. This ensures correct rendering of single-line expressions that include braces.
2026-06-22 08:49:22 +02:00
Christoph 5d1c402d2a Update version to 2026.6.200 2026-06-22 06:34:00 +00:00
160 changed files with 24560 additions and 8273 deletions
+2 -1
View File
@@ -9,4 +9,5 @@ __pycache__
.nox
*.g4
.antlr
.claude
.claude
/test/postprocessor/
+112 -11
View File
@@ -10,12 +10,75 @@
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
"outFiles": ["${workspaceFolder}/client/**/*.js"],
"args": [
"--extensionDevelopmentPath=${env:TEMP}/nx-post-support-vscode-debug",
"${env:TEMP}/nx-post-support-vscode-debug",
"${env:TEMP}/nx-post-support-vscode-debug/test/postprocessor"
],
"cwd": "${env:TEMP}/nx-post-support-vscode-debug",
"outFiles": ["${env:TEMP}/nx-post-support-vscode-debug/dist/**/*.js"],
"linux": {
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"${workspaceFolder}",
"${workspaceFolder}/test/postprocessor"
],
"cwd": "${workspaceFolder}",
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"resolveSourceMapLocations": [
"${workspaceFolder}/dist/**/*.js",
"!**/node_modules/**"
]
},
"sourceMaps": true,
"resolveSourceMapLocations": [
"${env:TEMP}/nx-post-support-vscode-debug/dist/**/*.js",
"!**/node_modules/**"
],
"skipFiles": ["<node_internals>/**"],
"autoAttachChildProcesses": true,
"preLaunchTask": {
"type": "npm",
"script": "watch"
"preLaunchTask": "NX Post Support: Compile Debug"
},
{
// Linux workaround: js-debug's extensionHost attach probes localhost and [::1]
// in parallel and aborts when [::1] is refused. Start the dev host ourselves
// with a fixed inspector port and attach directly to 127.0.0.1.
"name": "Run Extension (Linux)",
"type": "node",
"request": "attach",
"address": "127.0.0.1",
"port": 9333,
"timeout": 30000,
"continueOnAttach": true,
"sourceMaps": true,
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"resolveSourceMapLocations": [
"${workspaceFolder}/dist/**/*.js",
"!**/node_modules/**"
],
"skipFiles": ["<node_internals>/**"],
"preLaunchTask": "NX Post Support: Start Dev Host (Linux)"
},
{
"name": "Debug Extension (Linux, hidden)",
"type": "node",
"request": "attach",
"address": "127.0.0.1",
"port": 9333,
"timeout": 30000,
"continueOnAttach": true,
"sourceMaps": true,
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"resolveSourceMapLocations": [
"${workspaceFolder}/dist/**/*.js",
"!**/node_modules/**"
],
"skipFiles": ["<node_internals>/**"],
"preLaunchTask": "NX Post Support: Start Dev Host with debugpy (Linux)",
"presentation": {
"hidden": true,
"group": "",
"order": 4
}
},
{
@@ -34,10 +97,37 @@
"name": "Debug Extension (hidden)",
"type": "extensionHost",
"request": "launch",
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
"outFiles": ["${workspaceFolder}/client/**/*.js"],
"runtimeExecutable": "${execPath}",
"args": [
"--extensionDevelopmentPath=${env:TEMP}/nx-post-support-vscode-debug",
"${env:TEMP}/nx-post-support-vscode-debug",
"${env:TEMP}/nx-post-support-vscode-debug/test/postprocessor"
],
"cwd": "${env:TEMP}/nx-post-support-vscode-debug",
"outFiles": ["${env:TEMP}/nx-post-support-vscode-debug/dist/**/*.js"],
"linux": {
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"${workspaceFolder}",
"${workspaceFolder}/test/postprocessor"
],
"cwd": "${workspaceFolder}",
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"resolveSourceMapLocations": [
"${workspaceFolder}/dist/**/*.js",
"!**/node_modules/**"
]
},
"sourceMaps": true,
"resolveSourceMapLocations": [
"${env:TEMP}/nx-post-support-vscode-debug/dist/**/*.js",
"!**/node_modules/**"
],
"skipFiles": ["<node_internals>/**"],
"env": {
"USE_DEBUGPY": "True"
"USE_DEBUGPY": "True",
"NXPS_DEBUG_HOST": "127.0.0.1",
"NXPS_DEBUG_PORT": "5678"
},
"presentation": {
"hidden": true,
@@ -49,8 +139,9 @@
"name": "Python debug server (hidden)",
"type": "debugpy",
"request": "attach",
"listen": { "host": "localhost", "port": 5678 },
"justMyCode": true,
"listen": { "host": "127.0.0.1", "port": 5678 },
"justMyCode": false,
"logToFile": true,
"presentation": {
"hidden": true,
"group": "",
@@ -63,12 +154,22 @@
"name": "Debug Extension and Python",
"configurations": ["Python debug server (hidden)", "Debug Extension (hidden)"],
"stopAll": true,
"preLaunchTask": "npm: watch",
"preLaunchTask": "NX Post Support: Compile Debug",
"presentation": {
"hidden": false,
"group": "",
"order": 1
}
},
{
"name": "Debug Extension and Python (Linux)",
"configurations": ["Python debug server (hidden)", "Debug Extension (Linux, hidden)"],
"stopAll": true,
"presentation": {
"hidden": false,
"group": "",
"order": 2
}
}
]
}
+57
View File
@@ -0,0 +1,57 @@
param(
[Parameter(Mandatory = $true)]
[string]$WorkspaceRoot,
[Parameter(Mandatory = $true)]
[string]$DebugRoot
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function ConvertFrom-ExtendedWindowsPath {
param([string]$Path)
if ($Path.StartsWith("\\?\UNC\", [System.StringComparison]::OrdinalIgnoreCase)) {
return "\\" + $Path.Substring(8)
}
if ($Path.StartsWith("\\?\", [System.StringComparison]::OrdinalIgnoreCase)) {
return $Path.Substring(4)
}
return $Path
}
$workspacePath = ConvertFrom-ExtendedWindowsPath $WorkspaceRoot
$workspaceItem = Get-Item -LiteralPath $workspacePath
if (-not $workspaceItem.PSIsContainer) {
throw "Workspace root is not a directory: $workspacePath"
}
$workspacePath = $workspaceItem.FullName
$debugPath = ConvertFrom-ExtendedWindowsPath $DebugRoot
$tempPath = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd("\")
$debugParent = [System.IO.Path]::GetFullPath((Split-Path -Parent $debugPath)).TrimEnd("\")
if (-not $debugParent.Equals($tempPath, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Debug alias must be located directly below the user temp directory: $debugPath"
}
if (Test-Path -LiteralPath $debugPath) {
$debugItem = Get-Item -LiteralPath $debugPath -Force
if ($debugItem.LinkType -ne "Junction") {
throw "Debug alias exists but is not a junction: $debugPath"
}
$currentTarget = (Get-Item -LiteralPath $debugItem.Target).FullName
if (-not $currentTarget.Equals($workspacePath, [System.StringComparison]::OrdinalIgnoreCase)) {
# Removing a junction removes only the link, never the target directory.
Remove-Item -LiteralPath $debugPath -Force
}
}
if (-not (Test-Path -LiteralPath $debugPath)) {
New-Item -ItemType Junction -Path $debugPath -Target $workspacePath | Out-Null
}
Write-Output "Debug extension path: $debugPath -> $workspacePath"
& npm.cmd --prefix $workspacePath run compile:debug
exit $LASTEXITCODE
+74
View File
@@ -0,0 +1,74 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "NX Post Support: Compile Debug",
"type": "process",
"command": "powershell.exe",
"args": [
"-NoLogo",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
"& { param([string]$WorkspaceRoot, [string]$DebugRoot); $scriptRoot = $WorkspaceRoot; if ($scriptRoot.StartsWith('\\\\?\\')) { $scriptRoot = $scriptRoot.Substring(4) }; & (Join-Path $scriptRoot '.vscode\\prepare-debug.ps1') -WorkspaceRoot $WorkspaceRoot -DebugRoot $DebugRoot; exit $LASTEXITCODE }",
"${workspaceFolder}",
"${env:TEMP}\\nx-post-support-vscode-debug"
],
"linux": {
"command": "npm",
"args": ["run", "compile:debug"],
"options": {
"cwd": "${workspaceFolder}"
}
},
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"clear": true
}
},
{
"label": "NX Post Support: Start Dev Host (Linux)",
"detail": "Opens the Extension Development Host with the inspector on 127.0.0.1:9333 (workaround for js-debug localhost/::1 attach bug).",
"type": "shell",
"command": "\"$(dirname \"${execPath}\")/bin/$(basename \"${execPath}\")\" --new-window --inspect-brk-extensions=9333 --extensionDevelopmentPath=\"${workspaceFolder}\" \"${workspaceFolder}\" \"${workspaceFolder}/test/postprocessor\"",
"options": {
"shell": {
"executable": "/bin/bash",
"args": ["-c"]
}
},
"dependsOn": "NX Post Support: Compile Debug",
"problemMatcher": [],
"presentation": {
"reveal": "silent",
"panel": "dedicated"
}
},
{
"label": "NX Post Support: Start Dev Host with debugpy (Linux)",
"detail": "Same as above, but the language server connects to the debugpy listener on 127.0.0.1:5678.",
"type": "shell",
"command": "\"$(dirname \"${execPath}\")/bin/$(basename \"${execPath}\")\" --new-window --inspect-brk-extensions=9333 --extensionDevelopmentPath=\"${workspaceFolder}\" \"${workspaceFolder}\" \"${workspaceFolder}/test/postprocessor\"",
"options": {
"shell": {
"executable": "/bin/bash",
"args": ["-c"]
},
"env": {
"USE_DEBUGPY": "True",
"NXPS_DEBUG_HOST": "127.0.0.1",
"NXPS_DEBUG_PORT": "5678"
}
},
"dependsOn": "NX Post Support: Compile Debug",
"problemMatcher": [],
"presentation": {
"reveal": "silent",
"panel": "dedicated"
}
}
]
}
+5 -1
View File
@@ -9,6 +9,7 @@ vite.config.js
.prettierrc.json
esbuild.js
.gitea
**/.ruff_cache/**
.venv/**
.nox/**
server/.venv/**
@@ -18,4 +19,7 @@ server/.nox/**
**/requirements.in
**/server/src/_debug_server.py
server/noxfile.py
client/**
server/.claude/**
server/tests/**
client/**
dist/**/*.map
+295 -12
View File
@@ -1,23 +1,306 @@
## [0.0.1]
# Changelog
- Initial release
All notable changes to NX Postprocessor Support are listed here, newest release first.
Versions correspond to the Git tags of this repository.
## [0.0.2]
## Unreleased
- Add Autocomp for TYPE
### Documentation
## [0.1.0]
- Document DEF block templates, addresses, `BLOCK_LIST`/`ADDR_LIST`, and the formatting changes in the README
- Add Hover Feature
## [2026.9.700] - 2026-09-24
## [0.2.0]
### Added
- Add DEF File Support
- `BLOCK_LIST` and `ADDR_LIST` completion keywords that list all loaded block templates or addresses; the keyword is replaced by the selected quoted name
## [2026.6.100]
### Changed
- Fix several bugs
- Format comments with a space after `#` (`#Comment` becomes `# Comment`); `##` separators, `#!`, and comments that already start with whitespace stay unchanged
## [2026.6.200]
## [2026.9.600] - 2026-09-24
- Fix foramtting bug
### Added
- Read `BLOCK_TEMPLATE` and `ADDRESS` names from the `.def` files listed under `<DefinedEvents>` in PSC layers; changes to PSC and DEF files are picked up automatically
- Suggest block templates for `MOM_do_template` and addresses for `MOM_ask_address_value`, `MOM_force`, and `MOM_suppress`, each followed by variables
- Suggest `Always`, `Once`, and `Off` for the first argument of `MOM_force` and `MOM_suppress`
- Insert block templates, addresses, and modes in quotes without doubling typed or auto-closed quotes; insert variables with a leading `$`
- Persistent workspace index in the extension storage, so restarts skip reparsing unchanged files; the cache is discarded automatically when the server or bundled tclint changes
### Changed
- Reparse only the top-level Tcl commands touched by an edit instead of the whole file
- Speed up TclOO completion, signature help, inlay hints, and Go to Definition by reusing the cached syntax tree and skipping files without classes
- Speed up references, document highlights, and call hierarchy with cached definition lookups
- Keep background indexing from blocking requests that need a fresh syntax tree
### Fixed
- Format and lint `uplevel` bodies as Tcl scripts instead of leaving them untouched
- Stop the Python debug bootstrap with a clear error instead of hanging when no debugpy listener or only a stale one is available
## [2026.9.501] - 2026-09-21
### Fixed
- Highlight braced stored-procedure names in `lappend` arguments
## [2026.9.500] - 2026-09-21
### Added
- Index Tcl scripts referenced by PSC layers, including external folders, environment-variable folders, and legacy Windows encoding
- Share TclOO class metadata across files for completion, signature help, inlay hints, and highlighting
- Go to Definition for TclOO classes, constructors, and resolved methods, including PSC library definitions
- CDL event handler snippets declare an extra `<var>_defined` global for parameters marked `TOGGLE Off`
- Allow `INVALID` in the CDL `CATEGORY` grammar
## [2026.9.400] - 2026-09-11
### Added
- Document-local TclOO method completion for `new`/`create` instances, `my`, and statically inferred return chains
- Signature help and parameter inlay hints for resolved TclOO methods and constructors, including optional and variadic arguments
- Suggest TclOO class names as classes and semantically highlight their declarations and calls
- Keep completion working while the file has syntax errors
## [2026.9.300] - 2026-09-10
### Added
- Array key completion, including keys built with substitutions
- Generated CDL event handler snippets declare `args`
## [2026.9.220] - 2026-09-08
### Fixed
- Completion inside unfinished braced arguments now recognizes nested commands
## [2026.9.210] - 2026-09-04
### Added
- Highlight escaped quoted strings and embedded variables in DEF files
## [2026.9.200] - 2026-09-03
### Added
- Command-aware completion for Tcl subcommands, fixed argument values, and options such as `string compare -nocase`
- Semantic argument completion for variables, procedures, namespaces, and local file paths
- Placeholder-based snippets for `if`, `foreach`, `proc`, `switch`, `try`, and `dict for`
## [2026.9.100] - 2026-09-03
### Added
- Incoming and outgoing call hierarchy for custom Tcl procedures and MOM event handlers
- Document highlights for procedure and variable occurrences
- Context-aware completion that ranks local, current-file, workspace, and built-in symbols in that order
### Changed
- Update lsprotocol to 2025.0.0
## [2026.8.300] - 2026-08-28
### Added
- Integrate the NX Tcl Remote Debugger; the separate `local-nx.nx-tcl-debug` extension is no longer needed
- `nx-tcl` attach configurations and breakpoints in Tcl and DEF files
- Stepping, call stack, scopes, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops
- Breakpoints in `LIB_GE_command_buffer_edit_*` bodies, mapped back to their source and owning procedure
## [2026.8.200] - 2026-08-19
### Added
- Configurable parameter-name inlay hints (`inlayHints.parameterNames`, `inlayHints.suppressWhenArgumentMatchesName`) for custom and built-in NX procedures, including variadic arguments
- Unlimited inlay hint length for Tcl files, so later parameter names are not truncated
### Changed
- Cache semantic tokens, inlay hints, hover, completion, and variable indexes and debounce edits and diagnostics for faster responses
## [2026.8.100] - 2026-08-19
### Added
- Workspace symbol index with Go to Definition, references, and rename
- Signature help for custom Tcl procedures and built-in NX/MOM procedures
- CDL event handler snippets and hover with generated handler and `mom_` parameter hints
- Keep indexes in sync when Tcl files are changed, deleted, or renamed on disk
## [2026.6.201] - 2026-06-22
### Fixed
- Preserve whitespace inside single-line braced expressions when formatting
## [2026.6.200] - 2026-06-22
### Fixed
- Tcl formatting bug caused by the formatter options
## [2026.6.100] - 2026-06-18
### Changed
- Folding ranges for Tcl are computed by the language server, avoiding duplicate regions
- Language server restarts are serialized so only one server runs at a time
- Pin the Python dependencies of the server and update the bundled libraries
### Fixed
- Several stability fixes
## [2025.9.200] - 2025-09-01
### Changed
- Install server dependencies with uv instead of pip
- Relicense to AGPL-3.0-or-later
## [2025.9.101] - 2025-08-16
### Added
- Signature help
- Variable checks and additional semantic tokens
### Fixed
- `incr` on variables that already exist and `foreach` loop variables
## [2025.8.400] - 2025-08-15
### Added
- Outline for DEF and CDL files
### Fixed
- Duplicate `MOM_add_to_block_buffer` entry renamed to `MOM_add_to_line_buffer`
## [2025.8.300] - 2025-08-12
### Added
- Go to Definition and document outline for Tcl
- Variable index with positions
- Procedure documentation in hover
### Fixed
- Formatter deleting line continuations (`\`)
- Wrong semantic highlighting in several cases
## [2025.8.202] - 2025-08-12
### Fixed
- Formatter deleting line continuations (`\`)
## [2025.8.201] - 2025-08-11
### Fixed
- Highlighting errors
## [2025.8.200] - 2025-08-09
- Version change only
## [2025.8.2] - 2025-08-09
- Version change only
## [2025.08.1] - 2025-08-09
### Added
- Load procedures from all workspace files and PSC-referenced scripts for completion
- Extension icon
## [0.4.14] - 2025-08-04
### Added
- `LIB_GE_command_buffer_edit_*` support in the parser
### Fixed
- Namespace calls in POCO code
- Semantic highlighting of variables in arrays
## [0.4.12] - 2025-08-03
### Added
- More semantic tokens and completion items
## [0.4.10] - 2025-07-28
### Added
- Linter based on tclint
### Changed
- Use tclint as parser and formatter
## [0.4.9] - 2025-07-22
### Added
- More completion items
## [0.4.8] - 2025-07-21
### Added
- More NX functions and hover documentation
## [0.4.7] - 2025-07-21
### Added
- Formatter settings
### Fixed
- Formatter fixes
## [0.4.6] - 2025-07-21
### Fixed
- `@` in buffers when formatting
## [0.4.5] - 2025-07-21
### Fixed
- Formatting of buffers
## [0.4.4] - 2025-07-21
### Fixed
- Formatter fixes
## [0.4.3] - 2025-07-21
### Fixed
- Formatting fixes
## [0.4.2] - 2025-07-20
### Added
- Initial release with syntax highlighting, formatting, auto-completion (including `TYPE`), hover, and DEF file support
+97 -3
View File
@@ -1,6 +1,6 @@
# NX Postprocessor Support
A comprehensive VS Code extension providing language support for NX CAM postprocessor development, including CDL, TCL, and DEF files.
A comprehensive VS Code extension providing language support and remote debugging for NX CAM postprocessor development, including CDL, TCL, and DEF files.
## Features
@@ -9,6 +9,14 @@ A comprehensive VS Code extension providing language support for NX CAM postproc
- **Multi-language Support** - Supports NX CDL, TCL, and DEF file formats
- **Intelligent Code Analysis** - Linting and error detection for postprocessor code
- **Auto-completion** - Context-aware code completion for faster development
- **Signature Help** - Shows parameters and documentation for custom and NX procedures
- **Call Hierarchy** - Traces incoming and outgoing calls between custom TCL procedures and MOM event handlers
- **Document Highlights** - Highlights all reads, writes, and calls of the symbol under the cursor
- **Context-aware Completion** - Prioritizes local symbols and suggests variables, procedures, namespaces, paths, Tcl subcommands, valid argument values, and options based on cursor context
- **PSC and TclOO Classes** - Indexes Tcl scripts referenced by PSC layers, including external folders. Classes from indexed files provide method completion, signature help, parameter hints, and class highlighting in other files. PSC script names may omit `.tcl`; relative folders resolve from the PSC directory, and environment-variable folders are supported. Missing scripts are reported in the output channel; encrypted libraries cannot supply static class metadata.
- **DEF Block Templates and Addresses** - Reads the `.def` files listed under `<DefinedEvents>` in PSC layers and suggests their `BLOCK_TEMPLATE` and `ADDRESS` names, see [DEF block templates and addresses](#def-block-templates-and-addresses)
- **Tcl Snippets** - Inserts placeholder-based structures for `if`, `foreach`, `proc`, `switch`, `try`, and `dict for`
- **NX Tcl Remote Debugger** - Breakpoints, stepping, call stack, scopes, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops directly in a running NX Post process
## Supported File Types
@@ -19,10 +27,14 @@ A comprehensive VS Code extension providing language support for NX CAM postproc
## Installation
1. Install from the VS Code Marketplace
2. Install Python 3.8 or higher
2. Install Python 3.12 or higher
3. Open any `.cdl`, `.tcl`, or `.def` file
4. The extension will automatically activate and provide language support
The former standalone `NX Tcl Remote Debugger` extension is no longer required. Disable or
uninstall `local-nx.nx-tcl-debug` before using the integrated debugger because both extensions
register the same `nx-tcl` debug type.
## Configuration
The extension can be configured through VS Code settings:
@@ -30,6 +42,81 @@ The extension can be configured through VS Code settings:
- `nx-post-support.interpreter` - Specify custom Python interpreter path for the language server
- `nx-post-support.formatter` - Enable/disable the TCL formatter (default: false)
- `nx-post-support.inlayHint` - Enable/disable inlay Hints (default: true)
- `nx-post-support.inlayHints.parameterNames` - Show parameter names for `all`, only `literals`, or `none` (default: `all`)
- `nx-post-support.inlayHints.suppressWhenArgumentMatchesName` - Hide redundant hints such as `value:` before `$value` (default: true)
TCL files default to unlimited inlay hint length so that VS Code does not
truncate later parameter names on a line. An explicit user setting for
`editor.inlayHints.maximumLength` still takes precedence.
## DEF Block Templates and Addresses
The language server resolves the `.def` files of every PSC layer's `<DefinedEvents>` section the
same way as layer scripts (relative to the PSC, `SubFolder`, environment variables, missing `.def`
extension) and collects all `BLOCK_TEMPLATE` and `ADDRESS` declarations. Changes to PSC or DEF files
are picked up automatically.
| Command | Suggestions |
|---|---|
| `MOM_do_template` | Block templates, then variables |
| `MOM_ask_address_value` | Addresses, then variables |
| `MOM_force`, `MOM_suppress` | 1st argument: `Always`, `Once`, `Off`, then variables; further arguments: addresses, then variables |
Block templates, addresses, and `Always|Once|Off` are inserted in quotes (`"steady_rest"`); a quote
that is already typed or auto-closed is replaced instead of doubled. Variables are inserted with a
leading `$`. Typing `$` yourself still shows variables only.
Two keywords list all loaded names anywhere in a Tcl file:
- `BLOCK_LIST` - shows all block templates
- `ADDR_LIST` - shows all addresses
Select the keyword from the completion list (or type it completely) to open the list; text typed
directly after the keyword, such as `ADDR_LISTSP`, narrows it. The keyword is replaced by the
selected quoted name.
## NX Tcl Remote Debugger
### Add a VS Code attach configuration
Create `.vscode/launch.json` through **Run and Debug: create a launch.json file** and select
**NX Tcl: Attach to NX Post**, or use this configuration:
```json
{
"version": "0.2.0",
"configurations": [
{
"type": "nx-tcl",
"request": "attach",
"name": "Attach to NX Post Tcl",
"host": "127.0.0.1",
"port": 4711,
"connectTimeout": 120000,
"stopOnEntry": false,
"breakOnError": true,
"localRoot": "${workspaceFolder}"
}
]
}
```
When VS Code and NX see the source through different roots, set `remoteRoot` to the root used by
NX and keep `localRoot` as the corresponding workspace root.
### Start debugging
1. Set breakpoints on executable Tcl or DEF commands.
2. Start **Attach to NX Post Tcl** in VS Code before running the postprocessor.
3. Start postprocessing in NX.
4. Use Continue, Step Over, Step Into, Step Out, Pause, Variables, Watch, and the Debug Console as
with a normal source debugger.
Breakpoints remain active and stop at every real invocation. Use a hit condition such as `1` for
a one-time stop. Blank lines, comments, declarations, and multiline Tcl commands may be relocated
to the nearest executable command; VS Code shows the resolved line. Dynamic
`LIB_GE_command_buffer_edit_*` bodies are mapped back to their original source and owning Tcl
procedure.
## Usage
@@ -38,8 +125,15 @@ Simply open any supported file type and enjoy:
- Syntax highlighting
- Error detection and linting
- Code completion
- Code formatting (Format Document command)
- Code formatting (Format Document command), including `uplevel` bodies and a space after `#` in comments
- Hover information
- Signature help while entering procedure arguments
- Incoming and outgoing call hierarchy for custom procedures and MOM event handlers
- Document-wide highlights for procedure and variable occurrences
- Context-aware completion with local symbols ranked before workspace and built-in symbols, plus semantic arguments, local paths, Tcl subcommands, and options such as `string compare -nocase`
- Placeholder-based snippets for common Tcl control structures and procedures
- Block template and address suggestions from PSC DEF files, including `BLOCK_LIST` and `ADDR_LIST`
- Remote NX Tcl debugging with breakpoints and full stepping
## Contributing
+22
View File
@@ -14,6 +14,7 @@
"vscode-languageclient": "^9.0.1"
},
"devDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/node": "^22.10.5",
"@types/vscode": "^1.96.0"
},
@@ -21,6 +22,27 @@
"vscode": "^1.96.0"
}
},
"node_modules/@types/fs-extra": {
"version": "11.0.4",
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz",
"integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/jsonfile": "*",
"@types/node": "*"
}
},
"node_modules/@types/jsonfile": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz",
"integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "22.10.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz",
+1
View File
@@ -12,6 +12,7 @@
"vscode-languageclient": "^9.0.1"
},
"devDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/node": "^22.10.5",
"@types/vscode": "^1.96.0"
}
+118
View File
@@ -0,0 +1,118 @@
export interface CdlEventHandler {
eventName: string
parameterNames: string[]
toggleOffParameterNames?: string[]
}
function structuralCode(line: string): string {
let result = ""
let inString = false
let escaped = false
for (const character of line) {
if (escaped) {
escaped = false
result += inString ? " " : character
continue
}
if (character === "\\") {
escaped = true
result += inString ? " " : character
continue
}
if (character === '"') {
inString = !inString
result += " "
continue
}
if (character === "#" && !inString) {
break
}
result += inString ? " " : character
}
return result
}
export function cdlEventHandlerAtLine(
source: string,
declarationLine: number
): CdlEventHandler | undefined {
const lines = source.split(/\r?\n/)
const declaration = lines[declarationLine]
if (declaration === undefined) {
return undefined
}
const eventMatch = /^\s*EVENT\s+([^\s{]+)/.exec(structuralCode(declaration))
if (!eventMatch) {
return undefined
}
const parameterNames: string[] = []
const toggleOffParameterNames: string[] = []
let currentParameter: string | undefined
let eventOpened = false
let depth = 0
eventLines: for (let lineNumber = declarationLine; lineNumber < lines.length; lineNumber++) {
const line = lines[lineNumber]
const code = structuralCode(line)
const tokens = code.match(/[{}]|[^\s{}]+/g) ?? []
for (let index = 0; index < tokens.length; index++) {
const token = tokens[index]
if (token === "{") {
eventOpened = true
depth++
} else if (token === "}" && eventOpened) {
depth--
if (depth === 1) currentParameter = undefined
if (depth <= 0) break eventLines
} else if (depth === 1 && token === "PARAM") {
const name = tokens[index + 1]
if (name && name !== "{" && name !== "}") {
currentParameter = name
parameterNames.push(name)
index++
}
} else if (depth === 2 && currentParameter && token === "TOGGLE") {
if (tokens[index + 1]?.toLowerCase() === "off") {
toggleOffParameterNames.push(currentParameter)
}
}
}
}
return {
eventName: eventMatch[1],
parameterNames,
toggleOffParameterNames
}
}
function momEventName(eventName: string): string {
return `MOM_${eventName.replace(/^MOM_/i, "")}`
}
function momVariableName(parameterName: string): string {
return `mom_${parameterName.replace(/^mom_/i, "")}`
}
export function createCdlEventHandlerSnippet(handler: CdlEventHandler): string {
const toggleOffParameters = new Set(handler.toggleOffParameterNames ?? [])
const globals = [
...new Set(handler.parameterNames.flatMap((parameter) => {
const variable = momVariableName(parameter)
return toggleOffParameters.has(parameter) ? [variable, `${variable}_defined`] : [variable]
}))
]
const lines = [`proc ${momEventName(handler.eventName)} {args} {`]
if (globals.length > 0) {
lines.push(...globals.map((variable) => ` global ${variable}`), "")
}
lines.push(" #Put your UDE Handler Tcl here", "", "}")
return lines.join("\n")
}
+127 -31
View File
@@ -1,4 +1,7 @@
import * as vscode from "vscode"
import { cdlEventHandlerAtLine, createCdlEventHandlerSnippet } from "./cdlEventHandler"
const MACHINE_HEADER_REGEX = /^MACHINE\s+\S+/
export function formatCdlFile(content: string): string {
let indentLevel = 0
@@ -37,15 +40,19 @@ export function formatDefFile(content: string): string {
}
export function isFirstLineMachine(content: string): boolean {
const lines = content.split("\n").map((line) => line.trim())
for (const line of lines) {
console.log(line)
let lineStart = 0
while (lineStart <= content.length) {
const newline = content.indexOf("\n", lineStart)
const lineEnd = newline === -1 ? content.length : newline
const line = content.slice(lineStart, lineEnd).trim()
if (line === "" || line.startsWith("#")) {
console.log("skipping line")
if (newline === -1) {
return false
}
lineStart = newline + 1
continue
}
const machineRegex = /^MACHINE\s+\S+/
return machineRegex.test(line)
return MACHINE_HEADER_REGEX.test(line)
}
return false
}
@@ -53,11 +60,9 @@ export function isFirstLineMachine(content: string): boolean {
export function diagnosticHandler(document: vscode.TextDocument) {
const diagnostics: vscode.Diagnostic[] = []
if (document.languageId === "cdl" || document.languageId === "def") {
if (!isFirstLineMachine(document.getText())) {
const range = new vscode.Range(
document.positionAt(0),
document.positionAt(document.getText().length)
)
const text = document.getText()
if (!isFirstLineMachine(text)) {
const range = new vscode.Range(document.positionAt(0), document.positionAt(text.length))
const diagnostic = new vscode.Diagnostic(
range,
"The first line should contain 'MACHINE'.",
@@ -71,7 +76,7 @@ export function diagnosticHandler(document: vscode.TextDocument) {
export function completionHandlerCdl(document: vscode.TextDocument, position: vscode.Position) {
const linePrefix = document.lineAt(position).text.substring(0, position.character)
const categories = ["MILL", "LATHE", "DRILL"]
const categories = ["MILL", "LATHE", "DRILL", "INVALID"]
if (linePrefix.endsWith("TYPE ")) {
return [
@@ -110,33 +115,124 @@ export function completionHandlerCdl(document: vscode.TextDocument, position: vs
}
export function hoverCdlHandler(document: vscode.TextDocument, position: vscode.Position) {
const wordRange = document.getWordRangeAtPosition(position)
const word = document.getText(wordRange)
const text = document.getText()
const lines = text.split("\n")
let hoverText: string | undefined
for (const line of lines) {
const words = line.split(/\s+/)
const wordIndex = words.indexOf(word)
if (wordIndex > 0) {
if (words[wordIndex - 1] === "EVENT") {
hoverText = `MOM_${word}`
} else if (words[wordIndex - 1] === "PARAM") {
hoverText = `mom_${word}`
const line = document.lineAt(position.line).text
const eventMatch = /^\s*EVENT\s+([^\s{]+)/.exec(line)
if (eventMatch) {
const eventStart = line.indexOf(eventMatch[1], eventMatch.index)
const declarationEnd = eventStart + eventMatch[1].length
if (position.character <= declarationEnd) {
const handler = cdlEventHandlerAtLine(document.getText(), position.line)
if (handler) {
const markdown = new vscode.MarkdownString()
markdown.appendCodeblock(createCdlEventHandlerSnippet(handler), "tcl")
return new vscode.Hover(
markdown,
new vscode.Range(position.line, eventMatch.index, position.line, declarationEnd)
)
}
break
}
}
if (hoverText) {
return new vscode.Hover(hoverText)
const parameterMatch = /^\s*PARAM\s+([^\s{]+)/.exec(line)
if (parameterMatch) {
const parameterStart = line.indexOf(parameterMatch[1], parameterMatch.index)
const parameterEnd = parameterStart + parameterMatch[1].length
if (position.character >= parameterStart && position.character <= parameterEnd) {
return new vscode.Hover(`mom_${parameterMatch[1].replace(/^mom_/i, "")}`)
}
}
return undefined
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
export function cdlEventAtPosition(
document: vscode.TextDocument,
position: vscode.Position
): string | undefined {
const line = document.lineAt(position.line).text
const match = /^\s*EVENT\s+([^\s{]+)/.exec(line)
if (!match) {
return undefined
}
const declarationStart = match.index
const eventEnd = line.indexOf(match[1], match.index) + match[1].length
if (position.character < declarationStart || position.character > eventEnd) {
return undefined
}
return match[1]
}
export async function definitionCdlEventHandler(
document: vscode.TextDocument,
position: vscode.Position,
token: vscode.CancellationToken
): Promise<vscode.Location[] | undefined> {
const eventName = cdlEventAtPosition(document, position)
if (!eventName) {
return undefined
}
const handlerName = `MOM_${eventName}`
try {
const symbols = await vscode.commands.executeCommand<vscode.SymbolInformation[]>(
"vscode.executeWorkspaceSymbolProvider",
handlerName
)
const indexedLocations = (symbols || [])
.filter(
(symbol) =>
symbol.kind === vscode.SymbolKind.Function &&
(symbol.name === handlerName || symbol.name.endsWith(`::${handlerName}`))
)
.map((symbol) => symbol.location)
if (indexedLocations.length > 0) {
return indexedLocations
}
} catch {
// The Tcl language server may still be starting; use the file fallback below.
}
const declaration = new RegExp(`^\\s*proc\\s+(?:::)?${escapeRegExp(handlerName)}(?=\\s|\\{)`)
const tclFiles = await vscode.workspace.findFiles(
"**/*.tcl",
"**/{.git,.nox,.venv,dist,node_modules,out}/**"
)
const locations: vscode.Location[] = []
for (const uri of tclFiles) {
if (token.isCancellationRequested) {
return undefined
}
let tclDocument: vscode.TextDocument
try {
tclDocument = await vscode.workspace.openTextDocument(uri)
} catch {
continue
}
for (let lineNumber = 0; lineNumber < tclDocument.lineCount; lineNumber++) {
const line = tclDocument.lineAt(lineNumber).text
const match = declaration.exec(line)
if (!match) {
continue
}
const start = line.indexOf(handlerName, match.index)
locations.push(
new vscode.Location(
uri,
new vscode.Range(lineNumber, start, lineNumber, start + handlerName.length)
)
)
}
}
return locations.length > 0 ? locations : undefined
}
export function tclDocumentSymbolProvider(document: vscode.TextDocument): vscode.DocumentSymbol[] {
const symbols: vscode.DocumentSymbol[] = []
const lines = document.getText().split("\n")
+75 -36
View File
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import * as fsapi from "fs-extra"
import { Disposable, env, LogOutputChannel } from "vscode"
import { Disposable, env, LogOutputChannel, workspace } from "vscode"
import { State } from "vscode-languageclient"
import {
LanguageClient,
@@ -22,7 +22,19 @@ import {
import { getLSClientTraceLevel, getProjectRoot } from "./utilities"
import { isVirtualWorkspace } from "./vscodeapi"
export type IInitOptions = { settings: ISettings[]; globalSettings: ISettings }
export type IInitOptions = {
settings: ISettings[]
globalSettings: ISettings
// Folder for the server's persistent index cache; omitted without a workspace.
indexCachePath?: string
}
let _disposables: Disposable[] = []
export function disposeServerResources(): void {
_disposables.forEach((disposable) => disposable.dispose())
_disposables = []
}
async function createServer(
settings: ISettings,
@@ -32,13 +44,26 @@ async function createServer(
initializationOptions: IInitOptions
): Promise<LanguageClient> {
const command = settings.interpreter[0]
if (!command) {
throw new Error("No Python interpreter is configured for the language server.")
}
const cwd = settings.cwd
// Set debugger path needed for debugging python code.
const newEnv = { ...process.env }
const debuggerPath = await getDebuggerPath()
const isDebugScript = await fsapi.pathExists(DEBUG_SERVER_SCRIPT_PATH)
if (newEnv.USE_DEBUGPY && debuggerPath) {
const debugRequested = newEnv.USE_DEBUGPY?.toLowerCase() === "true"
if (debugRequested && !isDebugScript) {
throw new Error(`Python debug bootstrap not found: ${DEBUG_SERVER_SCRIPT_PATH}`)
}
const debuggerPath = debugRequested ? await getDebuggerPath() : undefined
if (debugRequested && !debuggerPath) {
throw new Error(
"Python debugging was requested, but the Python Debugger extension did not provide debugpy."
)
}
if (debugRequested && debuggerPath) {
newEnv.DEBUGPY_PATH = debuggerPath
} else {
newEnv.USE_DEBUGPY = "False"
@@ -50,10 +75,13 @@ async function createServer(
// Set notification type
newEnv.LS_SHOW_NOTIFICATION = settings.showNotifications
const args =
newEnv.USE_DEBUGPY === "False" || !isDebugScript
? settings.interpreter.slice(1).concat([SERVER_SCRIPT_PATH])
: settings.interpreter.slice(1).concat([DEBUG_SERVER_SCRIPT_PATH])
const serverScript = debugRequested ? DEBUG_SERVER_SCRIPT_PATH : SERVER_SCRIPT_PATH
const interpreterArgs = settings.interpreter.slice(1)
if (debugRequested && !interpreterArgs.includes("-Xfrozen_modules=off")) {
interpreterArgs.push("-Xfrozen_modules=off")
}
const args = interpreterArgs.concat([serverScript])
traceInfo(`Python debug mode: ${debugRequested ? "enabled" : "disabled"}`)
traceInfo(`Server run command: ${[command, ...args].join(" ")}`)
const serverOptions: ServerOptions = {
@@ -63,6 +91,7 @@ async function createServer(
}
// Options to control the language client
const tclFileWatcher = workspace.createFileSystemWatcher("**/*.{tcl,psc,def}")
const clientOptions: LanguageClientOptions = {
// Register the server for python documents
documentSelector: isVirtualWorkspace()
@@ -76,56 +105,66 @@ async function createServer(
outputChannel: outputChannel,
traceOutputChannel: outputChannel,
revealOutputChannelOn: RevealOutputChannelOn.Never,
synchronize: {
fileEvents: tclFileWatcher
},
initializationOptions
}
_disposables.push(tclFileWatcher)
return new LanguageClient(serverId, serverName, serverOptions, clientOptions)
}
let _disposables: Disposable[] = []
export async function restartServer(
serverId: string,
serverName: string,
outputChannel: LogOutputChannel,
lsClient?: LanguageClient
lsClient?: LanguageClient,
indexCachePath?: string
): Promise<LanguageClient | undefined> {
if (lsClient) {
traceInfo(`Server: Stop requested`)
await lsClient.stop()
_disposables.forEach((d) => d.dispose())
_disposables = []
disposeServerResources()
}
const projectRoot = await getProjectRoot()
const workspaceSetting = await getWorkspaceSettings(serverId, projectRoot, true)
const newLSClient = await createServer(workspaceSetting, serverId, serverName, outputChannel, {
settings: await getExtensionSettings(serverId, true),
globalSettings: await getGlobalSettings(serverId, false)
})
traceInfo(`Server: Start requested.`)
_disposables.push(
newLSClient.onDidChangeState((e) => {
switch (e.newState) {
case State.Stopped:
traceVerbose(`Server State: Stopped`)
break
case State.Starting:
traceVerbose(`Server State: Starting`)
break
case State.Running:
traceVerbose(`Server State: Running`)
break
}
})
)
try {
const newLSClient = await createServer(
workspaceSetting,
serverId,
serverName,
outputChannel,
{
settings: await getExtensionSettings(serverId, true),
globalSettings: await getGlobalSettings(serverId, false),
indexCachePath
}
)
traceInfo(`Server: Start requested.`)
_disposables.push(
newLSClient.onDidChangeState((e) => {
switch (e.newState) {
case State.Stopped:
traceVerbose(`Server State: Stopped`)
break
case State.Starting:
traceVerbose(`Server State: Starting`)
break
case State.Running:
traceVerbose(`Server State: Running`)
break
}
})
)
await newLSClient.start()
const level = getLSClientTraceLevel(outputChannel.logLevel, env.logLevel)
await newLSClient.setTrace(level)
return newLSClient
} catch (ex) {
traceError(`Server: Start failed: ${ex}`)
disposeServerResources()
return undefined
}
const level = getLSClientTraceLevel(outputChannel.logLevel, env.logLevel)
await newLSClient.setTrace(level)
return newLSClient
}
+23 -3
View File
@@ -18,6 +18,12 @@ export interface ISettings {
interpreter: string[]
importStrategy: string
showNotifications: string
formatter: boolean
inlayHint: boolean
inlayHints: {
parameterNames: "all" | "literals" | "none"
suppressWhenArgumentMatchesName: boolean
}
}
export function getExtensionSettings(
@@ -80,7 +86,13 @@ export async function getWorkspaceSettings(
importStrategy: config.get<string>(`importStrategy`) ?? "useBundled",
showNotifications: config.get<string>(`showNotifications`) ?? "off",
formatter: config.get<boolean>(`formatter`) ?? true,
inlayHint: config.get<boolean>(`inlayHint`) ?? true
inlayHint: config.get<boolean>(`inlayHint`) ?? true,
inlayHints: {
parameterNames:
config.get<"all" | "literals" | "none">(`inlayHints.parameterNames`) ?? "all",
suppressWhenArgumentMatchesName:
config.get<boolean>(`inlayHints.suppressWhenArgumentMatchesName`) ?? true
}
}
return workspaceSetting
}
@@ -113,7 +125,13 @@ export async function getGlobalSettings(
importStrategy: getGlobalValue<string>(config, "importStrategy", "useBundled"),
showNotifications: getGlobalValue<string>(config, "showNotifications", "off"),
formatter: config.get<boolean>(`formatter`) ?? true,
inlayHint: config.get<boolean>(`inlayHint`) ?? true
inlayHint: config.get<boolean>(`inlayHint`) ?? true,
inlayHints: {
parameterNames:
config.get<"all" | "literals" | "none">(`inlayHints.parameterNames`) ?? "all",
suppressWhenArgumentMatchesName:
config.get<boolean>(`inlayHints.suppressWhenArgumentMatchesName`) ?? true
}
}
return setting
}
@@ -129,7 +147,9 @@ export function checkIfConfigurationChanged(
`${namespace}.importStrategy`,
`${namespace}.showNotifications`,
`${namespace}.formatter`,
`${namespace}.inlayHint`
`${namespace}.inlayHint`,
`${namespace}.inlayHints.parameterNames`,
`${namespace}.inlayHints.suppressWhenArgumentMatchesName`
]
const changed = settings.map((s) => e.affectsConfiguration(s))
return changed.includes(true)
+650
View File
@@ -0,0 +1,650 @@
'use strict';
const path = require('path');
const { RuntimeConnection, encodeHex, decodeHex } = require('./protocol');
const THREAD_ID = 1;
class NxDebugAdapter {
constructor(input = process.stdin, output = process.stdout) {
this.input = input;
this.output = output;
this.inputBuffer = Buffer.alloc(0);
this.sequence = 1;
this.runtime = null;
this.terminated = false;
this.explicitDisconnect = false;
this.localRoot = '';
this.remoteRoot = '';
this.nextBreakpointId = 1;
// VS Code can expose the same physical file through more than one source
// identity (for example, drive mappings or differently cased workspace
// roots). SETBPS replaces all breakpoints for a runtime path, so retain
// each DAP source group and send their union to NX.
this.sourceBreakpointGroups = new Map();
this.pendingStartRequest = null;
this.resumeInProgress = 0;
this.pendingStoppedEvents = [];
this.isPaused = false;
// DAP frame/variable IDs can outlive a stop in the VS Code UI. Runtime
// levels and references, however, are valid only for the current Tcl
// pause. Give every stop adapter-owned IDs so a late request can never
// accidentally address a same-numbered frame from a later pause.
this.nextFrameId = 1;
this.frameReferences = new Map();
this.runtimeToFrameReference = new Map();
this.nextVariablesReference = 1;
this.variableReferences = new Map();
this.runtimeToVariableReference = new Map();
input.on('data', (chunk) => this.onData(chunk));
input.on('end', () => this.dispose());
input.resume();
}
onData(chunk) {
this.inputBuffer = Buffer.concat([this.inputBuffer, chunk]);
while (true) {
const headerEnd = this.inputBuffer.indexOf('\r\n\r\n');
if (headerEnd < 0) return;
const header = this.inputBuffer.subarray(0, headerEnd).toString('ascii');
const match = /Content-Length:\s*(\d+)/i.exec(header);
if (!match) throw new Error('missing DAP Content-Length header');
const contentLength = Number(match[1]);
const messageEnd = headerEnd + 4 + contentLength;
if (this.inputBuffer.length < messageEnd) return;
const payload = this.inputBuffer.subarray(headerEnd + 4, messageEnd).toString('utf8');
this.inputBuffer = this.inputBuffer.subarray(messageEnd);
const message = JSON.parse(payload);
if (message.type === 'request') {
this.handleRequest(message).catch((error) => this.sendErrorResponse(message, error));
}
}
}
send(message) {
message.seq = this.sequence++;
const json = JSON.stringify(message);
this.output.write(`Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`);
}
sendResponse(request, body = {}) {
this.send({
type: 'response',
request_seq: request.seq,
success: true,
command: request.command,
body,
});
}
sendErrorResponse(request, error) {
this.send({
type: 'response',
request_seq: request.seq,
success: false,
command: request.command,
message: error instanceof Error ? error.message : String(error),
});
}
sendEvent(event, body = {}) {
this.send({ type: 'event', event, body });
}
async handleRequest(request) {
const args = request.arguments || {};
switch (request.command) {
case 'initialize':
this.sendResponse(request, {
supportsConfigurationDoneRequest: true,
supportsConditionalBreakpoints: true,
supportsHitConditionalBreakpoints: true,
supportsLogPoints: true,
supportsEvaluateForHovers: true,
supportsSetVariable: true,
supportsExceptionFilterOptions: true,
exceptionBreakpointFilters: [
{ filter: 'tclError', label: 'Tcl errors', description: 'Break when a Tcl command returns TCL_ERROR' },
],
supportsTerminateRequest: true,
});
break;
case 'attach':
case 'launch':
await this.attach(args);
this.pendingStartRequest = request;
this.sendEvent('initialized');
break;
case 'configurationDone':
await this.runtime.request('CONFIGDONE');
this.sendResponse(request);
if (this.pendingStartRequest) {
this.sendResponse(this.pendingStartRequest);
this.pendingStartRequest = null;
}
break;
case 'setBreakpoints':
await this.setBreakpoints(request, args);
break;
case 'setExceptionBreakpoints': {
const filters = args.filters || [];
await this.runtime.request('SETEXCEPTIONS', filters);
this.sendResponse(request, {
breakpoints: filters.map(() => ({ verified: true })),
});
break;
}
case 'threads':
this.sendResponse(request, { threads: [{ id: THREAD_ID, name: 'NX Post Tcl' }] });
break;
case 'stackTrace':
await this.stackTrace(request);
break;
case 'scopes':
await this.scopes(request, args);
break;
case 'variables':
await this.variables(request, args);
break;
case 'evaluate':
await this.evaluate(request, args);
break;
case 'setVariable':
await this.setVariable(request, args);
break;
case 'continue':
await this.resume(request, 'CONTINUE', { allThreadsContinued: true });
break;
case 'next':
await this.resume(request, 'NEXT');
break;
case 'stepIn':
await this.resume(request, 'STEPIN');
break;
case 'stepOut':
await this.resume(request, 'STEPOUT');
break;
case 'pause':
await this.runtime.request('PAUSE');
this.sendResponse(request);
break;
case 'disconnect':
case 'terminate':
await this.disconnect(request);
break;
default:
this.sendResponse(request);
break;
}
}
async attach(args) {
const host = args.host || '127.0.0.1';
const port = Number(args.port || 4711);
const timeout = Number(args.connectTimeout || 120000);
this.localRoot = args.localRoot ? path.resolve(args.localRoot) : '';
this.remoteRoot = args.remoteRoot ? this.normalizePath(args.remoteRoot) : '';
this.sourceBreakpointGroups.clear();
this.isPaused = false;
this.resetStoppedReferences(true);
this.runtime = new RuntimeConnection();
this.runtime.on('event', (name, fields) => this.onRuntimeEvent(name, fields));
this.runtime.on('runtimeError', (error) => {
this.sendEvent('output', { category: 'stderr', output: `NX: ${error.message}\n` });
});
this.runtime.on('close', () => {
if (!this.explicitDisconnect) this.sendTerminated();
});
const started = Date.now();
let lastError;
while (Date.now() - started < timeout) {
try {
await this.runtime.connect(host, port);
lastError = null;
break;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
if (lastError) {
throw new Error(`could not connect to NX at ${host}:${port}: ${lastError.message}`);
}
await this.runtime.request('CONFIG', [args.stopOnEntry ? 1 : 0, args.breakOnError ? 1 : 0]);
}
async setBreakpoints(request, args) {
const sourcePath = args.source && args.source.path ? args.source.path : '';
const remotePath = this.toRemotePath(sourcePath);
const sourceKey = this.breakpointSourceKey(args.source, sourcePath);
const remoteKey = this.breakpointRemoteKey(remotePath);
const requested = args.breakpoints || (args.lines || []).map((line) => ({ line }));
const records = requested.map((breakpoint) => ({
id: this.nextBreakpointId++,
line: breakpoint.line,
condition: breakpoint.condition || '',
hitCondition: breakpoint.hitCondition || '',
logMessage: breakpoint.logMessage || '',
}));
if (!this.isTclSource(sourcePath)) {
this.sourceBreakpointGroups.delete(sourceKey);
// VS Code forwards breakpoints from every language to every active debug
// session. Clear any stale runtime entry left by an older adapter, but do
// not make the NX Tcl runtime parse or instrument a Python source file.
await this.runtime.request('SETBPS', [encodeHex(remotePath), 0]);
this.sendResponse(request, {
breakpoints: records.map((breakpoint) => ({
id: breakpoint.id,
verified: false,
line: breakpoint.line,
source: args.source,
message: 'NX Tcl Remote Debugger supports breakpoints only in .tcl and .def files',
})),
});
return;
}
this.sourceBreakpointGroups.set(sourceKey, { remotePath, remoteKey, records });
// Collapse exact duplicates shared by aliases, while remembering which
// runtime breakpoint represents every DAP breakpoint ID. This keeps hit
// counts and log points from firing twice for one Tcl command.
const mergedBreakpoints = [];
const mergedBySignature = new Map();
const representativeById = new Map();
for (const group of this.sourceBreakpointGroups.values()) {
if (group.remoteKey !== remoteKey) continue;
for (const breakpoint of group.records) {
const signature = this.breakpointSignature(breakpoint);
let representative = mergedBySignature.get(signature);
if (!representative) {
representative = breakpoint;
mergedBySignature.set(signature, representative);
mergedBreakpoints.push(representative);
}
representativeById.set(breakpoint.id, representative.id);
}
}
const fields = [encodeHex(remotePath), mergedBreakpoints.length];
for (const breakpoint of mergedBreakpoints) {
fields.push(
breakpoint.id,
breakpoint.line,
encodeHex(breakpoint.condition),
encodeHex(breakpoint.hitCondition),
encodeHex(breakpoint.logMessage),
);
}
const runtimeResponse = await this.runtime.request('SETBPS', fields);
const responseFields = runtimeResponse.fields || [];
const responseCount = Number(responseFields[0] || 0);
const responseStride = responseFields.length >= 1 + responseCount * 4 ? 4 : 2;
const runtimeBreakpoints = new Map();
let responseCursor = 1;
for (let index = 0; index < responseCount; index++) {
const id = Number(responseFields[responseCursor++]);
const line = Number(responseFields[responseCursor++]);
let verified = true;
let message = '';
if (responseStride === 4) {
verified = responseFields[responseCursor++] === '1';
message = decodeHex(responseFields[responseCursor++]);
}
runtimeBreakpoints.set(id, { line, verified, message });
}
this.sendResponse(request, {
breakpoints: records.map((breakpoint) => {
const representativeId = representativeById.get(breakpoint.id);
const runtimeBreakpoint = runtimeBreakpoints.get(representativeId);
const result = {
id: breakpoint.id,
verified: runtimeBreakpoint ? runtimeBreakpoint.verified : false,
line: runtimeBreakpoint && runtimeBreakpoint.line ? runtimeBreakpoint.line : breakpoint.line,
source: args.source,
};
if (runtimeBreakpoint && runtimeBreakpoint.message) result.message = runtimeBreakpoint.message;
return result;
}),
});
}
async stackTrace(request) {
if (!this.isPaused) {
this.sendResponse(request, { stackFrames: [], totalFrames: 0 });
return;
}
const { fields } = await this.runtime.request('STACK');
const count = Number(fields[0] || 0);
const stackFrames = [];
let cursor = 1;
for (let index = 0; index < count; index++) {
const runtimeId = Number(fields[cursor++]);
const name = decodeHex(fields[cursor++]);
const remoteFile = decodeHex(fields[cursor++]);
const line = Number(fields[cursor++]);
const column = Number(fields[cursor++]);
const localFile = this.toLocalPath(remoteFile);
const frame = {
id: this.toAdapterFrameId(runtimeId),
name: name || '<Tcl>',
line: line || 1,
column: column || 1,
};
if (localFile) frame.source = { name: path.basename(localFile), path: localFile };
stackFrames.push(frame);
}
this.sendResponse(request, { stackFrames, totalFrames: stackFrames.length });
}
async scopes(request, args) {
const runtimeFrameId = this.frameReferences.get(Number(args.frameId));
if (!this.isPaused || runtimeFrameId === undefined) {
this.sendResponse(request, { scopes: [] });
return;
}
const { fields } = await this.runtime.request('SCOPES', [runtimeFrameId]);
const count = Number(fields[0] || 0);
const scopes = [];
let cursor = 1;
for (let index = 0; index < count; index++) {
const variablesReference = this.toAdapterVariablesReference(Number(fields[cursor++]));
const name = decodeHex(fields[cursor++]);
cursor++; // wire label: expensive
const expensive = fields[cursor++] === '1';
scopes.push({ name, variablesReference, expensive });
}
this.sendResponse(request, { scopes });
}
async variables(request, args) {
const runtimeReference = this.variableReferences.get(Number(args.variablesReference));
if (!this.isPaused || runtimeReference === undefined) {
this.sendResponse(request, { variables: [] });
return;
}
const { fields } = await this.runtime.request('VARIABLES', [
runtimeReference,
args.start || 0,
args.count || 0,
encodeHex(args.filter || ''),
]);
const count = Number(fields[0] || 0);
const variables = [];
let cursor = 1;
for (let index = 0; index < count; index++) {
variables.push({
name: decodeHex(fields[cursor++]),
value: decodeHex(fields[cursor++]),
type: decodeHex(fields[cursor++]),
variablesReference: this.toAdapterVariablesReference(Number(fields[cursor++])),
indexedVariables: Number(fields[cursor++]) || undefined,
namedVariables: Number(fields[cursor++]) || undefined,
});
}
this.sendResponse(request, { variables });
}
async evaluate(request, args) {
// VS Code can refresh Watch/Hover expressions after Continue but before
// it has processed the next stopped event. Do not forward that transient
// request to the running NX interpreter (which correctly rejects it).
if (!this.isPaused) {
this.sendResponse(request, {
result: '<NX is running; evaluation is available while paused>',
type: 'unavailable',
variablesReference: 0,
});
return;
}
let runtimeFrameId = 0;
if (Number(args.frameId) > 0) {
runtimeFrameId = this.frameReferences.get(Number(args.frameId));
if (runtimeFrameId === undefined) {
this.sendResponse(request, {
result: '<stack frame is no longer available>',
type: 'unavailable',
variablesReference: 0,
});
return;
}
}
const { fields } = await this.runtime.request('EVALUATE', [runtimeFrameId, encodeHex(args.expression || '')]);
this.sendResponse(request, {
result: decodeHex(fields[0]),
type: decodeHex(fields[1]),
variablesReference: this.toAdapterVariablesReference(Number(fields[2])),
indexedVariables: Number(fields[3]) || undefined,
namedVariables: Number(fields[4]) || undefined,
});
}
async setVariable(request, args) {
const runtimeReference = this.variableReferences.get(Number(args.variablesReference));
if (!this.isPaused || runtimeReference === undefined) {
throw new Error('variable is no longer available');
}
const { fields } = await this.runtime.request('SETVARIABLE', [
runtimeReference,
encodeHex(args.name),
encodeHex(args.value),
]);
this.sendResponse(request, {
value: decodeHex(fields[0]),
type: decodeHex(fields[1]),
variablesReference: this.toAdapterVariablesReference(Number(fields[2])),
indexedVariables: Number(fields[3]) || undefined,
namedVariables: Number(fields[4]) || undefined,
});
}
async resume(request, command, responseBody = {}) {
this.isPaused = false;
this.resetStoppedReferences();
this.resumeInProgress++;
try {
await this.runtime.request(command);
// The runtime can hit the next breakpoint immediately and place its
// STOPPED event in the same TCP packet as this response. Always publish
// the DAP resume transition before releasing such a queued stop.
this.sendResponse(request, responseBody);
this.sendEvent('continued', { threadId: THREAD_ID, allThreadsContinued: true });
} finally {
this.resumeInProgress--;
if (this.resumeInProgress === 0) this.flushStoppedEvents();
}
}
async disconnect(request) {
this.explicitDisconnect = true;
this.isPaused = false;
this.resetStoppedReferences();
if (this.runtime && !this.runtime.closed) {
try {
await this.runtime.request('DISCONNECT', [], 2000);
} catch (_) {
// The runtime may close immediately after acknowledging detach.
}
this.runtime.close();
}
this.sendResponse(request);
this.sendTerminated();
}
onRuntimeEvent(name, fields) {
switch (name) {
case 'HELLO':
this.sendEvent('output', {
category: 'console',
output: `NX runtime ${decodeHex(fields[0])}, Tcl ${decodeHex(fields[1])}, PID ${fields[2]}\n`,
});
break;
case 'STOPPED':
this.isPaused = true;
this.resetStoppedReferences();
this.publishStoppedEvent({
reason: decodeHex(fields[0]) || 'pause',
threadId: THREAD_ID,
allThreadsStopped: true,
text: decodeHex(fields[3]) || undefined,
});
break;
case 'OUTPUT':
this.sendEvent('output', { category: 'stdout', output: decodeHex(fields[0]) });
break;
default:
this.sendEvent('output', { category: 'console', output: `NX event ${name}\n` });
}
}
publishStoppedEvent(body) {
if (this.resumeInProgress > 0) {
this.pendingStoppedEvents.push(body);
return;
}
this.sendEvent('stopped', body);
}
flushStoppedEvents() {
const queued = this.pendingStoppedEvents;
this.pendingStoppedEvents = [];
for (const body of queued) this.sendEvent('stopped', body);
}
resetStoppedReferences(resetCounters = false) {
this.frameReferences.clear();
this.runtimeToFrameReference.clear();
this.variableReferences.clear();
this.runtimeToVariableReference.clear();
if (resetCounters) {
this.nextFrameId = 1;
this.nextVariablesReference = 1;
}
}
toAdapterFrameId(runtimeId) {
const normalized = Number(runtimeId);
if (!Number.isFinite(normalized) || normalized <= 0) return 0;
let adapterId = this.runtimeToFrameReference.get(normalized);
if (adapterId !== undefined) return adapterId;
adapterId = this.nextFrameId++;
this.runtimeToFrameReference.set(normalized, adapterId);
this.frameReferences.set(adapterId, normalized);
return adapterId;
}
toAdapterVariablesReference(runtimeReference) {
const normalized = Number(runtimeReference);
if (!Number.isFinite(normalized) || normalized <= 0) return 0;
let adapterReference = this.runtimeToVariableReference.get(normalized);
if (adapterReference !== undefined) return adapterReference;
adapterReference = this.nextVariablesReference++;
this.runtimeToVariableReference.set(normalized, adapterReference);
this.variableReferences.set(adapterReference, normalized);
return adapterReference;
}
isTclSource(sourcePath) {
const extension = path.extname(String(sourcePath || '')).toLowerCase();
return extension === '.tcl' || extension === '.def';
}
breakpointSourceKey(source = {}, sourcePath = '') {
if (source && Number(source.sourceReference) > 0) {
return `reference:${source.sourceReference}`;
}
// Deliberately preserve spelling and case here: they are precisely what
// distinguish two VS Code source identities that map to the same runtime
// path. Runtime comparison is handled separately below.
return `path:${String(sourcePath || '')}`;
}
breakpointRemoteKey(remotePath) {
let normalized = this.normalizePath(remotePath);
if (process.platform === 'win32' || /^[A-Za-z]:\//.test(normalized) || normalized.startsWith('//')) {
normalized = normalized.toLowerCase();
}
return normalized;
}
breakpointSignature(breakpoint) {
return JSON.stringify([
Number(breakpoint.line),
breakpoint.condition || '',
breakpoint.hitCondition || '',
breakpoint.logMessage || '',
]);
}
normalizePath(value) {
const normalized = String(value || '').replace(/\\/g, '/');
if (normalized === '/' || /^[A-Za-z]:\/$/.test(normalized)) return normalized;
return normalized.replace(/\/+$/, '');
}
samePathPrefix(candidate, prefix) {
let normalizedCandidate = this.normalizePath(candidate);
let normalizedPrefix = this.normalizePath(prefix);
if (process.platform === 'win32') {
normalizedCandidate = normalizedCandidate.toLowerCase();
normalizedPrefix = normalizedPrefix.toLowerCase();
}
if (normalizedCandidate === normalizedPrefix) return true;
const boundaryPrefix = normalizedPrefix.endsWith('/') ? normalizedPrefix : `${normalizedPrefix}/`;
return normalizedCandidate.startsWith(boundaryPrefix);
}
toRemotePath(localPath) {
const normalized = this.normalizePath(path.resolve(localPath || '.'));
const localRoot = this.normalizePath(this.localRoot);
if (localRoot && this.remoteRoot && this.samePathPrefix(normalized, localRoot)) {
return this.remoteRoot + normalized.slice(localRoot.length);
}
return normalized;
}
toLocalPath(remotePath) {
const normalized = this.normalizePath(remotePath);
if (this.remoteRoot && this.localRoot && this.samePathPrefix(normalized, this.remoteRoot)) {
const suffix = normalized.slice(this.remoteRoot.length).replace(/^\//, '');
return path.join(this.localRoot, ...suffix.split('/'));
}
return process.platform === 'win32' ? normalized.replace(/\//g, '\\') : normalized;
}
sendTerminated() {
if (this.terminated) return;
this.terminated = true;
this.sendEvent('terminated');
}
dispose() {
if (this.runtime) this.runtime.close();
}
}
if (require.main === module) {
new NxDebugAdapter();
}
module.exports = { NxDebugAdapter, THREAD_ID };
+68
View File
@@ -0,0 +1,68 @@
'use strict';
const { PassThrough } = require('node:stream');
const { NxDebugAdapter } = require('./debugAdapter');
function frameMessage(message) {
const json = JSON.stringify(message);
return `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`;
}
class NxInlineDebugAdapter {
constructor() {
this.input = new PassThrough();
this.output = new PassThrough();
this.outputBuffer = Buffer.alloc(0);
this.listeners = new Set();
this.disposed = false;
this.onDidSendMessage = (listener, thisArgs, disposables) => {
const registration = { listener, thisArgs };
this.listeners.add(registration);
const disposable = {
dispose: () => this.listeners.delete(registration),
};
if (Array.isArray(disposables)) disposables.push(disposable);
return disposable;
};
this.output.on('data', (chunk) => this.onOutput(chunk));
this.adapter = new NxDebugAdapter(this.input, this.output);
}
handleMessage(message) {
if (!this.disposed) this.input.write(frameMessage(message));
}
onOutput(chunk) {
this.outputBuffer = Buffer.concat([this.outputBuffer, chunk]);
while (true) {
const headerEnd = this.outputBuffer.indexOf('\r\n\r\n');
if (headerEnd < 0) return;
const header = this.outputBuffer.subarray(0, headerEnd).toString('ascii');
const match = /Content-Length:\s*(\d+)/i.exec(header);
if (!match) throw new Error('missing DAP Content-Length header from NX adapter');
const contentLength = Number(match[1]);
const messageEnd = headerEnd + 4 + contentLength;
if (this.outputBuffer.length < messageEnd) return;
const payload = this.outputBuffer.subarray(headerEnd + 4, messageEnd).toString('utf8');
this.outputBuffer = this.outputBuffer.subarray(messageEnd);
const message = JSON.parse(payload);
for (const { listener, thisArgs } of [...this.listeners]) {
listener.call(thisArgs, message);
}
}
}
dispose() {
if (this.disposed) return;
this.disposed = true;
this.adapter.dispose();
this.input.end();
this.output.destroy();
this.listeners.clear();
}
}
module.exports = { NxInlineDebugAdapter, frameMessage };
+116
View File
@@ -0,0 +1,116 @@
'use strict';
const net = require('net');
const { EventEmitter } = require('events');
function encodeHex(value) {
return Buffer.from(String(value ?? ''), 'utf8').toString('hex') || '-';
}
function decodeHex(value) {
if (!value || value === '-') return '';
return Buffer.from(value, 'hex').toString('utf8');
}
class RuntimeConnection extends EventEmitter {
constructor() {
super();
this.socket = null;
this.buffer = '';
this.nextRequestId = 1;
this.pending = new Map();
this.closed = false;
}
connect(host, port) {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host, port });
const onError = (error) => {
socket.destroy();
reject(error);
};
socket.once('error', onError);
socket.once('connect', () => {
socket.removeListener('error', onError);
this.socket = socket;
this.closed = false;
socket.setEncoding('utf8');
socket.on('data', (chunk) => this.onData(chunk));
socket.on('error', (error) => this.emit('runtimeError', error));
socket.on('close', () => this.onClose());
resolve();
});
});
}
request(command, fields = [], timeoutMs = 30000) {
if (!this.socket || this.closed) {
return Promise.reject(new Error('NX runtime is not connected'));
}
const id = this.nextRequestId++;
const normalized = fields.map((field) => String(field ?? ''));
if (normalized.some((field) => field.includes('\t') || field.includes('\n'))) {
return Promise.reject(new Error('protocol fields must be hex-encoded before transmission'));
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`NX ${command} request timed out`));
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer, command });
this.socket.write(['REQ', id, command, ...normalized].join('\t') + '\n');
});
}
close() {
this.closed = true;
if (this.socket) this.socket.destroy();
this.socket = null;
}
onData(chunk) {
this.buffer += chunk;
let newline;
while ((newline = this.buffer.indexOf('\n')) >= 0) {
const line = this.buffer.slice(0, newline).replace(/\r$/, '');
this.buffer = this.buffer.slice(newline + 1);
if (line) this.onLine(line);
}
}
onLine(line) {
const fields = line.split('\t');
if (fields[0] === 'RES') {
const id = Number(fields[1]);
const pending = this.pending.get(id);
if (!pending) return;
this.pending.delete(id);
clearTimeout(pending.timer);
if (fields[2] === 'OK') {
pending.resolve({ command: fields[3], fields: fields.slice(4) });
} else {
pending.reject(new Error(decodeHex(fields[4]) || `NX ${pending.command} failed`));
}
return;
}
if (fields[0] === 'EVENT') {
this.emit('event', fields[1], fields.slice(2));
return;
}
this.emit('runtimeError', new Error(`unrecognized NX message: ${line}`));
}
onClose() {
if (this.closed) return;
this.closed = true;
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(new Error('NX runtime disconnected'));
}
this.pending.clear();
this.emit('close');
}
}
module.exports = { RuntimeConnection, encodeHex, decodeHex };
+42
View File
@@ -0,0 +1,42 @@
import * as vscode from "vscode"
// The adapter is shared with the standalone NX Tcl Remote Debugger. It is
// bundled by esbuild into this extension, so no child process or additional
// VS Code extension is required.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { NxInlineDebugAdapter } = require("./inlineAdapter")
class NxDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory {
createDebugAdapterDescriptor(): vscode.ProviderResult<vscode.DebugAdapterDescriptor> {
return new vscode.DebugAdapterInlineImplementation(new NxInlineDebugAdapter())
}
}
function resolveDebugConfiguration(
folder: vscode.WorkspaceFolder | undefined,
config: vscode.DebugConfiguration
): vscode.DebugConfiguration {
const resolved = { ...config }
if (!resolved.type) resolved.type = "nx-tcl"
if (!resolved.request) resolved.request = "attach"
if (!resolved.name) resolved.name = "Attach to NX Post Tcl"
if (!resolved.host) resolved.host = "127.0.0.1"
if (!resolved.port) resolved.port = 4711
if (!resolved.connectTimeout) resolved.connectTimeout = 120000
if (resolved.stopOnEntry === undefined) resolved.stopOnEntry = false
if (resolved.breakOnError === undefined) resolved.breakOnError = true
if (!resolved.localRoot && folder) resolved.localRoot = folder.uri.fsPath
return resolved
}
export function registerNxTclDebugger(context: vscode.ExtensionContext): void {
const factory = new NxDebugAdapterFactory()
context.subscriptions.push(
vscode.debug.registerDebugAdapterDescriptorFactory("nx-tcl", factory),
vscode.debug.registerDebugConfigurationProvider("nx-tcl", {
resolveDebugConfiguration
})
)
}
export { resolveDebugConfiguration }
+100 -19
View File
@@ -13,7 +13,8 @@ import {
isFirstLineMachine,
diagnosticHandler,
cdlDocumentSymbolProvider,
defDocumentSymbolProvider
defDocumentSymbolProvider,
definitionCdlEventHandler
} from "./common/handlers"
import { registerLogger, traceError, traceLog, traceVerbose } from "./common/log/logging"
import {
@@ -23,20 +24,24 @@ import {
onDidChangePythonInterpreter,
resolveInterpreter
} from "./common/python"
import { restartServer } from "./common/server"
import { disposeServerResources, restartServer } from "./common/server"
import { checkIfConfigurationChanged, getInterpreterFromSetting } from "./common/settings"
import { loadServerDefaults } from "./common/setup"
import { getLSClientTraceLevel } from "./common/utilities"
import { createOutputChannel, onDidChangeConfiguration, registerCommand } from "./common/vscodeapi"
import { registerNxTclDebugger } from "./debugger/register"
let client: LanguageClient | undefined
export async function activate(context: vscode.ExtensionContext) {
registerNxTclDebugger(context)
// This is required to get server name and module. This should be
// the first thing that we do in this extension.
const serverInfo = loadServerDefaults()
const serverName = serverInfo.name
const serverId = serverInfo.module
const pythonDebugMode = process.env.USE_DEBUGPY?.toLowerCase() === "true"
// Setup logging
const outputChannel = createOutputChannel(serverName)
@@ -68,7 +73,13 @@ export async function activate(context: vscode.ExtensionContext) {
traceVerbose(
`Using interpreter from ${serverInfo.module}.interpreter: ${interpreter.join(" ")}`
)
client = await restartServer(serverId, serverName, outputChannel, client)
client = await restartServer(
serverId,
serverName,
outputChannel,
client,
context.storageUri?.fsPath
)
}
return
}
@@ -78,7 +89,13 @@ export async function activate(context: vscode.ExtensionContext) {
traceVerbose(
`Using interpreter from Python extension: ${interpreterDetails.path.join(" ")}`
)
client = await restartServer(serverId, serverName, outputChannel, client)
client = await restartServer(
serverId,
serverName,
outputChannel,
client,
context.storageUri?.fsPath
)
return
}
@@ -100,10 +117,15 @@ export async function activate(context: vscode.ExtensionContext) {
return runServerQueue
}
if (!pythonDebugMode) {
context.subscriptions.push(
onDidChangePythonInterpreter(async () => {
await runServer()
})
)
}
context.subscriptions.push(
onDidChangePythonInterpreter(async () => {
await runServer()
}),
onDidChangeConfiguration(async (e: vscode.ConfigurationChangeEvent) => {
if (checkIfConfigurationChanged(e, serverId)) {
await runServer()
@@ -115,6 +137,15 @@ export async function activate(context: vscode.ExtensionContext) {
)
setImmediate(async () => {
if (pythonDebugMode) {
// A debugpy listen session is attached to exactly one process. Do not
// subscribe to interpreter changes during startup, as the Python
// extension can emit a duplicate event and restart that process.
traceLog("Python debug mode: starting one stable server session")
await runServer()
return
}
const interpreter = getInterpreterFromSetting(serverId)
if (interpreter === undefined || interpreter.length === 0) {
traceLog(`Python extension loading`)
@@ -177,6 +208,16 @@ export async function activate(context: vscode.ExtensionContext) {
)
context.subscriptions.push(hoverCdlProvider)
const definitionCdlEventProvider = vscode.languages.registerDefinitionProvider(
{ scheme: "file", language: "cdl" },
{
provideDefinition(document, position, token) {
return definitionCdlEventHandler(document, position, token)
}
}
)
context.subscriptions.push(definitionCdlEventProvider)
const formatDefProvider = vscode.languages.registerDocumentFormattingEditProvider(
{ scheme: "file", language: "def" },
{
@@ -220,33 +261,73 @@ export async function activate(context: vscode.ExtensionContext) {
const diagnosticCollectionDef = vscode.languages.createDiagnosticCollection("def")
context.subscriptions.push(diagnosticCollectionCdl, diagnosticCollectionDef)
const diagnosticTimers = new Map<string, ReturnType<typeof setTimeout>>()
const updateDiagnostics = (document: vscode.TextDocument) => {
if (document.languageId === "cdl") {
diagnosticCollectionCdl.set(document.uri, diagnosticHandler(document))
} else if (document.languageId === "def") {
diagnosticCollectionDef.set(document.uri, diagnosticHandler(document))
}
}
const scheduleDiagnostics = (document: vscode.TextDocument) => {
const key = document.uri.toString()
const previous = diagnosticTimers.get(key)
if (previous !== undefined) {
clearTimeout(previous)
}
diagnosticTimers.set(
key,
setTimeout(() => {
diagnosticTimers.delete(key)
updateDiagnostics(document)
}, 120)
)
}
// Check if the first line of the CDL file contains "MACHINE"
context.subscriptions.push(
vscode.workspace.onDidOpenTextDocument((document) => {
if (document.languageId === "cdl" || document.languageId === "def") {
if (document.languageId === "cdl") {
diagnosticCollectionCdl.set(document.uri, diagnosticHandler(document))
} else if (document.languageId === "def") {
diagnosticCollectionDef.set(document.uri, diagnosticHandler(document))
}
updateDiagnostics(document)
}
}),
vscode.workspace.onDidChangeTextDocument((event) => {
const document = event.document
if (document.languageId === "cdl" || document.languageId === "def") {
if (document.languageId === "cdl") {
diagnosticCollectionCdl.set(document.uri, diagnosticHandler(document))
} else if (document.languageId === "def") {
diagnosticCollectionDef.set(document.uri, diagnosticHandler(document))
}
scheduleDiagnostics(document)
}
})
}),
vscode.workspace.onDidCloseTextDocument((document) => {
const key = document.uri.toString()
const timer = diagnosticTimers.get(key)
if (timer !== undefined) {
clearTimeout(timer)
diagnosticTimers.delete(key)
}
diagnosticCollectionCdl.delete(document.uri)
diagnosticCollectionDef.delete(document.uri)
}),
{
dispose() {
for (const timer of diagnosticTimers.values()) {
clearTimeout(timer)
}
diagnosticTimers.clear()
}
}
)
for (const document of vscode.workspace.textDocuments) {
if (document.languageId === "cdl" || document.languageId === "def") {
updateDiagnostics(document)
}
}
}
export function deactivate(): Thenable<void> | undefined {
if (!client) {
disposeServerResources()
return undefined
}
return client.stop()
return client.stop().finally(disposeServerResources)
}
+4 -2
View File
@@ -1,19 +1,21 @@
const esbuild = require("esbuild")
const path = require("path")
const production = process.argv.includes("--production")
const watch = process.argv.includes("--watch")
async function main() {
const ctx = await esbuild.context({
absWorkingDir: __dirname,
entryPoints: ["client/src/extension.ts"],
bundle: true,
format: "cjs",
minify: production,
sourcemap: !production,
sourcesContent: false,
sourcesContent: !production,
platform: "node",
// outdir: "out",
outfile: "./dist/extension.js",
outfile: path.join(__dirname, "dist", "extension.js"),
external: ["vscode"],
logLevel: "silent",
plugins: [
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "nx-post-support",
"version": "2025.9.200",
"version": "2026.9.501",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nx-post-support",
"version": "2025.9.200",
"version": "2026.9.501",
"devDependencies": {
"@types/vscode": "^1.96.0",
"@vscode/vsce": "^3.2.1",
+132 -5
View File
@@ -1,10 +1,16 @@
{
"name": "nx-post-support",
"displayName": "NX Postprocessor Support",
"description": "VS Code extension for NX CAM postprocessor development with syntax highlighting, formatting, linting, and auto-completion for CDL, TCL, and DEF files",
"version": "2026.6.100",
"description": "VS Code extension for NX CAM postprocessor development with language support and remote Tcl debugging for CDL, TCL, and DEF files",
"version": "2026.9.700",
"publisher": "Christoph",
"icon": "images/nx-1.png",
"activationEvents": [
"onLanguage:tcl",
"onLanguage:cdl",
"onLanguage:def",
"onDebug"
],
"extensionDependencies": [
"ms-python.python"
],
@@ -23,15 +29,109 @@
"UDE",
"Postprocessor",
"NX CAM",
"Siemens NX"
"Siemens NX",
"debugger",
"remote debugging"
],
"engines": {
"vscode": "^1.96.0"
},
"categories": [
"Programming Languages"
"Programming Languages",
"Debuggers"
],
"contributes": {
"breakpoints": [
{
"language": "tcl"
},
{
"language": "def"
}
],
"debuggers": [
{
"type": "nx-tcl",
"label": "NX Tcl Remote Debugger",
"languages": [
"tcl",
"def"
],
"configurationAttributes": {
"attach": {
"required": [
"host",
"port"
],
"properties": {
"host": {
"type": "string",
"default": "127.0.0.1",
"description": "Host on which the NX Tcl runtime is listening."
},
"port": {
"type": "number",
"default": 4711,
"description": "TCP port of the NX Tcl runtime."
},
"connectTimeout": {
"type": "number",
"default": 120000,
"description": "How long VS Code retries until NX starts the runtime, in milliseconds."
},
"stopOnEntry": {
"type": "boolean",
"default": false,
"description": "Stop on the first traced Tcl command."
},
"breakOnError": {
"type": "boolean",
"default": true,
"description": "Stop when a traced Tcl command returns TCL_ERROR."
},
"localRoot": {
"type": "string",
"description": "Local source root opened in VS Code."
},
"remoteRoot": {
"type": "string",
"description": "Corresponding source root on the NX host when it differs from localRoot."
}
}
}
},
"initialConfigurations": [
{
"type": "nx-tcl",
"request": "attach",
"name": "Attach to NX Post Tcl",
"host": "127.0.0.1",
"port": 4711,
"connectTimeout": 120000,
"stopOnEntry": false,
"breakOnError": true,
"localRoot": "${workspaceFolder}"
}
],
"configurationSnippets": [
{
"label": "NX Tcl: Attach to NX Post",
"description": "Attach VS Code to an NX runtime embedded in NX Post.",
"body": {
"type": "nx-tcl",
"request": "attach",
"name": "Attach to NX Post Tcl",
"host": "127.0.0.1",
"port": 4711,
"connectTimeout": 120000,
"stopOnEntry": false,
"breakOnError": true,
"localRoot": "${workspaceFolder}"
}
}
]
}
],
"languages": [
{
"id": "tcl",
@@ -96,6 +196,26 @@
"default": true,
"description": "Use the Inlay Hints in from `NX Postprocessor Support`"
},
"nx-post-support.inlayHints.parameterNames": {
"type": "string",
"default": "all",
"enum": [
"all",
"literals",
"none"
],
"enumDescriptions": [
"Show parameter name hints for all arguments.",
"Show parameter name hints only for literal arguments.",
"Do not show parameter name hints."
],
"description": "Controls which TCL procedure arguments receive parameter name hints."
},
"nx-post-support.inlayHints.suppressWhenArgumentMatchesName": {
"type": "boolean",
"default": true,
"description": "Hide a parameter hint when a variable argument already has the same name, for example `output` in `my_proc $output`."
},
"nx-post-support.importStrategy": {
"default": "useBundled",
"description": "Defines where `NX Postprocessor Support` is imported from.",
@@ -120,12 +240,19 @@
"type": "array"
}
}
},
"configurationDefaults": {
"[tcl]": {
"editor.inlayHints.maximumLength": 0
}
}
},
"scripts": {
"compile": "node esbuild.js --production",
"compile:debug": "node esbuild.js",
"watch": "node esbuild.js --watch",
"package": "node esbuild.js --production"
"package": "node esbuild.js --production",
"test:debugger": "node --test test/debugger.test.js"
},
"devDependencies": {
"@types/vscode": "^1.96.0",
-19
View File
@@ -1,19 +0,0 @@
from __future__ import annotations
class ExceptionGroup(Exception):
"""Minimal backport used by bundled libs on Python < 3.11."""
def __new__(cls, message, exceptions):
obj = super().__new__(cls, message)
obj.message = message
obj.exceptions = tuple(exceptions)
return obj
def __init__(self, message, exceptions):
super().__init__(message)
self.message = message
self.exceptions = tuple(exceptions)
def derive(self, exceptions):
return self.__class__(self.message, exceptions)
@@ -1,12 +0,0 @@
lsprotocol-2023.0.1.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
lsprotocol-2023.0.1.dist-info/LICENSE,sha256=ws_MuBL-SCEBqPBFl9_FqZkaaydIJmxHrJG2parhU4M,1141
lsprotocol-2023.0.1.dist-info/METADATA,sha256=oh7M_V0nCX-lx8MCik5z0_J8Wyd7ApJtdl30wWs4Tb8,2237
lsprotocol-2023.0.1.dist-info/RECORD,,
lsprotocol-2023.0.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
lsprotocol-2023.0.1.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81
lsprotocol/__init__.py,sha256=zoT6Do2JtGHGb7pOeKpahg4ocXIsSpyowjhOrhUhx8g,94
lsprotocol/_hooks.py,sha256=PCTq4Ve_dDd02DMcWQ8afu9gj_oyX0B4nDOomPghqYs,41570
lsprotocol/converters.py,sha256=404tQOVoZL31R9CBrDe6Gx9Nok5cRph3glKlnSx00fo,433
lsprotocol/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
lsprotocol/types.py,sha256=nZYiI5ZvHEBkRYjtPd8tpqe0n2NpmdaCZ2RLwHxoMLs,454735
lsprotocol/validators.py,sha256=5UMUmWhk52_Ps66_KFydjNkMfLNUsPH3wmV0Buv645s,1420
@@ -1,15 +1,14 @@
Metadata-Version: 2.1
Metadata-Version: 2.4
Name: lsprotocol
Version: 2023.0.1
Summary: Python implementation of the Language Server Protocol.
Version: 2025.0.0
Summary: Python types for Language Server Protocol.
Author-email: Microsoft Corporation <lsprotocol-help@microsoft.com>
Maintainer-email: Brett Cannon <brett@python.org>, Karthik Nadig <kanadig@microsoft.com>
Requires-Python: >=3.7
Requires-Python: >=3.8
Description-Content-Type: text/markdown
Classifier: Development Status :: 5 - Production/Stable
Classifier: Development Status :: 4 - Beta
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3.7
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
@@ -17,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
License-File: LICENSE
Requires-Dist: attrs>=21.3.0
Requires-Dist: cattrs!=23.2.1
Project-URL: Issues, https://github.com/microsoft/lsprotocol/issues
@@ -24,7 +24,7 @@ Project-URL: Source, https://github.com/microsoft/lsprotocol
# Language Server Protocol Types implementation for Python
`lsprotocol` is a python implementation of object types used in the Language Server Protocol (LSP). This repository contains the code generator and the generated types for LSP.
`lsprotocol` is a Python implementation of object types used in the Language Server Protocol (LSP). This repository contains the code generator and the generated types for LSP.
## Overview
@@ -0,0 +1,12 @@
lsprotocol-2025.0.0.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
lsprotocol-2025.0.0.dist-info/METADATA,sha256=u3Lb5ZzZi4gH18WQWVPurP9kBvqHDY5U-Utafh8O7Bc,2184
lsprotocol-2025.0.0.dist-info/RECORD,,
lsprotocol-2025.0.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
lsprotocol-2025.0.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
lsprotocol-2025.0.0.dist-info/licenses/LICENSE,sha256=ws_MuBL-SCEBqPBFl9_FqZkaaydIJmxHrJG2parhU4M,1141
lsprotocol/__init__.py,sha256=zoT6Do2JtGHGb7pOeKpahg4ocXIsSpyowjhOrhUhx8g,94
lsprotocol/_hooks.py,sha256=dp-HEi_z7CqTz0cgzCYfT_s32Sr1hcrLlu3ff42lBXI,44624
lsprotocol/converters.py,sha256=404tQOVoZL31R9CBrDe6Gx9Nok5cRph3glKlnSx00fo,433
lsprotocol/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
lsprotocol/types.py,sha256=LJbn0uKWsPpveLZG2Wswr-1Gn2Tfimue3zV71xVA5SE,476733
lsprotocol/validators.py,sha256=5UMUmWhk52_Ps66_KFydjNkMfLNUsPH3wmV0Buv645s,1420
+185 -75
View File
@@ -1,7 +1,7 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
import sys
from typing import Any, List, Optional, Tuple, Union
from typing import Any, Optional, Sequence, Tuple, Union
import attrs
import cattrs
@@ -27,7 +27,7 @@ def _resolve_forward_references() -> None:
items = list(filter(_filter, lsp_types.ALL_TYPES_MAP.items()))
for _, value in items:
if isinstance(value, type):
attrs.resolve_types(value, lsp_types.ALL_TYPES_MAP, {}) # type: ignore
attrs.resolve_types(value, lsp_types.ALL_TYPES_MAP, {})
_resolved_forward_references = True
@@ -390,7 +390,7 @@ def _register_capabilities_hooks(converter: cattrs.Converter) -> cattrs.Converte
def _inlay_hint_label_part_hook(
object_: Any, _: type
) -> Union[str, List[lsp_types.InlayHintLabelPart]]:
) -> Union[str, Sequence[lsp_types.InlayHintLabelPart]]:
if isinstance(object_, str):
return object_
@@ -431,7 +431,7 @@ def _register_capabilities_hooks(converter: cattrs.Converter) -> cattrs.Converte
def _completion_list_hook(
object_: Any, _: type
) -> Optional[Union[lsp_types.CompletionList, List[lsp_types.CompletionItem]]]:
) -> Optional[Union[lsp_types.CompletionList, Sequence[lsp_types.CompletionItem]]]:
if object_ is None:
return None
if isinstance(object_, list):
@@ -446,8 +446,8 @@ def _register_capabilities_hooks(converter: cattrs.Converter) -> cattrs.Converte
) -> Optional[
Union[
lsp_types.Location,
List[lsp_types.Location],
List[lsp_types.LocationLink],
Sequence[lsp_types.Location],
Sequence[lsp_types.LocationLink],
]
]:
if object_ is None:
@@ -470,7 +470,7 @@ def _register_capabilities_hooks(converter: cattrs.Converter) -> cattrs.Converte
def _symbol_hook(
object_: Any, _: type
) -> Optional[
Union[List[lsp_types.DocumentSymbol], List[lsp_types.SymbolInformation]]
Union[Sequence[lsp_types.DocumentSymbol], Sequence[lsp_types.SymbolInformation]]
]:
if object_ is None:
return None
@@ -496,8 +496,8 @@ def _register_capabilities_hooks(converter: cattrs.Converter) -> cattrs.Converte
Union[
OptionalPrimitive,
lsp_types.MarkupContent,
lsp_types.MarkedString_Type1,
List[Union[OptionalPrimitive, lsp_types.MarkedString_Type1]],
lsp_types.MarkedStringWithLanguage,
Sequence[Union[OptionalPrimitive, lsp_types.MarkedStringWithLanguage]],
]
]:
if object_ is None:
@@ -509,14 +509,14 @@ def _register_capabilities_hooks(converter: cattrs.Converter) -> cattrs.Converte
(
item
if isinstance(item, (bool, int, str, float))
else converter.structure(item, lsp_types.MarkedString_Type1)
else converter.structure(item, lsp_types.MarkedStringWithLanguage)
)
for item in object_
]
if "kind" in object_:
return converter.structure(object_, lsp_types.MarkupContent)
else:
return converter.structure(object_, lsp_types.MarkedString_Type1)
return converter.structure(object_, lsp_types.MarkedStringWithLanguage)
def _document_edit_hook(
object_: Any, _: type
@@ -544,25 +544,25 @@ def _register_capabilities_hooks(converter: cattrs.Converter) -> cattrs.Converte
def _semantic_tokens_hook(
object_: Any, _: type
) -> Union[OptionalPrimitive, lsp_types.SemanticTokensOptionsFullType1]:
) -> Union[OptionalPrimitive, lsp_types.SemanticTokensFullDelta]:
if object_ is None:
return None
if isinstance(object_, (bool, int, str, float)):
return object_
return converter.structure(object_, lsp_types.SemanticTokensOptionsFullType1)
return converter.structure(object_, lsp_types.SemanticTokensFullDelta)
def _semantic_tokens_capabilities_hook(
object_: Any, _: type
) -> Union[
OptionalPrimitive,
lsp_types.SemanticTokensClientCapabilitiesRequestsTypeFullType1,
lsp_types.ClientSemanticTokensRequestFullDelta,
]:
if object_ is None:
return None
if isinstance(object_, (bool, int, str, float)):
return object_
return converter.structure(
object_, lsp_types.SemanticTokensClientCapabilitiesRequestsTypeFullType1
object_, lsp_types.ClientSemanticTokensRequestFullDelta
)
def _code_action_kind_hook(
@@ -622,29 +622,29 @@ def _register_capabilities_hooks(converter: cattrs.Converter) -> cattrs.Converte
def _notebook_sync_option_selector_hook(
object_: Any, _: type
) -> Union[
lsp_types.NotebookDocumentSyncOptionsNotebookSelectorType1,
lsp_types.NotebookDocumentSyncOptionsNotebookSelectorType2,
lsp_types.NotebookDocumentFilterWithNotebook,
lsp_types.NotebookDocumentFilterWithCells,
]:
if "notebook" in object_:
return converter.structure(
object_, lsp_types.NotebookDocumentSyncOptionsNotebookSelectorType1
object_, lsp_types.NotebookDocumentFilterWithNotebook
)
else:
return converter.structure(
object_, lsp_types.NotebookDocumentSyncOptionsNotebookSelectorType2
object_, lsp_types.NotebookDocumentFilterWithCells
)
def _semantic_token_registration_options_hook(
object_: Any, _: type
) -> Optional[
Union[OptionalPrimitive, lsp_types.SemanticTokensRegistrationOptionsFullType1]
Union[OptionalPrimitive, lsp_types.ClientSemanticTokensRequestFullDelta]
]:
if object_ is None:
return None
if isinstance(object_, (bool, int, str, float)):
return object_
return converter.structure(
object_, lsp_types.SemanticTokensRegistrationOptionsFullType1
object_, lsp_types.ClientSemanticTokensRequestFullDelta
)
def _inline_completion_provider_hook(
@@ -659,7 +659,7 @@ def _register_capabilities_hooks(converter: cattrs.Converter) -> cattrs.Converte
def _inline_completion_list_hook(
object_: Any, _: type
) -> Optional[
Union[lsp_types.InlineCompletionList, List[lsp_types.InlineCompletionItem]]
Union[lsp_types.InlineCompletionList, Sequence[lsp_types.InlineCompletionItem]]
]:
if object_ is None:
return None
@@ -682,7 +682,9 @@ def _register_capabilities_hooks(converter: cattrs.Converter) -> cattrs.Converte
def _symbol_list_hook(
object_: Any, _: type
) -> Optional[
Union[List[lsp_types.SymbolInformation], List[lsp_types.WorkspaceSymbol]]
Union[
Sequence[lsp_types.SymbolInformation], Sequence[lsp_types.WorkspaceSymbol]
]
]:
if object_ is None:
return None
@@ -703,22 +705,71 @@ def _register_capabilities_hooks(converter: cattrs.Converter) -> cattrs.Converte
converter.structure(item, lsp_types.SymbolInformation) for item in object_
]
def _notebook_sync_registration_option_selector_hook(
def _language_kind_hook(
object_: Any, _: type
) -> Union[
lsp_types.NotebookDocumentSyncRegistrationOptionsNotebookSelectorType1,
lsp_types.NotebookDocumentSyncRegistrationOptionsNotebookSelectorType2,
lsp_types.LanguageKind,
OptionalPrimitive,
]:
if "notebook" in object_:
if object_ is None:
return None
if isinstance(object_, (bool, int, str, float)):
return object_
return converter.structure(object_, lsp_types.LanguageKind)
def _text_edit_hook(
object_: Any, _: type
) -> Union[
lsp_types.TextEdit, lsp_types.AnnotatedTextEdit, lsp_types.SnippetTextEdit
]:
if "snippet" in object_:
return converter.structure(object_, lsp_types.SnippetTextEdit)
if "annotationId" in object_:
return converter.structure(object_, lsp_types.AnnotatedTextEdit)
return converter.structure(object_, lsp_types.TextEdit)
def _completion_item_kind_hook(
object_: Any, _: type
) -> Union[lsp_types.CompletionItemKind, OptionalPrimitive]:
if object_ is None:
return None
if isinstance(object_, (bool, int, str, float)):
return object_
return converter.structure(object_, lsp_types.CompletionItemKind)
def _relative_pattern_hook(
object_: Any, _: type
) -> Union[OptionalPrimitive, lsp_types.RelativePattern]:
if object_ is None:
return None
if isinstance(object_, (bool, int, str, float)):
return object_
return converter.structure(object_, lsp_types.RelativePattern)
def _workspace_folder_hook(
object_: Any, _: type
) -> Union[OptionalPrimitive, lsp_types.WorkspaceFolder]:
if object_ is None:
return None
if isinstance(object_, (bool, int, str, float)):
return object_
return converter.structure(object_, lsp_types.WorkspaceFolder)
def _text_document_content_hook(
object_: Any, _: type
) -> Union[
OptionalPrimitive,
lsp_types.TextDocumentContentRegistrationOptions,
lsp_types.TextDocumentContentOptions,
]:
if object_ is None:
return None
if "id" in object_:
return converter.structure(
object_,
lsp_types.NotebookDocumentSyncRegistrationOptionsNotebookSelectorType1,
object_, lsp_types.TextDocumentContentRegistrationOptions
)
else:
return converter.structure(
object_,
lsp_types.NotebookDocumentSyncRegistrationOptionsNotebookSelectorType2,
)
return converter.structure(object_, lsp_types.TextDocumentContentOptions)
structure_hooks = [
(
@@ -892,7 +943,7 @@ def _register_capabilities_hooks(converter: cattrs.Converter) -> cattrs.Converte
_inlay_hint_provider_hook,
),
(
Union[str, List[lsp_types.InlayHintLabelPart]],
Union[str, Sequence[lsp_types.InlayHintLabelPart]],
_inlay_hint_label_part_hook,
),
(
@@ -912,22 +963,27 @@ def _register_capabilities_hooks(converter: cattrs.Converter) -> cattrs.Converte
_code_action_hook,
),
(
Optional[Union[List[lsp_types.CompletionItem], lsp_types.CompletionList]],
Optional[
Union[Sequence[lsp_types.CompletionItem], lsp_types.CompletionList]
],
_completion_list_hook,
),
(
Optional[
Union[
lsp_types.Location,
List[lsp_types.Location],
List[lsp_types.LocationLink],
Sequence[lsp_types.Location],
Sequence[lsp_types.LocationLink],
]
],
_location_hook,
),
(
Optional[
Union[List[lsp_types.SymbolInformation], List[lsp_types.DocumentSymbol]]
Union[
Sequence[lsp_types.SymbolInformation],
Sequence[lsp_types.DocumentSymbol],
]
],
_symbol_hook,
),
@@ -935,8 +991,8 @@ def _register_capabilities_hooks(converter: cattrs.Converter) -> cattrs.Converte
Union[
lsp_types.MarkupContent,
str,
lsp_types.MarkedString_Type1,
List[Union[str, lsp_types.MarkedString_Type1]],
lsp_types.MarkedStringWithLanguage,
Sequence[Union[str, lsp_types.MarkedStringWithLanguage]],
],
_markup_content_hook,
),
@@ -950,14 +1006,14 @@ def _register_capabilities_hooks(converter: cattrs.Converter) -> cattrs.Converte
_document_edit_hook,
),
(
Optional[Union[bool, lsp_types.SemanticTokensOptionsFullType1]],
Optional[Union[bool, lsp_types.SemanticTokensFullDelta]],
_semantic_tokens_hook,
),
(
Optional[
Union[
bool,
lsp_types.SemanticTokensClientCapabilitiesRequestsTypeFullType1,
lsp_types.ClientSemanticTokensRequestFullDelta,
]
],
_semantic_tokens_capabilities_hook,
@@ -1012,8 +1068,8 @@ def _register_capabilities_hooks(converter: cattrs.Converter) -> cattrs.Converte
),
(
Union[
lsp_types.NotebookDocumentSyncOptionsNotebookSelectorType1,
lsp_types.NotebookDocumentSyncOptionsNotebookSelectorType2,
lsp_types.NotebookDocumentFilterWithNotebook,
lsp_types.NotebookDocumentFilterWithCells,
],
_notebook_sync_option_selector_hook,
),
@@ -1027,7 +1083,7 @@ def _register_capabilities_hooks(converter: cattrs.Converter) -> cattrs.Converte
_position_encoding_hook,
),
(
Optional[Union[bool, lsp_types.SemanticTokensRegistrationOptionsFullType1]],
Optional[Union[bool, lsp_types.ClientSemanticTokensRequestFullDelta]],
_semantic_token_registration_options_hook,
),
(
@@ -1037,7 +1093,8 @@ def _register_capabilities_hooks(converter: cattrs.Converter) -> cattrs.Converte
(
Optional[
Union[
lsp_types.InlineCompletionList, List[lsp_types.InlineCompletionItem]
lsp_types.InlineCompletionList,
Sequence[lsp_types.InlineCompletionItem],
]
],
_inline_completion_list_hook,
@@ -1049,17 +1106,63 @@ def _register_capabilities_hooks(converter: cattrs.Converter) -> cattrs.Converte
(
Optional[
Union[
List[lsp_types.SymbolInformation], List[lsp_types.WorkspaceSymbol]
Sequence[lsp_types.SymbolInformation],
Sequence[lsp_types.WorkspaceSymbol],
]
],
_symbol_list_hook,
),
(
Union[lsp_types.LanguageKind, str],
_language_kind_hook,
),
(
Union[
lsp_types.NotebookDocumentSyncRegistrationOptionsNotebookSelectorType1,
lsp_types.NotebookDocumentSyncRegistrationOptionsNotebookSelectorType2,
lsp_types.TextEdit,
lsp_types.AnnotatedTextEdit,
lsp_types.SnippetTextEdit,
],
_notebook_sync_registration_option_selector_hook,
_text_edit_hook,
),
(
Optional[Union[lsp_types.CompletionItemKind, int]],
_completion_item_kind_hook,
),
(
Union[lsp_types.CompletionItemKind, int],
_completion_item_kind_hook,
),
(
Optional[Union[str, lsp_types.RelativePattern]],
_relative_pattern_hook,
),
(
Union[str, lsp_types.RelativePattern],
_relative_pattern_hook,
),
(
Optional[Union[lsp_types.WorkspaceFolder, str]],
_workspace_folder_hook,
),
(
Union[lsp_types.WorkspaceFolder, str],
_workspace_folder_hook,
),
(
Optional[
Union[
lsp_types.TextDocumentContentOptions,
lsp_types.TextDocumentContentRegistrationOptions,
]
],
_text_document_content_hook,
),
(
Union[
lsp_types.TextDocumentContentOptions,
lsp_types.TextDocumentContentRegistrationOptions,
],
_text_document_content_hook,
),
]
for type_, hook in structure_hooks:
@@ -1085,9 +1188,9 @@ def _register_required_structure_hooks(
object_: Any, _: type
) -> Union[
str,
lsp_types.TextDocumentFilter_Type1,
lsp_types.TextDocumentFilter_Type2,
lsp_types.TextDocumentFilter_Type3,
lsp_types.TextDocumentFilterLanguage,
lsp_types.TextDocumentFilterScheme,
lsp_types.TextDocumentFilterPattern,
lsp_types.NotebookCellTextDocumentFilter,
]:
if isinstance(object_, str):
@@ -1097,30 +1200,31 @@ def _register_required_structure_hooks(
object_, lsp_types.NotebookCellTextDocumentFilter
)
elif "language" in object_:
return converter.structure(object_, lsp_types.TextDocumentFilter_Type1)
return converter.structure(object_, lsp_types.TextDocumentFilterLanguage)
elif "scheme" in object_:
return converter.structure(object_, lsp_types.TextDocumentFilter_Type2)
return converter.structure(object_, lsp_types.TextDocumentFilterScheme)
else:
return converter.structure(object_, lsp_types.TextDocumentFilter_Type3)
return converter.structure(object_, lsp_types.TextDocumentFilterPattern)
def _notebook_filter_hook(
object_: Any, _: type
) -> Union[
str,
lsp_types.NotebookDocumentFilter_Type1,
lsp_types.NotebookDocumentFilter_Type2,
lsp_types.NotebookDocumentFilter_Type3,
lsp_types.NotebookDocumentFilterNotebookType,
lsp_types.NotebookDocumentFilterScheme,
lsp_types.NotebookDocumentFilterPattern,
]:
if isinstance(object_, str):
return str(object_)
elif "notebookType" in object_:
return converter.structure(object_, lsp_types.NotebookDocumentFilter_Type1)
return converter.structure(
object_, lsp_types.NotebookDocumentFilterNotebookType
)
elif "scheme" in object_:
return converter.structure(object_, lsp_types.NotebookDocumentFilter_Type2)
return converter.structure(object_, lsp_types.NotebookDocumentFilterScheme)
else:
return converter.structure(object_, lsp_types.NotebookDocumentFilter_Type3)
return converter.structure(object_, lsp_types.NotebookDocumentFilterPattern)
# TODO: Remove the ignore after this issue with attrs is addressed in either attrs or mypy
NotebookSelectorItem = attrs.fields(
lsp_types.NotebookCellTextDocumentFilter
).notebook.type
@@ -1133,9 +1237,9 @@ def _register_required_structure_hooks(
(Optional[Union[bool, Any]], lambda object_, _type: object_),
(
Union[
lsp_types.TextDocumentFilter_Type1,
lsp_types.TextDocumentFilter_Type2,
lsp_types.TextDocumentFilter_Type3,
lsp_types.TextDocumentFilterLanguage,
lsp_types.TextDocumentFilterScheme,
lsp_types.TextDocumentFilterPattern,
lsp_types.NotebookCellTextDocumentFilter,
],
_text_document_filter_hook,
@@ -1144,20 +1248,26 @@ def _register_required_structure_hooks(
(
Union[
str,
lsp_types.NotebookDocumentFilter_Type1,
lsp_types.NotebookDocumentFilter_Type2,
lsp_types.NotebookDocumentFilter_Type3,
lsp_types.NotebookDocumentFilterNotebookType,
lsp_types.NotebookDocumentFilterScheme,
lsp_types.NotebookDocumentFilterPattern,
],
_notebook_filter_hook,
),
(NotebookSelectorItem, _notebook_filter_hook),
(
Union[lsp_types.LSPObject, List["LSPAny"], str, int, float, bool, None],
Union[lsp_types.LSPObject, Sequence["LSPAny"], str, int, float, bool, None],
_lsp_object_hook,
),
(
Union[
lsp_types.LSPObject, List[lsp_types.LSPAny], str, int, float, bool, None
lsp_types.LSPObject,
Sequence[lsp_types.LSPAny],
str,
int,
float,
bool,
None,
],
_lsp_object_hook,
),
@@ -1173,10 +1283,10 @@ def _register_required_structure_hooks(
(
Union[
lsp_types.LSPObject,
List[
Sequence[
Union[
lsp_types.LSPObject,
List["LSPAny"],
Sequence["LSPAny"],
str,
int,
float,
@@ -1220,7 +1330,7 @@ def _register_custom_property_hooks(converter: cattrs.Converter) -> cattrs.Conve
)
for a in attrs.fields(cls)
}
return cattrs.gen.make_dict_unstructure_fn(cls, converter, **attributes)
return cattrs.gen.make_dict_unstructure_fn(cls, converter, **attributes) # type: ignore
def _with_custom_structure(cls: type) -> Any:
attributes = {
@@ -1230,7 +1340,7 @@ def _register_custom_property_hooks(converter: cattrs.Converter) -> cattrs.Conve
)
for a in attrs.fields(cls)
}
return cattrs.gen.make_dict_structure_fn(cls, converter, **attributes)
return cattrs.gen.make_dict_structure_fn(cls, converter, **attributes) # type: ignore
converter.register_unstructure_hook_factory(attrs.has, _with_custom_unstructure)
converter.register_structure_hook_factory(attrs.has, _with_custom_structure)
File diff suppressed because it is too large Load Diff
@@ -1,29 +0,0 @@
packaging-26.2.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
packaging-26.2.dist-info/METADATA,sha256=T5y815M0FaR5P3dnyYoralEsgj_IHIczeBVwXyMOyr8,3543
packaging-26.2.dist-info/RECORD,,
packaging-26.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
packaging-26.2.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
packaging-26.2.dist-info/licenses/LICENSE,sha256=ytHvW9NA1z4HS6YU0m996spceUDD2MNIUuZcSQlobEg,197
packaging-26.2.dist-info/licenses/LICENSE.APACHE,sha256=DVQuDIgE45qn836wDaWnYhSdxoLXgpRRKH4RuTjpRZQ,10174
packaging-26.2.dist-info/licenses/LICENSE.BSD,sha256=tw5-m3QvHMb5SLNMFqo5_-zpQZY2S8iP8NIYDwAo-sU,1344
packaging/__init__.py,sha256=QhMEdPu2XogrJzV3S0KWS6t7l0I9k8EeDRJl4fnw87s,494
packaging/_elffile.py,sha256=-sKkptYqzYw2-x3QByJa5mB4rfPWu1pxkZHRx1WAFCY,3211
packaging/_manylinux.py,sha256=Hf6nB0cOrayEs96-p3oIXAgGnFquv20DO5l-o2_Xnv0,9559
packaging/_musllinux.py,sha256=Z6swjH3MA7XS3qXnmMN7QPhqP3fnoYI0eQ18e9-HgAE,2707
packaging/_parser.py,sha256=Kf2nsDw4c54X82pY8ba4F02Bve6OygGMAjL-Begqcew,11698
packaging/_structures.py,sha256=60jRbF78p8z5MKnNd6cAprgOadCJHV0DlmUmRBqFZcs,1109
packaging/_tokenizer.py,sha256=tFU2Wr-ZZJdAbkXLEJo7qUQDJaIkfft9DqaifiEND7A,5391
packaging/dependency_groups.py,sha256=XZIAVFK9uHG4RCGprmJn3VInUWMesxha_kytJuMO9eY,10218
packaging/direct_url.py,sha256=eKmbDiPP1sLV4Mj_kCSZqqknrIyVO9Sr7JpF8KCjp4U,10917
packaging/errors.py,sha256=6hfEYXAf8v_IF65-lFadJOMIieBP2xIKtyEXjG1nGIs,2680
packaging/licenses/__init__.py,sha256=_Jx0XRiD_58palsWnyLrLuh59ZpGCPIPXLKdZo9OJvQ,7293
packaging/licenses/_spdx.py,sha256=WW7DXiyg68up_YND_wpRYlr1SHhiV4FfJLQffghhMxQ,51122
packaging/markers.py,sha256=8fDIUhAF6YMnCNB5FSiwh9pEIusiFzAF73J-0OB8bTk,17055
packaging/metadata.py,sha256=crAh0E3GVGVqPlu6EdRFsaG-Y6UYznTUqjuGKRGPv6c,38770
packaging/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
packaging/pylock.py,sha256=G_1gncTmDbRLY1jo4VDI9Uw-b5IErh_Q9V_BbVJTmD8,33890
packaging/requirements.py,sha256=dd1c9aa1gp5NI6btF6UFRQjPn1nxQXnE_T34yDDTEpc,4383
packaging/specifiers.py,sha256=Mfp8avQg0lVot17to9lVKBtZD1FsWBTItoGwFUZ3wtg,71514
packaging/tags.py,sha256=NQ1weo69_Sjte3xBZ1I_G63CIgCmaN0C24mz-z3hGYo,34224
packaging/utils.py,sha256=M7-JMKic2sP1YtV_8aW7eVGB-x3ADuKCiSrsVeCd2Uo,9848
packaging/version.py,sha256=Y1aTtxe3sn2xOMa5BdI85-AcHuybbanOVkEvvSRRC8I,38369
@@ -1,9 +1,9 @@
Metadata-Version: 2.4
Name: packaging
Version: 26.2
Version: 26.3
Summary: Core utilities for Python packages
Author-email: Donald Stufft <donald@stufft.io>
Requires-Python: >=3.8
Requires-Python: >=3.9
Description-Content-Type: text/x-rst
License-Expression: Apache-2.0 OR BSD-2-Clause
Classifier: Development Status :: 5 - Production/Stable
@@ -11,13 +11,13 @@ Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Classifier: Programming Language :: Python :: Free Threading :: 4 - Resilient
@@ -0,0 +1,31 @@
packaging-26.3.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
packaging-26.3.dist-info/METADATA,sha256=cP24n8TUqaBDv3NyuJcrzIg_3f80q1Xpz4DXOHU4R2M,3544
packaging-26.3.dist-info/RECORD,,
packaging-26.3.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
packaging-26.3.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
packaging-26.3.dist-info/licenses/LICENSE,sha256=ytHvW9NA1z4HS6YU0m996spceUDD2MNIUuZcSQlobEg,197
packaging-26.3.dist-info/licenses/LICENSE.APACHE,sha256=DVQuDIgE45qn836wDaWnYhSdxoLXgpRRKH4RuTjpRZQ,10174
packaging-26.3.dist-info/licenses/LICENSE.BSD,sha256=tw5-m3QvHMb5SLNMFqo5_-zpQZY2S8iP8NIYDwAo-sU,1344
packaging/__init__.py,sha256=bnWM3QAossrXTHObmtycnwbwVJMjBigLh4v-WdOSGbU,494
packaging/_elffile.py,sha256=HwJfVMVz8ahXQMT72QjMHR-wwXfBQ3fr9fCdbNU2h4A,3236
packaging/_manylinux.py,sha256=W26EMkxCU4q0zbFI4kMP26NvBEJ7Dv2lW-oIOjX6YTY,10000
packaging/_musllinux.py,sha256=Ayj7gnsRcMd99MEH1Jvo3403JiEDSQph6Kytii-crf8,2774
packaging/_parser.py,sha256=iKNIEVIJMnSX5FO2NeXbCZ_VkvkzXXEMcxOynNswxQw,12639
packaging/_ranges.py,sha256=t7WV8uaSrrNpfFRbhcJMu8tFUvKtxtkFmqgtCHfoFrc,30850
packaging/_structures.py,sha256=60jRbF78p8z5MKnNd6cAprgOadCJHV0DlmUmRBqFZcs,1109
packaging/_tokenizer.py,sha256=zCXlfA1WTInhUHNHHW4cJkKZypRvsXwOHkr1TvdxPuY,5573
packaging/dependency_groups.py,sha256=UwnMVkyjAi37WUgW6dWTuwnN80IIwfuMXsmvtsZwJ4w,11224
packaging/direct_url.py,sha256=hDuqldBMu0kojenZ_XfulG6GLLYDEj5-RqaeFx-n0Gs,11645
packaging/errors.py,sha256=6hfEYXAf8v_IF65-lFadJOMIieBP2xIKtyEXjG1nGIs,2680
packaging/licenses/__init__.py,sha256=WQ0S7uc92-xTtOnuW_xk2cs6rLSUDo68uiYHV85WdAs,7859
packaging/licenses/_spdx.py,sha256=WW7DXiyg68up_YND_wpRYlr1SHhiV4FfJLQffghhMxQ,51122
packaging/markers.py,sha256=bL6GDTXZjStZnn8Oxz_qsSTgFXEbML6QReZ4ySlA2cw,20554
packaging/metadata.py,sha256=k0FJ9CClRJ5vN2qmHW8moII-uCzHxqHIzeWqe-Q9D2M,42059
packaging/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
packaging/pylock.py,sha256=G84Kn-Y-Qrh-jO0F2icrrxamiqcEs3GEBeJzWmESRnw,35232
packaging/ranges.py,sha256=tHgc6RPl3_ngeQbyBYXFTDAo4ff_VjQYKOedUi3b-rY,83054
packaging/requirements.py,sha256=RZR26yEH_jDmsTEAUaW9tEx9O3944Oh7raJoKCy6YvU,7040
packaging/specifiers.py,sha256=ZhrTHd9F_7hh0mJnNPTj1mlmApuP23lYa7EFgl2NnVk,52798
packaging/tags.py,sha256=DnF6CCp5Yg15zwMhtCDfFVk8SRb82gPCfLnYLgfvXW4,38302
packaging/utils.py,sha256=oTdCzZmU7spa37AknRJkCMR4Am6x9Iy5evvy_p8o7MY,12215
packaging/version.py,sha256=VYsvtQ_RmMZgpMCC-WuJGcVlkRtrS24H80trYMB0vpc,39040
+1 -1
View File
@@ -6,7 +6,7 @@ __title__ = "packaging"
__summary__ = "Core utilities for Python packages"
__uri__ = "https://github.com/pypa/packaging"
__version__ = "26.2"
__version__ = "26.3"
__author__ = "Donald Stufft and individual contributors"
__email__ = "donald@stufft.io"
+5 -5
View File
@@ -34,7 +34,7 @@ class EMachine(enum.IntEnum):
S390 = 22
Arm = 40
X8664 = 62
AArc64 = 183
AArch64 = 183
class ELFFile:
@@ -57,8 +57,8 @@ class ELFFile:
self.encoding = ident[5] # Data structure encoding (endianness).
try:
# e_fmt: Format for program header.
# p_fmt: Format for section header.
# e_fmt: Format for the ELF header.
# p_fmt: Format for a program header.
# p_idx: Indexes to find p_type, p_offset, and p_filesz.
e_fmt, self._p_fmt, self._p_idx = {
(1, 1): ("<HHIIIIIHHH", "<IIIIIIII", (0, 1, 4)), # 32-bit LSB.
@@ -81,8 +81,8 @@ class ELFFile:
_,
self.flags, # Processor-specific flags.
_,
self._e_phentsize, # Size of section.
self._e_phnum, # Number of sections.
self._e_phentsize, # Size of a program header entry.
self._e_phnum, # Number of program headers.
) = self._read(e_fmt)
except struct.error as e:
raise ELFInvalid("unable to parse machine and section information") from e
+34 -18
View File
@@ -7,10 +7,14 @@ import os
import re
import sys
import warnings
from typing import Generator, Iterator, NamedTuple, Sequence
from typing import TYPE_CHECKING, NamedTuple
from ._elffile import EIClass, EIData, ELFFile, EMachine
if TYPE_CHECKING:
import types
from collections.abc import Generator, Iterator, Sequence
EF_ARM_ABIMASK = 0xFF000000
EF_ARM_ABI_VER5 = 0x05000000
EF_ARM_ABI_FLOAT_HARD = 0x00000400
@@ -26,8 +30,6 @@ _ALLOWED_ARCHS = {
}
# `os.PathLike` not a generic type until Python 3.9, so sticking with `str`
# as the type for `path` until then.
@contextlib.contextmanager
def _parse_elf(path: str) -> Generator[ELFFile | None, None, None]:
try:
@@ -136,11 +138,11 @@ def _glibc_version_string_ctypes() -> str | None:
# glibc.
return None
# Call gnu_get_libc_version, which returns a string like "2.5"
# Call gnu_get_libc_version, which returns a string like "2.5".
gnu_get_libc_version.restype = ctypes.c_char_p
version_str: str = gnu_get_libc_version()
# py2 / py3 compatibility:
if not isinstance(version_str, str):
# A c_char_p restype comes back as bytes, so decode to text.
version_str: str | bytes = gnu_get_libc_version()
if isinstance(version_str, bytes):
version_str = version_str.decode("ascii")
return version_str
@@ -179,30 +181,44 @@ def _get_glibc_version() -> _GLibCVersion:
# From PEP 513, PEP 600
@functools.lru_cache(maxsize=1)
def _get_manylinux_module() -> types.ModuleType | None:
"""Return the ``_manylinux`` C extension module, or None if unavailable.
The result is cached for the lifetime of the process, since the presence
of the module does not change while running.
"""
try:
return __import__("_manylinux")
except ImportError:
return None
def _is_compatible(arch: str, version: _GLibCVersion) -> bool:
sys_glibc = _get_glibc_version()
if sys_glibc < version:
return False
# Check for presence of _manylinux module.
try:
import _manylinux # noqa: PLC0415
except ImportError:
manylinux_mod = _get_manylinux_module()
if manylinux_mod is None:
return True
if hasattr(_manylinux, "manylinux_compatible"):
result = _manylinux.manylinux_compatible(version[0], version[1], arch)
if hasattr(manylinux_mod, "manylinux_compatible"):
result = manylinux_mod.manylinux_compatible(version[0], version[1], arch)
if result is not None:
return bool(result)
return True
if version == _GLibCVersion(2, 5) and hasattr(_manylinux, "manylinux1_compatible"):
return bool(_manylinux.manylinux1_compatible)
if version == _GLibCVersion(2, 5) and hasattr(
manylinux_mod, "manylinux1_compatible"
):
return bool(manylinux_mod.manylinux1_compatible)
if version == _GLibCVersion(2, 12) and hasattr(
_manylinux, "manylinux2010_compatible"
manylinux_mod, "manylinux2010_compatible"
):
return bool(_manylinux.manylinux2010_compatible)
return bool(manylinux_mod.manylinux2010_compatible)
if version == _GLibCVersion(2, 17) and hasattr(
_manylinux, "manylinux2014_compatible"
manylinux_mod, "manylinux2014_compatible"
):
return bool(_manylinux.manylinux2014_compatible)
return bool(manylinux_mod.manylinux2014_compatible)
return True
+5 -2
View File
@@ -10,10 +10,13 @@ import functools
import re
import subprocess
import sys
from typing import Iterator, NamedTuple, Sequence
from typing import TYPE_CHECKING, NamedTuple
from ._elffile import ELFFile
if TYPE_CHECKING:
from collections.abc import Iterator, Sequence
class _MuslVersion(NamedTuple):
major: int
@@ -81,5 +84,5 @@ if __name__ == "__main__": # pragma: no cover
print("plat:", plat)
print("musl:", _get_musl_version(sys.executable))
print("tags:", end=" ")
for t in platform_tags(re.sub(r"[.-]", "_", plat.split("-", 1)[-1])):
for t in platform_tags([re.sub(r"[.-]", "_", plat.split("-", 1)[-1])]):
print(t, end="\n ")
+33 -8
View File
@@ -7,9 +7,10 @@ the implementation.
from __future__ import annotations
import ast
from typing import List, Literal, NamedTuple, Sequence, Tuple, Union
from collections.abc import Sequence
from typing import Literal, NamedTuple, Union
from ._tokenizer import DEFAULT_RULES, Tokenizer
from ._tokenizer import DEFAULT_RULES, ParserSyntaxError, Tokenizer
class Node:
@@ -67,7 +68,14 @@ class Value(Node):
__slots__ = ()
def serialize(self) -> str:
return f'"{self}"'
value = str(self)
if '"' not in value:
return f'"{value}"'
if "'" not in value:
return f"'{value}'"
raise ValueError(
"Cannot serialize marker value containing both quote characters"
)
class Op(Node):
@@ -79,9 +87,9 @@ class Op(Node):
MarkerLogical = Literal["and", "or"]
MarkerVar = Union[Variable, Value]
MarkerItem = Tuple[MarkerVar, Op, MarkerVar]
MarkerItem = tuple[MarkerVar, Op, MarkerVar]
MarkerAtom = Union[MarkerItem, Sequence["MarkerAtom"]]
MarkerList = List[Union["MarkerList", MarkerAtom, MarkerLogical]]
MarkerList = list[Union["MarkerList", MarkerAtom, MarkerLogical]]
class ParsedRequirement(NamedTuple):
@@ -264,10 +272,19 @@ def _parse_version_many(tokenizer: Tokenizer) -> str:
parsed_specifiers = ""
while tokenizer.check("SPECIFIER"):
span_start = tokenizer.position
parsed_specifiers += tokenizer.read().text
specifier = tokenizer.read().text
parsed_specifiers += specifier
if tokenizer.check("VERSION_PREFIX_TRAIL", peek=True):
message = ".* suffix can only be used with `==` or `!=` operators"
if specifier.startswith("!=") or (
specifier.startswith("==") and not specifier.startswith("===")
):
message = (
".* suffix cannot be used with pre-release, post-release, "
"dev or local versions"
)
tokenizer.raise_syntax_error(
".* suffix can only be used with `==` or `!=` operators",
message,
span_start=span_start,
span_end=tokenizer.position + 1,
)
@@ -354,7 +371,15 @@ def _parse_marker_var(tokenizer: Tokenizer) -> MarkerVar: # noqa: RET503
if tokenizer.check("VARIABLE"):
return process_env_var(tokenizer.read().text.replace(".", "_"))
elif tokenizer.check("QUOTED_STRING"):
return process_python_str(tokenizer.read().text)
token = tokenizer.read()
try:
return process_python_str(token.text)
except (SyntaxError, ValueError) as exc:
raise ParserSyntaxError(
"Invalid quoted string",
source=tokenizer.source,
span=(token.position, token.position + len(token.text)),
) from exc
else:
tokenizer.raise_syntax_error(
message="Expected a marker variable or quoted string"
+836
View File
@@ -0,0 +1,836 @@
# This file is dual licensed under the terms of the Apache License, Version
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
# for complete details.
"""Private version-range helpers used by :mod:`packaging.specifiers`."""
from __future__ import annotations
import enum
import functools
from typing import (
TYPE_CHECKING,
Any,
Final,
)
from .version import InvalidVersion, Version
if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Iterator, Sequence
from typing import Union
# Total-order key for comparing two boundaries (boundary-vs-boundary only).
# The post slot may be ``_BOUNDARY_INF`` for an AFTER_POSTS boundary.
_BoundaryOrderSuffix = tuple[int, int, int, Union[int, float], int, int]
_BoundaryOrderKey = tuple[int, tuple[int, ...], _BoundaryOrderSuffix, float]
__all__ = [
"FULL_RANGE",
"bounds_for_spec",
"coerce_version",
"filter_by_ranges",
"intersect_ranges",
"intersect_specifier_bounds",
"least_version_above",
"matches_bounds_only",
"range_is_empty",
"ranges_are_prerelease_only",
"resolve_prereleases",
"standard_ranges",
"wildcard_ranges",
]
#: The smallest possible PEP 440 version. No valid version is less than this.
MIN_VERSION: Final[Version] = Version("0.dev0")
#: The smallest non-pre-release version, i.e. the nearest non-pre-release at or
#: above the ``-inf`` floor.
MIN_RELEASE: Final[Version] = Version("0")
#: Sorts above any real post number and any local label, so a boundary can be
#: ordered above the version family it covers when two boundaries are compared.
_BOUNDARY_INF: Final[float] = float("inf")
class BoundaryKind(enum.Enum):
"""Where a boundary marker sits in the version ordering."""
AFTER_LOCALS = enum.auto() # after V+local, before V.post0
AFTER_POSTS = enum.auto() # after V.postN, before next release
@functools.total_ordering
class BoundaryVersion:
"""A point on the version line between two real PEP 440 versions.
Relative to a base version V::
V < V+local < AFTER_LOCALS(V) < V.post0 < AFTER_POSTS(V)
AFTER_LOCALS is the upper bound of ``<=V``, ``==V``, ``!=V`` (no
local), and the lower bound of the upper-side range of ``!=V``.
AFTER_POSTS is the lower bound of ``>V`` (V final or pre-release),
excluding V's post-releases per PEP 440.
"""
__slots__ = (
"_cached_dev",
"_cached_epoch",
"_cached_post",
"_cached_pre",
"_cached_trimmed_release",
"kind",
"version",
)
def __init__(self, version: Version, kind: BoundaryKind) -> None:
self.version = version
self.kind = kind
self._cached_trimmed_release = trim_release(version.release)
self._cached_epoch = version.epoch
self._cached_pre = version.pre
self._cached_post = version.post
self._cached_dev = version.dev
def _is_family(self, other: Version) -> bool:
"""Is ``other`` a version that this boundary sorts above?"""
if other.epoch != self._cached_epoch:
return False
# Inline release-trim comparison: other.release matches the
# trimmed release iff its leading slice is equal and any extra
# components are zero. Avoids trim_release's tuple allocation.
other_release = other.release
trimmed_release = self._cached_trimmed_release
trimmed_length = len(trimmed_release)
if len(other_release) < trimmed_length:
return False
if other_release[:trimmed_length] != trimmed_release:
return False
for i in range(trimmed_length, len(other_release)):
if other_release[i] != 0:
return False
if other.pre != self._cached_pre:
return False
if self.kind == BoundaryKind.AFTER_LOCALS:
# Local family: same public version, any local label.
return other.post == self._cached_post and other.dev == self._cached_dev
# Post family: V itself + any post-release of V.
return other.dev == self._cached_dev or other.post is not None
def _order_key(self) -> _BoundaryOrderKey:
"""Sort key placing this boundary just above the versions it covers.
It extends ``V``'s comparison key ``(epoch, release, suffix)`` with
a trailing ``_BOUNDARY_INF`` local component, so the key sorts after
``V`` and every ``V+local`` (whose keys carry a real, finite local
segment). ``suffix`` is the 6-int comparison suffix
``(pre_rank, pre_n, post_rank, post_n, dev_rank, dev_n)``.
For an AFTER_POSTS boundary the suffix is replaced with one whose
post number is ``_BOUNDARY_INF``, so the key also sorts after every
``V.postN``. An AFTER_LOCALS boundary uses ``V``'s suffix unchanged.
"""
version_key = self.version._key
suffix: _BoundaryOrderSuffix = version_key[2]
if self.kind == BoundaryKind.AFTER_POSTS:
suffix = (suffix[0], suffix[1], 1, _BOUNDARY_INF, 1, 0)
return version_key[0], version_key[1], suffix, _BOUNDARY_INF
def __eq__(self, other: object) -> bool:
# Key off the order key so equality matches the ``<`` / ``>`` order:
# ``AFTER_POSTS(1.0)`` and ``AFTER_POSTS(1.0.post1)`` are the same point.
if isinstance(other, BoundaryVersion):
return self._order_key() == other._order_key()
return NotImplemented
def __lt__(self, other: BoundaryVersion | Version) -> bool:
if isinstance(other, BoundaryVersion):
return self._order_key() < other._order_key()
# boundary < other_version iff V < other AND other not in family.
# The cheap V >= other path short-circuits before the family check.
if not (self.version < other):
return False
return not self._is_family(other)
def __gt__(self, other: BoundaryVersion | Version) -> bool:
# Defined directly to bypass functools.total_ordering's
# NotImplemented round-trip on reflected ``Version < boundary``.
if isinstance(other, BoundaryVersion):
return self._order_key() > other._order_key()
if self.version >= other:
return True
return self._is_family(other)
def __hash__(self) -> int:
# Keyed to ``__eq__`` (the order key), so equal boundaries hash equal.
return hash(self._order_key())
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.version!r}, {self.kind.name})"
if TYPE_CHECKING:
_VersionOrBoundary = Union[Version, BoundaryVersion, None]
@functools.total_ordering
class LowerBound:
"""Lower bound of a version range.
A version *v* of ``None`` means unbounded below (-inf).
At equal versions, ``[v`` sorts before ``(v`` because an inclusive
bound starts earlier.
"""
__slots__ = ("_above", "inclusive", "version")
def __init__(self, version: _VersionOrBoundary, inclusive: bool) -> None:
self.version = version
self.inclusive = inclusive
# Pre-bind a predicate "is parsed at or above this lower
# bound?" for the hot filter / contains loops. One direct
# call per check, no operator-dispatch chain.
if version is None:
self._above: Callable[[Version], bool] | None = None
elif isinstance(version, BoundaryVersion):
# >V produces an AFTER_POSTS lower bound; the upper-side
# range of !=V produces an AFTER_LOCALS lower bound.
if version.kind == BoundaryKind.AFTER_POSTS:
self._above = _make_above_after_posts(version.version)
else:
self._above = _make_above_after_locals(version.version)
elif inclusive:
self._above = version.__le__
else:
self._above = version.__lt__
def __eq__(self, other: object) -> bool:
if not isinstance(other, LowerBound):
return NotImplemented
return self.version == other.version and self.inclusive == other.inclusive
def __lt__(self, other: LowerBound) -> bool:
if not isinstance(other, LowerBound):
return NotImplemented
# -inf < anything (except -inf itself).
if self.version is None:
return other.version is not None
if other.version is None:
return False
if self.version != other.version:
return self.version < other.version
# [v < (v: inclusive starts earlier.
return self.inclusive and not other.inclusive
def __hash__(self) -> int:
return hash((self.version, self.inclusive))
def __repr__(self) -> str:
bracket = "[" if self.inclusive else "("
return f"<{self.__class__.__name__} {bracket}{self.version!r}>"
@functools.total_ordering
class UpperBound:
"""Upper bound of a version range.
A version *v* of ``None`` means unbounded above (+inf).
At equal versions, ``v)`` sorts before ``v]`` because an exclusive
bound ends earlier.
"""
__slots__ = ("_below", "inclusive", "version")
def __init__(self, version: _VersionOrBoundary, inclusive: bool) -> None:
self.version = version
self.inclusive = inclusive
# Pre-bind a predicate "is parsed at or below this upper
# bound?". See LowerBound for the rationale.
if version is None:
self._below: Callable[[Version], bool] | None = None
elif isinstance(version, BoundaryVersion):
# Standard specifiers only ever produce AFTER_LOCALS upper
# bounds (from <=V / ==V / !=V with no local).
if version.kind == BoundaryKind.AFTER_LOCALS:
self._below = _make_below_after_locals(version.version)
else:
# An AFTER_POSTS upper is not produced by any specifier, but
# range algebra reaches it: complementing ``>V`` flips the
# ``AFTER_POSTS(V)`` lower into this upper bound.
self._below = version.__ge__
elif inclusive:
self._below = version.__ge__
else:
self._below = version.__gt__
def __eq__(self, other: object) -> bool:
if not isinstance(other, UpperBound):
return NotImplemented
return self.version == other.version and self.inclusive == other.inclusive
def __lt__(self, other: UpperBound) -> bool:
if not isinstance(other, UpperBound):
return NotImplemented
# Nothing < +inf (except +inf itself).
if self.version is None:
return False
if other.version is None:
return True
if self.version != other.version:
return self.version < other.version
# v) < v]: exclusive ends earlier.
return not self.inclusive and other.inclusive
def __hash__(self) -> int:
return hash((self.version, self.inclusive))
def __repr__(self) -> str:
bracket = "]" if self.inclusive else ")"
return f"<{self.__class__.__name__} {self.version!r}{bracket}>"
if TYPE_CHECKING:
#: A single contiguous interval as a (lower, upper) bound pair.
Interval = tuple[LowerBound, UpperBound]
NEG_INF: Final[LowerBound] = LowerBound(None, False)
POS_INF: Final[UpperBound] = UpperBound(None, False)
FULL_RANGE: Final[tuple[Interval]] = ((NEG_INF, POS_INF),)
def trim_release(release: tuple[int, ...]) -> tuple[int, ...]:
"""Strip trailing zeros from a release tuple for normalized comparison."""
end = len(release)
while end > 1 and release[end - 1] == 0:
end -= 1
return release if end == len(release) else release[:end]
def _next_prefix_dev0(version: Version) -> Version:
"""Smallest version in the next prefix: 1.2 -> 1.3.dev0."""
release = (*version.release[:-1], version.release[-1] + 1)
return Version.from_parts(epoch=version.epoch, release=release, dev=0)
def _base_dev0(version: Version) -> Version:
"""The .dev0 of a version's base release: 1.2 -> 1.2.dev0."""
return Version.from_parts(epoch=version.epoch, release=version.release, dev=0)
def coerce_version(version: Version | str) -> Version | None:
if not isinstance(version, Version):
try:
version = Version(version)
except InvalidVersion:
return None
return version
def _make_above_after_posts(version: Version) -> Callable[[Version], bool]:
"""Predicate ``parsed > AFTER_POSTS(V)`` for a lower bound.
Per PEP 440, ``>V`` excludes V's post-releases unless V is itself
a post-release. AFTER_POSTS sits above V and every V.postN (with
or without local), and just below the next release.
"""
version_ge = version.__ge__
version_epoch = version.epoch
version_pre = version.pre
version_release_trimmed = trim_release(version.release)
trimmed_length = len(version_release_trimmed)
def above(parsed: Version) -> bool:
if version_ge(parsed):
return False
# parsed > V cmpkey-wise: above the boundary iff NOT in V's
# post family.
if parsed.epoch != version_epoch:
return True
parsed_release = parsed.release
if len(parsed_release) < trimmed_length:
return True
if parsed_release[:trimmed_length] != version_release_trimmed:
return True
for i in range(trimmed_length, len(parsed_release)):
if parsed_release[i] != 0:
return True
if parsed.pre != version_pre:
return True
# Same release and pre as V: parsed is in V's post family (V itself,
# V+local, or V.postN), which the boundary sits above. A V.devN
# (different dev, no post) sorts before V and was already caught by
# ``version_ge`` above, so the answer here is always "not above".
return False
return above
def _make_above_after_locals(version: Version) -> Callable[[Version], bool]:
"""Predicate ``parsed > AFTER_LOCALS(V)`` for a lower bound.
Used by the upper-side range of ``!=V`` (when V has no local
segment). AFTER_LOCALS sits above V and every ``V+local`` but
just below ``V.post0``.
"""
version_ge = version.__ge__
version_epoch = version.epoch
version_pre = version.pre
version_post = version.post
version_dev = version.dev
version_release_trimmed = trim_release(version.release)
trimmed_length = len(version_release_trimmed)
def above(parsed: Version) -> bool:
if version_ge(parsed):
return False
# parsed > V cmpkey-wise: above the boundary iff NOT in V's
# local family (same public version, any local segment).
if parsed.epoch != version_epoch:
return True
parsed_release = parsed.release
if len(parsed_release) < trimmed_length:
return True
if parsed_release[:trimmed_length] != version_release_trimmed:
return True
for i in range(trimmed_length, len(parsed_release)):
if parsed_release[i] != 0:
return True
if parsed.pre != version_pre:
return True
if parsed.post != version_post:
return True
return parsed.dev != version_dev
return above
def _make_below_after_locals(version: Version) -> Callable[[Version], bool]:
"""Predicate ``parsed <= AFTER_LOCALS(V)`` for an upper bound.
Used by ``<=V``, ``==V``, ``!=V`` (no local). ``parsed`` is at or
below the boundary when it is at or below V cmpkey-wise, or when
it is in V's local family.
"""
version_ge = version.__ge__
version_epoch = version.epoch
version_pre = version.pre
version_post = version.post
version_dev = version.dev
version_release_trimmed = trim_release(version.release)
trimmed_length = len(version_release_trimmed)
def below(parsed: Version) -> bool:
if version_ge(parsed):
return True
# parsed > V cmpkey-wise: below the boundary iff in V's local
# family.
if parsed.epoch != version_epoch:
return False
parsed_release = parsed.release
if len(parsed_release) < trimmed_length:
return False
if parsed_release[:trimmed_length] != version_release_trimmed:
return False
for i in range(trimmed_length, len(parsed_release)):
if parsed_release[i] != 0:
return False
if parsed.pre != version_pre:
return False
if parsed.post != version_post:
return False
return parsed.dev == version_dev
return below
def least_version_above(boundary: BoundaryVersion) -> Version | None:
"""Smallest real version strictly above *boundary*, or ``None`` if none exists."""
base = boundary.version
if boundary.kind == BoundaryKind.AFTER_LOCALS:
# AFTER_LOCALS(V) sits just below V.post0, so its least successor is
# V.post0.dev0 (V.dev(N+1) if V has a dev, V.post(N+1).dev0 if a post).
if base.dev is not None:
return base.__replace__(dev=base.dev + 1, local=None)
next_post = (base.post + 1) if base.post is not None else 0
return base.__replace__(post=next_post, dev=0, local=None)
# AFTER_POSTS(V): a pre-release V steps to the next pre-release's .dev0;
# a final-release AFTER_POSTS has no least successor.
if base.pre is not None:
kind, number = base.pre
return base.__replace__(pre=(kind, number + 1), post=None, dev=0, local=None)
return None
def range_is_empty(lower: LowerBound, upper: UpperBound) -> bool:
"""True when the range defined by *lower* and *upper* contains no versions.
A boundary lower sits just below the next real version, so an ordered pair
is still empty when the upper excludes that least successor:
``(AFTER_POSTS(1.0a1), 1.0a2.dev0)`` holds no version.
"""
if upper.version is None:
return False
if lower.version is None:
# Nothing sorts below MIN_VERSION, so an exclusive upper at or below it
# leaves an empty floor interval such as ``(-inf, 0.dev0)``.
return (
not upper.inclusive
and isinstance(upper.version, Version)
and upper.version <= MIN_VERSION
)
if isinstance(lower.version, BoundaryVersion):
successor = least_version_above(lower.version)
if successor is not None:
if upper.version == successor:
return not upper.inclusive
return upper.version < successor
if lower.version == upper.version:
return not (lower.inclusive and upper.inclusive)
return lower.version > upper.version
def intersect_ranges(
left: Sequence[Interval],
right: Sequence[Interval],
) -> list[Interval]:
"""Intersect two sorted, non-overlapping range lists (two-pointer merge)."""
result: list[Interval] = []
left_index = right_index = 0
while left_index < len(left) and right_index < len(right):
left_lower, left_upper = left[left_index]
right_lower, right_upper = right[right_index]
lower = max(left_lower, right_lower)
upper = min(left_upper, right_upper)
if not range_is_empty(lower, upper):
result.append((lower, upper))
# Advance whichever side has the smaller upper bound.
if left_upper < right_upper:
left_index += 1
else:
right_index += 1
return result
def filter_by_ranges(
ranges: Sequence[Interval],
iterable: Iterable[Any],
key: Callable[[Any], Version | str] | None,
prereleases: bool | None,
region: Sequence[Interval] = (),
) -> Iterator[Any]:
"""Filter *iterable* against precomputed version *ranges*.
With ``prereleases=None``, the PEP 440 default applies: pre-releases are
excluded unless no final matches, in which case buffered pre-releases come
out at the end. A pre-release inside the opt-in ``region`` is the exception:
it is force-admitted in place, as ``prereleases=True`` would yield it. A
force-admitted pre-release is not a final, so it never suppresses the buffer.
"""
if prereleases is None:
prerelease_buffer: list[Any] = []
found_final = False
if len(ranges) == 1:
# Hot path: most specifiers and small SpecifierSets reduce to
# a single contiguous range.
lower, upper = ranges[0]
above = lower._above
below = upper._below
for item in iterable:
parsed = coerce_version(item if key is None else key(item))
if parsed is None:
continue
if above is not None and not above(parsed):
continue
if below is not None and not below(parsed):
continue
if not parsed.is_prerelease:
found_final = True
yield item
elif region and matches_bounds_only(region, parsed):
yield item
elif not found_final:
prerelease_buffer.append(item)
if not found_final:
yield from prerelease_buffer
return
for item in iterable:
parsed = coerce_version(item if key is None else key(item))
if parsed is None:
continue
for lower, upper in ranges:
above = lower._above
if above is not None and not above(parsed):
break
below = upper._below
if below is None or below(parsed):
if not parsed.is_prerelease:
found_final = True
yield item
elif region and matches_bounds_only(region, parsed):
yield item
elif not found_final:
prerelease_buffer.append(item)
break
if not found_final:
yield from prerelease_buffer
return
exclude_prereleases = prereleases is False
if len(ranges) == 1:
# Hot path: most specifiers and small SpecifierSets reduce to
# a single contiguous range.
lower, upper = ranges[0]
above = lower._above
below = upper._below
for item in iterable:
parsed = coerce_version(item if key is None else key(item))
if parsed is None:
continue
if exclude_prereleases and parsed.is_prerelease:
continue
if above is not None and not above(parsed):
continue
if below is None or below(parsed):
yield item
return
for item in iterable:
parsed = coerce_version(item if key is None else key(item))
if parsed is None:
continue
if exclude_prereleases and parsed.is_prerelease:
continue
for lower, upper in ranges:
above = lower._above
if above is not None and not above(parsed):
break
below = upper._below
if below is None or below(parsed):
yield item
break
def _nearest_release_above_prerelease(version: Version) -> Version:
"""Smallest non-pre-release at or above a pre-release *version*."""
if version.pre is not None:
# An a/b/rc pre-release drops to its final release, which outranks
# every post-release of that pre-release (1.0a1.post0 -> 1.0).
return version.__replace__(pre=None, post=None, dev=None, local=None)
# A dev-only release keeps its post-release (1.0.post0.dev0 -> 1.0.post0,
# whose final 1.0 sorts below it).
return version.__replace__(dev=None, local=None)
def _lowest_release_at_or_above(value: Version | BoundaryVersion | None) -> Version:
"""Smallest non-pre-release version at or above *value*.
``None`` is the ``-inf`` floor, whose nearest non-pre-release is
:data:`MIN_RELEASE`.
"""
if value is None:
return MIN_RELEASE
if isinstance(value, BoundaryVersion):
inner_version = value.version
if inner_version.is_prerelease:
return _nearest_release_above_prerelease(inner_version)
# AFTER_LOCALS(1.0) -> nearest non-pre is 1.0.post0
# AFTER_LOCALS(1.0.post0) -> nearest non-pre is 1.0.post1
next_post = (inner_version.post + 1) if inner_version.post is not None else 0
return inner_version.__replace__(post=next_post, local=None)
if not value.is_prerelease:
return value
return _nearest_release_above_prerelease(value)
def ranges_are_prerelease_only(ranges: Sequence[Interval]) -> bool:
"""True when every range in *ranges* contains only pre-releases.
Used to detect unsatisfiable specifier sets when ``prereleases=False``:
if every range is pre-release-only, every contained version is excluded.
"""
for lower, upper in ranges:
nearest = _lowest_release_at_or_above(lower.version)
if upper.version is None or nearest < upper.version:
return False
if nearest == upper.version and upper.inclusive:
return False
return True
def wildcard_ranges(op: str, base: Version) -> list[Interval]:
"""Ranges for ==V.* and !=V.*.
==1.2.* -> [1.2.dev0, 1.3.dev0); !=1.2.* -> complement.
"""
lower = _base_dev0(base)
upper = _next_prefix_dev0(base)
if op == "==":
return [(LowerBound(lower, True), UpperBound(upper, False))]
# !=
return [
(NEG_INF, UpperBound(lower, False)),
(LowerBound(upper, True), POS_INF),
]
def standard_ranges(op: str, version: Version, has_local: bool) -> list[Interval]:
"""Ranges for the standard PEP 440 operators (no wildcard, no ===).
*has_local* indicates whether the spec string included a ``+local``
segment; relevant only for ``==`` / ``!=`` to decide whether the
upper bound includes V's local family.
"""
if op == ">=":
return [(LowerBound(version, True), POS_INF)]
if op == "<=":
return [
(
NEG_INF,
UpperBound(BoundaryVersion(version, BoundaryKind.AFTER_LOCALS), True),
)
]
if op == ">":
if version.dev is not None:
# >V.devN: dev versions have no post-releases, so the
# next real version is V.dev(N+1).
lower_bound = version.__replace__(dev=version.dev + 1, local=None)
return [(LowerBound(lower_bound, True), POS_INF)]
if version.post is not None:
# >V.postN: next real version is V.post(N+1).dev0.
lower_bound = version.__replace__(post=version.post + 1, dev=0, local=None)
return [(LowerBound(lower_bound, True), POS_INF)]
# >V (final or pre-release V): exclude V itself, V+local, and
# every V.postN per PEP 440.
return [
(
LowerBound(BoundaryVersion(version, BoundaryKind.AFTER_POSTS), False),
POS_INF,
)
]
if op == "<":
# <V excludes pre-releases of V when V is not a pre-release.
# V.dev0 is the earliest pre-release of V.
bound = (
version if version.is_prerelease else version.__replace__(dev=0, local=None)
)
if bound <= MIN_VERSION:
return []
return [(NEG_INF, UpperBound(bound, False))]
# ==, !=: local versions of V match when the spec has no local segment.
after_locals = BoundaryVersion(version, BoundaryKind.AFTER_LOCALS)
upper = version if has_local else after_locals
if op == "==":
return [(LowerBound(version, True), UpperBound(upper, True))]
if op == "!=":
return [
(NEG_INF, UpperBound(version, False)),
(LowerBound(upper, False), POS_INF),
]
if op == "~=":
prefix = version.__replace__(release=version.release[:-1])
return [
(LowerBound(version, True), UpperBound(_next_prefix_dev0(prefix), False))
]
raise ValueError(f"Unknown operator: {op!r}") # pragma: no cover
def bounds_for_spec(op: str, version_str: str, version: Version) -> list[Interval]:
"""Ranges for one specifier's ``(op, version_str)``.
Dispatches between the wildcard and standard builders. ``version`` is the
parsed ``version_str`` (its base, without the trailing ``.*``, for
wildcards). ``===`` is not handled here; its match is a literal string
compared in :mod:`packaging.specifiers`.
"""
if version_str.endswith(".*"):
return wildcard_ranges(op, version)
return standard_ranges(op, version, "+" in version_str)
def intersect_specifier_bounds(
per_specifier_ranges: Iterable[Sequence[Interval]],
) -> Sequence[Interval]:
"""Intersect each specifier's ranges into a single sequence.
Short-circuits once the running intersection is empty, since no later
specifier can revive it. Callers must pass at least one specifier.
"""
result: Sequence[Interval] | None = None
for sub in per_specifier_ranges:
if result is None:
result = sub
else:
result = intersect_ranges(result, sub)
if not result:
break
if result is None: # pragma: no cover - callers guard non-empty input
raise RuntimeError("intersect_specifier_bounds called with no specifiers")
return result
def matches_bounds_only(ranges: Sequence[Interval], version: Version) -> bool:
"""Whether ``version`` falls within any of ``ranges``.
The pure bounds membership test, for a single already-parsed version with
no pre-release policy applied. ``ranges`` are sorted and non-overlapping,
so a version below one range's lower bound is below every later range too.
"""
for lower, upper in ranges:
above = lower._above
if above is not None and not above(version):
return False
below = upper._below
if below is None or below(version):
return True
return False
def resolve_prereleases(
configured: bool | None, autodetected: bool | None
) -> bool | None:
"""Resolve a specifier's effective default pre-release policy.
An explicit ``configured`` value wins; otherwise an autodetected ``True``
propagates and anything else falls back to the PEP 440 default (``None``).
"""
if configured is not None:
return configured
if autodetected:
return True
return None
+10 -3
View File
@@ -3,13 +3,18 @@ from __future__ import annotations
import contextlib
import re
from dataclasses import dataclass
from typing import Generator, Mapping, NoReturn
from typing import TYPE_CHECKING, NoReturn
from .specifiers import Specifier
if TYPE_CHECKING:
from collections.abc import Generator, Mapping
@dataclass
class Token:
__slots__ = ("name", "position", "text")
name: str
text: str
position: int
@@ -84,7 +89,7 @@ DEFAULT_RULES: dict[str, re.Pattern[str]] = {
"VERSION_PREFIX_TRAIL": re.compile(r"\.\*"),
"VERSION_LOCAL_LABEL_TRAIL": re.compile(r"\+[a-z0-9]+(?:[-_\.][a-z0-9]+)*"),
"WS": re.compile(r"[ \t]+"),
"END": re.compile(r"$"),
"END": re.compile(r"\Z"),
}
@@ -95,6 +100,8 @@ class Tokenizer:
matches.
"""
__slots__ = ("next_token", "position", "rules", "source")
def __init__(
self,
source: str,
@@ -135,7 +142,7 @@ class Tokenizer:
def expect(self, name: str, *, expected: str) -> Token:
"""Expect a certain token name next, failing with a syntax error otherwise.
The token is *not* read.
The token is read and returned.
"""
if not self.check(name):
raise self.raise_syntax_error(f"Expected {expected}")
+38 -5
View File
@@ -4,7 +4,7 @@ import re
from collections.abc import Mapping, Sequence
from .errors import _ErrorCollector
from .requirements import Requirement
from .requirements import InvalidRequirement, Requirement
__all__ = [
"CyclicDependencyGroup",
@@ -28,12 +28,16 @@ def __dir__() -> list[str]:
class DuplicateGroupNames(ValueError):
"""
The same dependency groups were defined twice, with different non-normalized names.
.. versionadded:: 26.1
"""
class CyclicDependencyGroup(ValueError):
"""
The dependency group includes form a cycle.
.. versionadded:: 26.1
"""
def __init__(self, requested_group: str, group: str, include_group: str) -> None:
@@ -50,6 +54,10 @@ class CyclicDependencyGroup(ValueError):
f"{requested_group}: {reason}"
)
# Support pickling; ``args`` does not match ``__init__``'s signature.
def __reduce__(self) -> tuple[type[CyclicDependencyGroup], tuple[str, str, str]]:
return (self.__class__, (self.requested_group, self.group, self.include_group))
# in the PEP 735 spec, the tables in dependency group lists were described as
# "Dependency Object Specifiers", but the only defined type of object was a
@@ -58,6 +66,8 @@ class InvalidDependencyGroupObject(ValueError):
"""
A member of a dependency group was identified as a dict, but was not in a valid
format.
.. versionadded:: 26.1
"""
@@ -67,6 +77,12 @@ class InvalidDependencyGroupObject(ValueError):
class DependencyGroupInclude:
"""
A reference to another dependency group by name.
.. versionadded:: 26.1
"""
__slots__ = ("include_group",)
def __init__(self, include_group: str) -> None:
@@ -91,6 +107,8 @@ class DependencyGroupResolver:
:param dependency_groups: A mapping, as provided via pyproject
``[dependency-groups]``.
.. versionadded:: 26.1
"""
def __init__(
@@ -227,9 +245,10 @@ class DependencyGroupResolver:
for item in raw_group:
if isinstance(item, str):
# packaging.requirements.Requirement parsing ensures that this is a
# valid PEP 508 Dependency Specifier
# raises InvalidRequirement on failure
elements.append(Requirement(item))
# valid PEP 508 Dependency Specifier. Collect InvalidRequirement
# if it throws that.
with errors.collect(InvalidRequirement):
elements.append(Requirement(item))
elif isinstance(item, Mapping):
if tuple(item.keys()) != ("include-group",):
errors.error(
@@ -239,10 +258,22 @@ class DependencyGroupResolver:
)
else:
include_group = item["include-group"]
elements.append(DependencyGroupInclude(include_group=include_group))
if not isinstance(include_group, str):
msg = (
"Dependency group include-group value is not a string: "
f"{item!r}"
)
errors.error(TypeError(msg))
else:
elements.append(
DependencyGroupInclude(include_group=include_group)
)
else:
errors.error(TypeError(f"Invalid dependency group item: {item!r}"))
if errors.errors:
return ()
self._parsed_groups[group] = tuple(elements)
return self._parsed_groups[group]
@@ -261,6 +292,8 @@ def resolve_dependency_groups(
:param dependency_groups: the parsed contents of the ``[dependency-groups]`` table
from ``pyproject.toml``
:param groups: the name of the group(s) to resolve
.. versionadded:: 26.1
"""
resolver = DependencyGroupResolver(dependency_groups)
return tuple(str(r) for group in groups for r in resolver.resolve(group))
+32 -8
View File
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Protocol, TypeVar
if TYPE_CHECKING: # pragma: no cover
import sys
from collections.abc import Collection
from urllib.parse import SplitResult
if sys.version_info >= (3, 11):
from typing import Self
@@ -83,7 +84,7 @@ _PEP610_USER_PASS_ENV_VARS_REGEX = re.compile(
def _strip_auth_from_netloc(netloc: str, safe_user_passwords: Collection[str]) -> str:
if "@" not in netloc:
return netloc
user_pass, netloc_no_user_pass = netloc.split("@", 1)
user_pass, netloc_no_user_pass = netloc.rsplit("@", 1)
if user_pass in safe_user_passwords:
return netloc
if _PEP610_USER_PASS_ENV_VARS_REGEX.match(user_pass):
@@ -109,8 +110,15 @@ def _strip_url(url: str, safe_user_passwords: Collection[str]) -> str:
)
def _file_url_has_absolute_path(parsed_url: SplitResult) -> bool:
return parsed_url.path.startswith("/")
class DirectUrlValidationError(Exception):
"""Raised when when input data is not spec-compliant."""
"""Raised when when input data is not spec-compliant.
.. versionadded:: 26.1
"""
context: str | None = None
message: str
@@ -146,6 +154,8 @@ class _DirectUrlRequiredKeyError(DirectUrlValidationError):
@dataclasses.dataclass(frozen=True, init=False)
class VcsInfo:
"""The version control information of a :class:`DirectUrl`."""
vcs: str
commit_id: str
requested_revision: str | None = None
@@ -173,6 +183,8 @@ class VcsInfo:
@dataclasses.dataclass(frozen=True, init=False)
class ArchiveInfo:
"""The archive information of a :class:`DirectUrl`."""
hashes: Mapping[str, str] | None = None
def __init__(
@@ -219,6 +231,8 @@ class ArchiveInfo:
@dataclasses.dataclass(frozen=True, init=False)
class DirInfo:
"""The local directory information of a :class:`DirectUrl`."""
editable: bool | None = None
def __init__(
@@ -237,7 +251,10 @@ class DirInfo:
@dataclasses.dataclass(frozen=True, init=False)
class DirectUrl:
"""A class representing a direct URL."""
"""A class representing a direct URL.
.. versionadded:: 26.1
"""
url: str
archive_info: ArchiveInfo | None = None
@@ -277,11 +294,18 @@ class DirectUrl:
raise DirectUrlValidationError(
"Exactly one of vcs_info, archive_info, dir_info must be present"
)
if direct_url.dir_info is not None and not direct_url.url.startswith("file://"):
raise DirectUrlValidationError(
"URL scheme must be file:// when dir_info is present",
context="url",
)
if direct_url.dir_info is not None:
parsed_url = urllib.parse.urlsplit(direct_url.url)
if parsed_url.scheme != "file":
raise DirectUrlValidationError(
"URL scheme must be file:// when dir_info is present",
context="url",
)
if not _file_url_has_absolute_path(parsed_url):
raise DirectUrlValidationError(
"File URL must be absolute when dir_info is present",
context="url",
)
# XXX subdirectory must be relative, can we, should we validate that here?
return direct_url
+18 -6
View File
@@ -48,7 +48,7 @@ def __dir__() -> list[str]:
return __all__
license_ref_allowed = re.compile("^[A-Za-z0-9.-]*$")
license_ref_allowed = re.compile("^[A-Za-z0-9.-]+$")
NormalizedLicenseExpression = NewType("NormalizedLicenseExpression", str)
"""
@@ -64,7 +64,7 @@ class InvalidLicenseExpression(ValueError):
>>> canonicalize_license_expression("invalid")
Traceback (most recent call last):
...
packaging.licenses.InvalidLicenseExpression: Invalid license expression: 'invalid'
packaging.licenses.InvalidLicenseExpression: Unknown license: 'invalid'
"""
@@ -148,9 +148,18 @@ def canonicalize_license_expression(
# Take a final pass to check for unknown licenses/exceptions.
normalized_tokens = []
for token in tokens:
last_license_start = False
for index, token in enumerate(tokens):
if token in {"or", "and", "with", "(", ")"}:
if token == "with" and (
not last_license_start
or index + 1 == len(tokens)
or tokens[index + 1] in {"or", "and", "with", "(", ")"}
):
message = f"Invalid license expression: {raw_license_expression!r}"
raise InvalidLicenseExpression(message)
normalized_tokens.append(token.upper())
last_license_start = False
continue
if normalized_tokens and normalized_tokens[-1] == "WITH":
@@ -159,6 +168,7 @@ def canonicalize_license_expression(
raise InvalidLicenseExpression(message)
normalized_tokens.append(EXCEPTIONS[token]["id"])
last_license_start = False
else:
if token.endswith("+"):
final_token = token[:-1]
@@ -168,15 +178,17 @@ def canonicalize_license_expression(
suffix = ""
if final_token.startswith("licenseref-"):
if not license_ref_allowed.match(final_token):
message = f"Invalid licenseref: {final_token!r}"
license_ref_id = final_token[len("licenseref-") :]
if suffix or not license_ref_allowed.match(license_ref_id):
message = f"Invalid licenseref: {token!r}"
raise InvalidLicenseExpression(message)
normalized_tokens.append(license_refs[final_token] + suffix)
normalized_tokens.append(license_refs[final_token])
else:
if final_token not in LICENSES:
message = f"Unknown license: {final_token!r}"
raise InvalidLicenseExpression(message)
normalized_tokens.append(LICENSES[final_token]["id"] + suffix)
last_license_start = True
normalized_expression = " ".join(normalized_tokens)
+106 -22
View File
@@ -4,11 +4,13 @@
from __future__ import annotations
import functools
import operator
import os
import platform
import sys
from typing import AbstractSet, Callable, Literal, Mapping, TypedDict, Union, cast
from collections.abc import Set as AbstractSet
from typing import TYPE_CHECKING, Callable, Literal, TypedDict, Union, cast
from ._parser import MarkerAtom, MarkerList, Op, Value, Variable
from ._parser import parse_marker as _parse_marker
@@ -16,6 +18,9 @@ from ._tokenizer import ParserSyntaxError
from .specifiers import InvalidSpecifier, Specifier
from .utils import canonicalize_name
if TYPE_CHECKING:
from collections.abc import Mapping
__all__ = [
"Environment",
"EvaluateContext",
@@ -40,6 +45,8 @@ Valid values for the ``context`` passed to :meth:`Marker.evaluate` are:
* ``"metadata"`` (for core metadata; default)
* ``"lock_file"`` (for lock files)
* ``"requirement"`` (i.e. all other situations)
.. versionadded:: 25.0
"""
MARKERS_ALLOWING_SET = {"extras", "dependency_groups"}
@@ -68,8 +75,17 @@ class UndefinedComparison(ValueError):
"""
class UndefinedEnvironmentName(ValueError):
"""Raised when evaluating a marker that references a missing environment key."""
class UndefinedEnvironmentName(KeyError):
"""Raised when evaluating a marker that references a missing environment key.
Subclasses :class:`KeyError` so that code catching the bare ``KeyError`` that
a missing environment lookup historically produced keeps working.
.. versionchanged:: 26.3
Now subclasses :class:`KeyError` (was :class:`ValueError`) and is raised by
:meth:`Marker.evaluate` for missing environment keys, where a bare
``KeyError`` was raised before.
"""
class Environment(TypedDict):
@@ -152,16 +168,30 @@ class Environment(TypedDict):
def _normalize_extras(
result: MarkerList | MarkerAtom | str,
) -> MarkerList | MarkerAtom | str:
if isinstance(result, list):
return [_normalize_extras(r) for r in result]
if not isinstance(result, tuple):
return result
lhs, op, rhs = result
if isinstance(lhs, Variable) and lhs.value == "extra":
if isinstance(lhs, Variable) and lhs.value == "extra" and isinstance(rhs, Value):
normalized_extra = canonicalize_name(rhs.value)
rhs = Value(normalized_extra)
elif isinstance(rhs, Variable) and rhs.value == "extra":
elif isinstance(rhs, Variable) and rhs.value == "extra" and isinstance(lhs, Value):
normalized_extra = canonicalize_name(lhs.value)
lhs = Value(normalized_extra)
elif (
isinstance(rhs, Variable)
and rhs.value in MARKERS_ALLOWING_SET
and isinstance(lhs, Value)
):
# PEP 685 (extras) / PEP 735 (dependency_groups): the set-valued membership
# literal must also be normalized. evaluate() already canonicalizes both
# operands for these keys (see _normalize), so normalizing the literal at
# parse time keeps __str__/__eq__/__hash__ consistent with evaluate() -- e.g.
# Marker('"Foo" in extras') and Marker('"foo" in extras') must compare and
# hash equal (the membership variable is always the right-hand operand).
lhs = Value(canonicalize_name(lhs.value))
return lhs, op, rhs
@@ -178,16 +208,14 @@ def _format_marker(
) -> str:
assert isinstance(marker, (list, tuple, str))
# Sometimes we have a structure like [[...]] which is a single item list
# where the single item is itself it's own list. In that case we want skip
# the rest of this function so that we don't get extraneous () on the
# outside.
# Unwrap a redundant [[...]] wrapper, but keep the nesting context so a
# nested group keeps the parentheses its and/or precedence needs.
if (
isinstance(marker, list)
and len(marker) == 1
and isinstance(marker[0], (list, tuple))
):
return _format_marker(marker[0])
return _format_marker(marker[0], first=first)
if isinstance(marker, list):
inner = (_format_marker(m, first=False) for m in marker)
@@ -251,6 +279,15 @@ def _normalize(
return lhs, rhs
def _lookup_environment(
environment: dict[str, str | AbstractSet[str]], key: str
) -> str | AbstractSet[str]:
try:
return environment[key]
except KeyError:
raise UndefinedEnvironmentName(key) from None
def _evaluate_markers(
markers: MarkerList, environment: dict[str, str | AbstractSet[str]]
) -> bool:
@@ -264,14 +301,20 @@ def _evaluate_markers(
if isinstance(lhs, Variable):
environment_key = lhs.value
lhs_value = environment[environment_key]
lhs_value = _lookup_environment(environment, environment_key)
rhs_value = rhs.value
else:
lhs_value = lhs.value
environment_key = rhs.value
rhs_value = environment[environment_key]
rhs_value = _lookup_environment(environment, environment_key)
assert isinstance(lhs_value, str), "lhs must be a string"
if not isinstance(lhs_value, str):
raise UndefinedComparison(
f"Set-valued marker {environment_key!r} can only be used "
f'with the membership form (e.g. "<name>" in '
f"{environment_key}); it cannot appear on the left-hand "
f"side of {op.serialize()!r}."
)
lhs_value, rhs_value = _normalize(lhs_value, rhs_value, key=environment_key)
groups[-1].append(_eval_op(lhs_value, op, rhs_value, key=environment_key))
elif marker == "or":
@@ -292,10 +335,14 @@ def _format_full_version(info: sys._version_info) -> str:
return version
def default_environment() -> Environment:
"""Return the default marker environment for the current Python process.
@functools.cache
def _cached_default_environment() -> Environment:
"""Build the default marker environment for the current Python process.
This is the base environment used by :meth:`Marker.evaluate`.
The values are derived from process-constant data (the running interpreter
and the host platform), so this is cached and built only once. The result is
shared between callers and must never be mutated; :func:`default_environment`
returns a fresh copy.
"""
iver = _format_full_version(sys.implementation.version)
implementation_name = sys.implementation.name
@@ -314,6 +361,22 @@ def default_environment() -> Environment:
}
def default_environment() -> Environment:
"""Return the default marker environment for the current Python process.
This is the base environment used by :meth:`Marker.evaluate`. A fresh copy
is returned on every call so callers may freely mutate the result; a shallow
copy suffices because all values are immutable strings.
.. versionchanged:: 26.3
The environment is computed once per process and cached, since it is
derived from process-constant data. Patching ``platform``/``sys``/``os``
after the first call has no effect; pass an explicit ``environment`` to
:meth:`Marker.evaluate` to evaluate against different values.
"""
return cast("Environment", dict(_cached_default_environment()))
class Marker:
"""Represents a parsed dependency marker expression.
@@ -421,11 +484,19 @@ class Marker:
raise TypeError(f"Cannot restore Marker from {state!r}")
def __and__(self, other: Marker) -> Marker:
"""Combine this marker with another using ``and``.
.. versionadded:: 26.1
"""
if not isinstance(other, Marker):
return NotImplemented
return self._from_markers([self._markers, "and", other._markers])
def __or__(self, other: Marker) -> Marker:
"""Combine this marker with another using ``or``.
.. versionadded:: 26.1
"""
if not isinstance(other, Marker):
return NotImplemented
return self._from_markers([self._markers, "or", other._markers])
@@ -454,19 +525,23 @@ class Marker:
is missing from the evaluation environment.
:returns: ``True`` if the marker matches, otherwise ``False``.
.. versionchanged:: 25.0
Added the ``context`` parameter, which influences which marker names
are considered valid.
"""
current_environment = cast(
"dict[str, str | AbstractSet[str]]", default_environment()
)
if context == "lock_file":
current_environment.update(
extras=frozenset(), dependency_groups=frozenset()
)
current_environment |= {
"extras": frozenset(),
"dependency_groups": frozenset(),
}
elif context == "metadata":
current_environment["extra"] = ""
if environment is not None:
current_environment.update(environment)
current_environment |= environment
if "extra" in current_environment:
# The API used to allow setting extra to None. We need to handle
# this case for backwards compatibility. Also skip running
@@ -479,6 +554,16 @@ class Marker:
)
def _pep440_python_full_version(python_full_version: str) -> str:
"""
Work around platform.python_version() returning something that is not PEP 440
compliant for non-tagged Python builds.
"""
if python_full_version.endswith("+"):
return f"{python_full_version}local"
return python_full_version
def _repair_python_full_version(
env: dict[str, str | AbstractSet[str]],
) -> dict[str, str | AbstractSet[str]]:
@@ -487,6 +572,5 @@ def _repair_python_full_version(
compliant for non-tagged Python builds.
"""
python_full_version = cast("str", env["python_full_version"])
if python_full_version.endswith("+"):
env["python_full_version"] = f"{python_full_version}local"
env["python_full_version"] = _pep440_python_full_version(python_full_version)
return env
+143 -53
View File
@@ -6,6 +6,7 @@ import email.parser
import email.policy
import keyword
import pathlib
import re
import typing
from typing import (
Any,
@@ -22,6 +23,7 @@ from .errors import ExceptionGroup, _ErrorCollector
if typing.TYPE_CHECKING:
from .licenses import NormalizedLicenseExpression
from .version import Version
T = typing.TypeVar("T")
@@ -42,7 +44,10 @@ def __dir__() -> list[str]:
class InvalidMetadata(ValueError):
"""A metadata field contains invalid data."""
"""A metadata field contains invalid data.
.. versionadded:: 23.2
"""
field: str
"""The name of the field that contains invalid data."""
@@ -51,6 +56,10 @@ class InvalidMetadata(ValueError):
self.field = field
super().__init__(message)
# Support pickling; ``args`` does not match ``__init__``'s signature.
def __reduce__(self) -> tuple[type[InvalidMetadata], tuple[str, str]]:
return (self.__class__, (self.field, self.args[0]))
# The RawMetadata class attempts to make as few assumptions about the underlying
# serialization formats as possible. The idea is that as long as a serialization
@@ -125,11 +134,17 @@ class RawMetadata(TypedDict, total=False):
# Metadata 2.4 - PEP 639
license_expression: str
""".. versionadded:: 24.2"""
license_files: list[str]
""".. versionadded:: 24.2"""
# Metadata 2.5 - PEP 794
import_names: list[str]
""".. versionadded:: 26.0"""
import_namespaces: list[str]
""".. versionadded:: 26.0"""
# Metadata 2.6 - PEP 808 (no new fields, behavior change for Dynamic)
# 'keywords' is special as it's a string in the core metadata spec, but we
@@ -225,13 +240,19 @@ def _get_payload(msg: email.message.Message, source: bytes | str) -> str:
# and we don't need to deal with it.
if isinstance(source, str):
payload = msg.get_payload()
assert isinstance(payload, str)
# A multipart payload makes get_payload() return a list of messages
# rather than a str; route it to ``unparsed``.
if not isinstance(payload, str):
raise ValueError("payload is not a string") # noqa: TRY004
return payload
# If our source is a bytes, then we're managing the encoding and we need
# to deal with it.
else:
bpayload = msg.get_payload(decode=True)
assert isinstance(bpayload, bytes)
# A multipart payload makes get_payload(decode=True) return None;
# route it to ``unparsed``.
if not isinstance(bpayload, bytes):
raise ValueError("payload in an invalid encoding") # noqa: TRY004
try:
return bpayload.decode("utf8", "strict")
except UnicodeDecodeError as exc:
@@ -287,11 +308,19 @@ _EMAIL_TO_RAW_MAPPING = {
_RAW_TO_EMAIL_MAPPING = {raw: email for email, raw in _EMAIL_TO_RAW_MAPPING.items()}
# A bare "\r" makes the email generator raise ``HeaderWriteError``, and on
# CPython releases without the CVE-2024-6923 fix any ``str.splitlines``
# boundary ends the header line, so fold all of them, not just "\n".
_LINE_BOUNDARY_RE = re.compile(r"\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]")
# This class is for writing RFC822 messages
class RFC822Policy(email.policy.EmailPolicy):
"""
This is :class:`email.policy.EmailPolicy`, but with a simple ``header_store_parse``
implementation that handles multi-line values, and some nice defaults.
.. versionadded:: 26.0
"""
utf8 = True
@@ -300,7 +329,7 @@ class RFC822Policy(email.policy.EmailPolicy):
def header_store_parse(self, name: str, value: str) -> tuple[str, str]:
size = len(name) + 2
value = value.replace("\n", "\n" + " " * size)
value = _LINE_BOUNDARY_RE.sub("\n" + " " * size, value)
return (name, value)
@@ -310,6 +339,8 @@ class RFC822Message(email.message.EmailMessage):
This is :class:`email.message.EmailMessage` with two small changes: it defaults to
our `RFC822Policy`, and it correctly writes unicode when being called
with `bytes()`.
.. versionadded:: 26.0
"""
def __init__(self) -> None:
@@ -511,8 +542,20 @@ _NOT_FOUND = object()
# Keep the two values in sync.
_VALID_METADATA_VERSIONS = ["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5"]
_MetadataVersion = Literal["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5"]
_VALID_METADATA_VERSIONS = [
"1.0",
"1.1",
"1.2",
"2.1",
"2.2",
"2.3",
"2.4",
"2.5",
"2.6",
]
_MetadataVersion = Literal[
"1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6"
]
_REQUIRED_ATTRS = frozenset(["metadata_version", "name", "version"])
@@ -572,9 +615,7 @@ class _Validator(Generic[T]):
def _invalid_metadata(
self, msg: str, cause: Exception | None = None
) -> InvalidMetadata:
exc = InvalidMetadata(
self.raw_name, msg.format_map({"field": repr(self.raw_name)})
)
exc = InvalidMetadata(self.raw_name, msg)
exc.__cause__ = cause
return exc
@@ -586,67 +627,82 @@ class _Validator(Generic[T]):
def _process_name(self, value: str) -> str:
if not value:
raise self._invalid_metadata("{field} is a required field")
raise self._invalid_metadata(f"{self.raw_name!r} is a required field")
# Validate the name as a side-effect.
try:
utils.canonicalize_name(value, validate=True)
except utils.InvalidName as exc:
raise self._invalid_metadata(
f"{value!r} is invalid for {{field}}", cause=exc
f"{value!r} is invalid for {self.raw_name!r}", cause=exc
) from exc
else:
return value
def _process_version(self, value: str) -> version_module.Version:
def _process_version(self, value: str) -> Version:
if not value:
raise self._invalid_metadata("{field} is a required field")
raise self._invalid_metadata(f"{self.raw_name!r} is a required field")
try:
return version_module.parse(value)
except version_module.InvalidVersion as exc:
raise self._invalid_metadata(
f"{value!r} is invalid for {{field}}", cause=exc
f"{value!r} is invalid for {self.raw_name!r}", cause=exc
) from exc
def _process_summary(self, value: str) -> str:
"""Check the field contains no newlines."""
if "\n" in value:
raise self._invalid_metadata("{field} must be a single line")
"""Check the field contains no line breaks."""
if _LINE_BOUNDARY_RE.search(value):
raise self._invalid_metadata(f"{self.raw_name!r} must be a single line")
return value
def _process_description_content_type(self, value: str) -> str:
content_types = {"text/plain", "text/x-rst", "text/markdown"}
invalid_msg = (
f"{self.raw_name!r} must be one of {list(content_types)}, not {value!r}"
)
message = email.message.EmailMessage()
message["content-type"] = value
try:
message["content-type"] = value
# The email parser can raise IndexError on malformed RFC 2231
# parameters such as "text/plain; x*".
except (ValueError, IndexError) as exc:
msg = f"{value!r} is not a valid content type for {self.raw_name!r}"
raise self._invalid_metadata(msg, cause=exc) from exc
content_type_header = message["content-type"]
if content_type_header.defects:
defect = content_type_header.defects[0]
msg = (
f"{value!r} is not a valid content type for {self.raw_name!r}: {defect}"
)
raise self._invalid_metadata(msg, cause=defect) from defect
content_type, parameters = (
# Defaults to `text/plain` if parsing failed.
message.get_content_type().lower(),
message["content-type"].params,
content_type_header.params,
)
# Check if content-type is valid or defaulted to `text/plain` and thus was
# not parseable.
if content_type not in content_types or content_type not in value.lower():
raise self._invalid_metadata(
f"{{field}} must be one of {list(content_types)}, not {value!r}"
)
raise self._invalid_metadata(invalid_msg)
charset = parameters.get("charset", "UTF-8")
if charset != "UTF-8":
if charset.lower() != "utf-8":
raise self._invalid_metadata(
f"{{field}} can only specify the UTF-8 charset, not {charset!r}"
f"{self.raw_name!r} can only specify the UTF-8 charset, not {charset!r}"
)
markdown_variants = {"GFM", "CommonMark"}
variant = parameters.get("variant", "GFM") # Use an acceptable default.
if content_type == "text/markdown" and variant not in markdown_variants:
raise self._invalid_metadata(
f"valid Markdown variants for {{field}} are {list(markdown_variants)}, "
f"not {variant!r}",
f"valid Markdown variants for {self.raw_name!r} are "
f"{list(markdown_variants)}, not {variant!r}",
)
return value
def _process_dynamic(self, value: list[str]) -> list[str]:
for dynamic_field in map(str.lower, value):
dynamic_fields = list(map(str.lower, value))
for dynamic_field in dynamic_fields:
if dynamic_field in {"name", "version", "metadata-version"}:
raise self._invalid_metadata(
f"{dynamic_field!r} is not allowed as a dynamic field"
@@ -655,7 +711,7 @@ class _Validator(Generic[T]):
raise self._invalid_metadata(
f"{dynamic_field!r} is not a valid dynamic field"
)
return list(map(str.lower, value))
return dynamic_fields
def _process_provides_extra(
self,
@@ -667,7 +723,7 @@ class _Validator(Generic[T]):
normalized_names.append(utils.canonicalize_name(name, validate=True))
except utils.InvalidName as exc:
raise self._invalid_metadata(
f"{name!r} is invalid for {{field}}", cause=exc
f"{name!r} is invalid for {self.raw_name!r}", cause=exc
) from exc
else:
return normalized_names
@@ -677,7 +733,7 @@ class _Validator(Generic[T]):
return specifiers.SpecifierSet(value)
except specifiers.InvalidSpecifier as exc:
raise self._invalid_metadata(
f"{value!r} is invalid for {{field}}", cause=exc
f"{value!r} is invalid for {self.raw_name!r}", cause=exc
) from exc
def _process_requires_dist(
@@ -690,7 +746,7 @@ class _Validator(Generic[T]):
reqs.append(requirements.Requirement(req))
except requirements.InvalidRequirement as exc:
raise self._invalid_metadata(
f"{req!r} is invalid for {{field}}", cause=exc
f"{req!r} is invalid for {self.raw_name!r}", cause=exc
) from exc
else:
return reqs
@@ -700,7 +756,7 @@ class _Validator(Generic[T]):
return licenses.canonicalize_license_expression(value)
except ValueError as exc:
raise self._invalid_metadata(
f"{value!r} is invalid for {{field}}", cause=exc
f"{value!r} is invalid for {self.raw_name!r}", cause=exc
) from exc
def _process_license_files(self, value: list[str]) -> list[str]:
@@ -708,23 +764,24 @@ class _Validator(Generic[T]):
for path in value:
if ".." in path:
raise self._invalid_metadata(
f"{path!r} is invalid for {{field}}, "
f"{path!r} is invalid for {self.raw_name!r}, "
"parent directory indicators are not allowed"
)
if "*" in path:
raise self._invalid_metadata(
f"{path!r} is invalid for {{field}}, paths must be resolved"
f"{path!r} is invalid for {self.raw_name!r}, paths must be resolved"
)
if (
pathlib.PurePosixPath(path).is_absolute()
or pathlib.PureWindowsPath(path).is_absolute()
):
raise self._invalid_metadata(
f"{path!r} is invalid for {{field}}, paths must be relative"
f"{path!r} is invalid for {self.raw_name!r}, paths must be relative"
)
if pathlib.PureWindowsPath(path).as_posix() != path:
raise self._invalid_metadata(
f"{path!r} is invalid for {{field}}, paths must use '/' delimiter"
f"{path!r} is invalid for {self.raw_name!r}, "
"paths must use '/' delimiter"
)
paths.append(path)
return paths
@@ -736,17 +793,17 @@ class _Validator(Generic[T]):
for identifier in name.split("."):
if not identifier.isidentifier():
raise self._invalid_metadata(
f"{name!r} is invalid for {{field}}; "
f"{name!r} is invalid for {self.raw_name!r}; "
f"{identifier!r} is not a valid identifier"
)
elif keyword.iskeyword(identifier):
raise self._invalid_metadata(
f"{name!r} is invalid for {{field}}; "
f"{name!r} is invalid for {self.raw_name!r}; "
f"{identifier!r} is a keyword"
)
if semicolon and private.lstrip() != "private":
raise self._invalid_metadata(
f"{import_name!r} is invalid for {{field}}; "
f"{import_name!r} is invalid for {self.raw_name!r}; "
"the only valid option is 'private'"
)
return value
@@ -761,6 +818,12 @@ class Metadata:
metadata fields instead of only using built-in types. Any invalid metadata
will cause :exc:`InvalidMetadata` to be raised (with a
:py:attr:`~BaseException.__cause__` attribute as appropriate).
.. versionadded:: 23.2
.. versionchanged:: 24.0
Optional attributes now return None when the field is absent instead of
raising.
"""
_raw: RawMetadata
@@ -769,8 +832,9 @@ class Metadata:
def from_raw(cls, data: RawMetadata, *, validate: bool = True) -> Metadata:
"""Create an instance from :class:`RawMetadata`.
If *validate* is true, all metadata will be validated. All exceptions
related to validation will be gathered and raised as an :class:`ExceptionGroup`.
If *validate* is true, all metadata will be validated and all related
exceptions will be gathered and raised as an :class:`ExceptionGroup`;
otherwise, validation happens per attribute when it is accessed.
"""
ins = cls()
ins._raw = data.copy() # Mutations occur due to caching enriched values.
@@ -829,20 +893,32 @@ class Metadata:
raw, unparsed = parse_email(data)
if validate:
with _ErrorCollector().on_exit("unparsed") as collector:
with _ErrorCollector().on_exit("invalid or unparsed metadata") as collector:
for unparsed_key in unparsed:
if unparsed_key in _EMAIL_TO_RAW_MAPPING:
message = f"{unparsed_key!r} has invalid data"
else:
message = f"unrecognized field: {unparsed_key!r}"
collector.error(InvalidMetadata(unparsed_key, message))
try:
validated = cls.from_raw(raw, validate=validate)
except ExceptionGroup as exc_group:
# The no-branch pragmas cover arcs only seen by Python 3.9.
for exc in exc_group.exceptions: # pragma: no branch
# A required field reported above as unparsed is absent
# from `raw`, so skip from_raw's duplicate "missing"
# complaint.
if not (
isinstance(exc, InvalidMetadata)
and exc.field in unparsed
and _EMAIL_TO_RAW_MAPPING.get(exc.field) not in raw
):
collector.error(exc)
else:
if not collector.errors: # pragma: no branch
return validated
try:
return cls.from_raw(raw, validate=validate)
except ExceptionGroup as exc_group:
raise ExceptionGroup(
"invalid or unparsed metadata", exc_group.exceptions
) from None
return cls.from_raw(raw, validate=validate)
metadata_version: _Validator[_MetadataVersion] = _Validator()
""":external:ref:`core-metadata-metadata-version`
@@ -853,7 +929,7 @@ class Metadata:
""":external:ref:`core-metadata-name`
(required; validated using :func:`~packaging.utils.canonicalize_name` and its
*validate* parameter)"""
version: _Validator[version_module.Version] = _Validator()
version: _Validator[Version] = _Validator()
""":external:ref:`core-metadata-version` (required)"""
dynamic: _Validator[list[str] | None] = _Validator(
added="2.2",
@@ -889,9 +965,15 @@ class Metadata:
license_expression: _Validator[NormalizedLicenseExpression | None] = _Validator(
added="2.4"
)
""":external:ref:`core-metadata-license-expression`"""
""":external:ref:`core-metadata-license-expression`
.. versionadded:: 24.2
"""
license_files: _Validator[list[str] | None] = _Validator(added="2.4")
""":external:ref:`core-metadata-license-file`"""
""":external:ref:`core-metadata-license-file`
.. versionadded:: 24.2
"""
classifiers: _Validator[list[str] | None] = _Validator(added="1.1")
""":external:ref:`core-metadata-classifier`"""
requires_dist: _Validator[list[requirements.Requirement] | None] = _Validator(
@@ -919,9 +1001,15 @@ class Metadata:
obsoletes_dist: _Validator[list[str] | None] = _Validator(added="1.2")
""":external:ref:`core-metadata-obsoletes-dist`"""
import_names: _Validator[list[str] | None] = _Validator(added="2.5")
""":external:ref:`core-metadata-import-name`"""
""":external:ref:`core-metadata-import-name`
.. versionadded:: 26.0
"""
import_namespaces: _Validator[list[str] | None] = _Validator(added="2.5")
""":external:ref:`core-metadata-import-namespace`"""
""":external:ref:`core-metadata-import-namespace`
.. versionadded:: 26.0
"""
requires: _Validator[list[str] | None] = _Validator(added="1.1")
"""``Requires`` (deprecated)"""
provides: _Validator[list[str] | None] = _Validator(added="1.1")
@@ -932,6 +1020,8 @@ class Metadata:
def as_rfc822(self) -> RFC822Message:
"""
Return an RFC822 message with the metadata.
.. versionadded:: 26.0
"""
message = RFC822Message()
self._write_metadata(message)
+46 -8
View File
@@ -14,9 +14,14 @@ from typing import (
TypeVar,
cast,
)
from urllib.parse import urlparse
from urllib.parse import unquote, urlparse
from .markers import Environment, Marker, default_environment
from .markers import (
Environment,
Marker,
_pep440_python_full_version,
default_environment,
)
from .specifiers import SpecifierSet
from .tags import create_compatible_tags_selector, sys_tags
from .utils import (
@@ -45,6 +50,7 @@ __all__ = [
"PackageVcs",
"PackageWheel",
"Pylock",
"PylockSelectError",
"PylockUnsupportedVersionError",
"PylockValidationError",
"is_valid_pylock_path",
@@ -99,7 +105,10 @@ def _get(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T | None:
"""Get a value from the dictionary and verify it's the expected type."""
if (value := d.get(key)) is None:
return None
if not isinstance(value, expected_type):
if not isinstance(value, expected_type) or (
# Special case: bool is a subclass of int, but TOML distinguishes the two
expected_type is int and isinstance(value, bool)
):
raise PylockValidationError(
f"Unexpected type {type(value).__name__} "
f"(expected {expected_type.__name__})",
@@ -255,7 +264,8 @@ def _url_name(url: str | None) -> str | None:
if not url:
return None
url_path = urlparse(url).path
return url_path.rsplit("/", 1)[-1]
# The last path component is percent-encoded, so decode it to the file name
return unquote(url_path.rsplit("/", 1)[-1])
def _validate_hashes(hashes: Mapping[str, Any]) -> Mapping[str, Any]:
@@ -306,7 +316,10 @@ class PylockUnsupportedVersionError(PylockValidationError):
class PylockSelectError(Exception):
"""Base exception for errors raised by :meth:`Pylock.select`."""
"""Base exception for errors raised by :meth:`Pylock.select`.
.. versionadded:: 26.1
"""
@dataclass(frozen=True, init=False)
@@ -460,7 +473,10 @@ class PackageSdist:
@property
def filename(self) -> str:
"""Get the filename of the sdist."""
"""Get the filename of the sdist.
.. versionadded:: 26.1
"""
filename = self.name or _path_name(self.path) or _url_name(self.url)
if not filename:
raise PylockValidationError("Cannot determine sdist filename")
@@ -737,6 +753,7 @@ class Pylock:
tags: Sequence[Tag] | None = None,
extras: Collection[str] | None = None,
dependency_groups: Collection[str] | None = None,
prefer_sdist_predicate: Callable[[NormalizedName], bool] | None = None,
) -> Iterator[
tuple[
Package,
@@ -758,11 +775,23 @@ class Pylock:
The *dependency_groups* parameter represents the groups to install. If
unspecified, the default groups are used.
The *prefer_sdist_predicate* parameter is called for packages with a source
distribution. If it returns ``True``, the source distribution is selected
before attempting wheel compatibility. If no source distribution is
available, wheel selection proceeds as usual without calling the predicate.
This method must be used on valid Pylock instances (i.e. one obtained
from :meth:`Pylock.from_dict` or if constructed manually, after calling
:meth:`Pylock.validate`).
.. versionadded:: 26.1
.. versionchanged:: 26.3
Added the *prefer_sdist_predicate* parameter.
"""
compatible_tags_selector = create_compatible_tags_selector(tags or sys_tags())
compatible_tags_selector = create_compatible_tags_selector(
tags if tags is not None else sys_tags()
)
# #. Gather the extras and dependency groups to install and set ``extras`` and
# ``dependency_groups`` for marker evaluation, respectively.
@@ -782,7 +811,7 @@ class Pylock:
),
),
)
env_python_full_version = (
env_python_full_version = _pep440_python_full_version(
environment["python_full_version"]
if environment
else default_environment()["python_full_version"]
@@ -870,6 +899,15 @@ class Pylock:
elif package.archive is not None:
yield package, package.archive
# - Else if source preference selects an available
# :ref:`pylock-packages-sdist`:
elif (
package.sdist is not None
and prefer_sdist_predicate is not None
and prefer_sdist_predicate(package.name)
):
yield package, package.sdist
# - Else if there are entries for :ref:`pylock-packages-wheels`:
elif package.wheels:
# #. Look for the appropriate wheel file based on
File diff suppressed because it is too large Load Diff
+87 -24
View File
@@ -3,14 +3,17 @@
# for complete details.
from __future__ import annotations
from typing import Iterator
from typing import TYPE_CHECKING
from ._parser import parse_requirement as _parse_requirement
from ._tokenizer import ParserSyntaxError
from .markers import Marker, _normalize_extra_values
from .specifiers import SpecifierSet
from .specifiers import InvalidSpecifier, SpecifierSet
from .utils import canonicalize_name
if TYPE_CHECKING:
from collections.abc import Iterator
__all__ = [
"InvalidRequirement",
"Requirement",
@@ -24,6 +27,8 @@ def __dir__() -> list[str]:
class InvalidRequirement(ValueError):
"""
An invalid requirement was found, users should refer to PEP 508.
.. versionadded:: 16.1
"""
@@ -34,6 +39,18 @@ class Requirement:
URL, and extras. Raises InvalidRequirement on a badly-formed requirement
string.
.. versionadded:: 16.1
.. versionchanged:: 22.0
Added equality (``__eq__``) and hashing (``__hash__``) so requirements
can be compared and stored in sets / dicts.
.. versionchanged:: 23.2
Equality and hashing began canonicalizing requirement names, so
requirements whose names differ only by normalization (e.g.
``Requirement("Foo")`` vs ``Requirement("foo")``) now compare and hash
equal.
Instances are safe to serialize with :mod:`pickle`. They use a stable
format so the same pickle can be loaded in future packaging releases.
@@ -43,6 +60,16 @@ class Requirement:
be unpickled with future releases. Backward compatibility with pickles
from packaging < 26.2 is supported but may be removed in a future
release.
.. versionchanged:: 26.3
The dedicated pickle support introduced in 26.2 did not preserve the
specifier's explicit :attr:`~packaging.specifiers.SpecifierSet.prereleases`
override; it is now included again.
Equality and hashing normalize requirement names, extras, and
equivalent specifiers. The string representation still preserves the
parsed name and extras spelling.
"""
# TODO: Can we test whether something is contained within a requirement?
@@ -50,6 +77,8 @@ class Requirement:
# the thing as well as the version? What about the markers?
# TODO: Can we normalize the name and extra name?
__slots__ = ("extras", "marker", "name", "specifier", "url")
def __init__(self, requirement_string: str) -> None:
try:
parsed = _parse_requirement(requirement_string)
@@ -58,8 +87,11 @@ class Requirement:
self.name: str = parsed.name
self.url: str | None = parsed.url or None
self.extras: set[str] = set(parsed.extras or [])
self.specifier: SpecifierSet = SpecifierSet(parsed.specifier)
self.extras: set[str] = set(parsed.extras)
try:
self.specifier: SpecifierSet = SpecifierSet(parsed.specifier)
except InvalidSpecifier as e:
raise InvalidRequirement(str(e)) from e
self.marker: Marker | None = None
if parsed.marker is not None:
self.marker = Marker.__new__(Marker)
@@ -83,29 +115,44 @@ class Requirement:
if self.marker:
yield f"; {self.marker}"
def __getstate__(self) -> str:
# Return the requirement string for compactness and stability.
# Re-parsed on load to reconstruct all fields.
return str(self)
def __getstate__(self) -> tuple[str, bool | None]:
# Return the requirement string for compactness and stability, paired
# with the specifier's explicit prereleases override, which is not
# captured by the string form. Re-parsed on load to reconstruct all
# other fields.
return (str(self), self.specifier._prereleases)
def __setstate__(self, state: object) -> None:
if isinstance(state, str):
# New format (26.2+): just the requirement string.
try:
tmp = Requirement(state)
except InvalidRequirement as exc:
raise TypeError(f"Cannot restore Requirement from {state!r}") from exc
self.name = tmp.name
self.url = tmp.url
self.extras = tmp.extras
self.specifier = tmp.specifier
self.marker = tmp.marker
return
if isinstance(state, dict):
# Format (26.2): just the requirement string.
requirement_string: str = state
prereleases: bool | None = None
elif (
isinstance(state, tuple)
and len(state) == 2
and isinstance(state[0], str)
and (state[1] is None or isinstance(state[1], bool))
):
# New format (26.3+): (requirement string, specifier prereleases).
requirement_string, prereleases = state
elif isinstance(state, dict) and state.keys() >= set(self.__slots__):
# Old format (packaging <= 26.1, no __slots__): plain __dict__.
self.__dict__.update(state)
for key in self.__slots__:
setattr(self, key, state[key])
return
raise TypeError(f"Cannot restore Requirement from {state!r}")
else:
raise TypeError(f"Cannot restore Requirement from {state!r}")
try:
tmp = Requirement(requirement_string)
except InvalidRequirement as exc:
raise TypeError(f"Cannot restore Requirement from {state!r}") from exc
self.name = tmp.name
self.url = tmp.url
self.extras = tmp.extras
self.specifier = tmp.specifier
self.specifier._prereleases = prereleases
self.marker = tmp.marker
def __str__(self) -> str:
return "".join(self._iter_parts(self.name))
@@ -114,15 +161,31 @@ class Requirement:
return f"<{self.__class__.__name__}({str(self)!r})>"
def __hash__(self) -> int:
return hash(tuple(self._iter_parts(canonicalize_name(self.name))))
# Mirror __eq__ by hashing the canonical specifier object rather than
# its raw string. ``_iter_parts`` yields ``str(self.specifier)``, which
# is non-canonical, so trailing-zero-equivalent requirements such as
# ``foo==1.0.0`` and ``foo==1.0.0.0`` (which compare equal) would
# otherwise hash differently, breaking the hash/__eq__ invariant.
return hash(
(
canonicalize_name(self.name),
frozenset(canonicalize_name(e) for e in self.extras),
self.specifier,
self.url,
self.marker,
)
)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Requirement):
return NotImplemented
# Extras must be normalized before comparison as per PEP 685.
self_extras = frozenset(canonicalize_name(e) for e in self.extras)
other_extras = frozenset(canonicalize_name(e) for e in other.extras)
return (
canonicalize_name(self.name) == canonicalize_name(other.name)
and self.extras == other.extras
and self_extras == other_extras
and self.specifier == other.specifier
and self.url == other.url
and self.marker == other.marker
File diff suppressed because it is too large Load Diff
+181 -60
View File
@@ -12,13 +12,10 @@ import struct
import subprocess
import sys
import sysconfig
from collections.abc import Iterable, Iterator, Sequence
from importlib.machinery import EXTENSION_SUFFIXES
from typing import (
TYPE_CHECKING,
Iterable,
Iterator,
Sequence,
Tuple,
TypeVar,
cast,
)
@@ -26,15 +23,17 @@ from typing import (
from . import _manylinux, _musllinux
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
from typing import AbstractSet
from collections.abc import Callable
from collections.abc import Set as AbstractSet
__all__ = [
"INTERPRETER_SHORT_NAMES",
"AppleVersion",
"InvalidTag",
"PythonVersion",
"Tag",
"TooManyTagsError",
"UnsortedTagsError",
"android_platforms",
"compatible_tags",
@@ -47,6 +46,7 @@ __all__ = [
"mac_platforms",
"parse_tag",
"platform_tags",
"pure_python_tags",
"sys_tags",
]
@@ -58,7 +58,18 @@ def __dir__() -> list[str]:
logger = logging.getLogger(__name__)
PythonVersion = Sequence[int]
AppleVersion = Tuple[int, int]
"""
A sequence of integers describing a Python version, e.g. ``(3, 13)``.
.. versionadded:: 20.0
"""
AppleVersion = tuple[int, int]
"""
A ``(major, minor)`` integer pair describing an Apple OS version.
.. versionadded:: 24.2
"""
_T = TypeVar("_T")
INTERPRETER_SHORT_NAMES: dict[str, str] = {
@@ -82,6 +93,25 @@ _32_BIT_INTERPRETER = _compute_32_bit_interpreter()
class UnsortedTagsError(ValueError):
"""
Raised when a tag component is not in sorted order per PEP 425.
.. versionadded:: 26.1
"""
class InvalidTag(ValueError):
"""
Raised when an interpreter component is not an identifier, a tag component
is empty, or a tag does not have exactly three components.
.. versionadded:: 26.3
"""
class TooManyTagsError(ValueError):
"""
Raised when a compressed tag set exceeds the configured limit.
.. versionadded:: 26.3
"""
@@ -200,7 +230,9 @@ class Tag:
raise TypeError(f"Cannot restore Tag from {state!r}")
def parse_tag(tag: str, *, validate_order: bool = False) -> frozenset[Tag]:
def parse_tag(
tag: str, *, validate_order: bool = False, limit: int | None = None
) -> frozenset[Tag]:
"""
Parses the provided tag (e.g. `py3-none-any`) into a frozenset of
:class:`Tag` instances.
@@ -212,29 +244,70 @@ def parse_tag(tag: str, *, validate_order: bool = False) -> frozenset[Tag]:
If **validate_order** is true, compressed tag set components are checked
to be in sorted order as required by PEP 425.
If **limit** is not ``None``, the compressed tag set can generate at most
that many tags.
:param str tag: The tag to parse, e.g. ``"py3-none-any"``.
:param bool validate_order: Check whether compressed tag set components
are in sorted order.
:param int | None limit: The maximum number of tags to parse.
:raises UnsortedTagsError: If **validate_order** is true and any compressed tag
set component is not in sorted order.
:raises InvalidTag: If the interpreter field is not an identifier; if the
interpreter, ABI, or platform field (or any member of a compressed tag
set) is empty; or if the tag does not have exactly three components.
:raises TooManyTagsError: If **limit** is not ``None`` and the compressed tag
set would generate more than **limit** tags.
:raises ValueError: If **limit** is negative.
.. versionadded:: 26.1
The *validate_order* parameter.
.. versionadded:: 26.3
Raises :class:`InvalidTag` when an interpreter component is not an
identifier, a tag component is empty, or a tag does not have exactly
three components.
Added the *limit* parameter. Raises :class:`TooManyTagsError` if the compressed
tag set would generate more than *limit* tags.
"""
tags = set()
interpreters, abis, platforms = tag.split("-")
if validate_order:
for component in (interpreters, abis, platforms):
parts = component.split(".")
if parts != sorted(parts):
raise UnsortedTagsError(
f"Tag component {component!r} is not in sorted order per PEP 425"
)
for interpreter in interpreters.split("."):
for abi in abis.split("."):
for platform_ in platforms.split("."):
tags.add(Tag(interpreter, abi, platform_))
return frozenset(tags)
if limit is not None and limit < 0:
raise ValueError("limit must be non-negative")
component_parts = [component.split(".") for component in tag.split("-")]
for parts in component_parts:
if "" in parts:
component = ".".join(parts)
raise InvalidTag(f"Tag {tag!r} has an empty component: {component!r}")
if validate_order and parts != sorted(parts):
component = ".".join(parts)
raise UnsortedTagsError(
f"Tag component {component!r} is not in sorted order per PEP 425"
)
tag_count = 1
for parts in component_parts:
tag_count *= len(parts)
if limit is not None and tag_count > limit:
raise TooManyTagsError(
f"Compressed tag set would generate {tag_count} tags, exceeding "
f"limit {limit}"
)
try:
interpreters, abis, platforms = component_parts
except ValueError as exc:
raise InvalidTag(f"Tag {tag!r} must have exactly three components") from exc
for interpreter in interpreters:
if not interpreter.isidentifier():
raise InvalidTag(f"Tag {tag!r} has an invalid interpreter: {interpreter!r}")
return frozenset(
Tag(interpreter, abi, platform_)
for interpreter in interpreters
for abi in abis
for platform_ in platforms
)
def _get_config_var(name: str, warn: bool = False) -> int | str | None:
@@ -355,6 +428,8 @@ def cpython_tags(
:param Iterable platforms: Iterable of compatible platforms. Defaults to the
platforms compatible with the current system.
:param bool warn: Whether warnings should be logged. Defaults to ``False``.
.. versionadded:: 20.0
"""
if not python_version:
python_version = sys.version_info[:2]
@@ -364,8 +439,10 @@ def cpython_tags(
if abis is None:
abis = _cpython_abis(python_version, warn) if len(python_version) > 1 else []
abis = list(abis)
# 'abi3' and 'none' are explicitly handled later.
for explicit_abi in ("abi3", "none"):
threading = _is_threaded_cpython(abis)
# Stable ABIs and 'none' are explicitly handled later.
explicit_abis = ("abi3", "abi3t", "none") if threading else ("abi3", "none")
for explicit_abi in explicit_abis:
try:
abis.remove(explicit_abi)
except ValueError: # noqa: PERF203
@@ -376,10 +453,8 @@ def cpython_tags(
for platform_ in platforms:
yield Tag(interpreter, abi, platform_)
threading = _is_threaded_cpython(abis)
use_abi3 = _abi3_applies(python_version, threading)
use_abi3t = _abi3t_applies(python_version, threading)
if use_abi3:
yield from (Tag(interpreter, "abi3", platform_) for platform_ in platforms)
if use_abi3t:
@@ -417,7 +492,7 @@ def _generic_abi() -> list[str]:
# => graalpy_38_native
ext_suffix = _get_config_var("EXT_SUFFIX", warn=True)
if not isinstance(ext_suffix, str) or ext_suffix[0] != ".":
if not isinstance(ext_suffix, str) or not ext_suffix.startswith("."):
raise SystemError("invalid sysconfig.get_config_var('EXT_SUFFIX')")
parts = ext_suffix.split(".")
if len(parts) < 3:
@@ -426,7 +501,10 @@ def _generic_abi() -> list[str]:
soabi = parts[1]
if soabi.startswith("cpython"):
# non-windows
abi = "cp" + soabi.split("-")[1]
cpython_parts = soabi.split("-")
if len(cpython_parts) < 2 or not cpython_parts[1]:
raise SystemError("invalid sysconfig.get_config_var('EXT_SUFFIX')")
abi = "cp" + cpython_parts[1]
elif soabi.startswith("cp"):
# windows
abi = soabi.split("-")[0]
@@ -469,6 +547,8 @@ def generic_tags(
:param Iterable platforms: Iterable of compatible platforms. Defaults to the
platforms compatible with the current system.
:param bool warn: Whether warnings should be logged. Defaults to ``False``.
.. versionadded:: 20.0
"""
if not interpreter:
interp_name = interpreter_name()
@@ -498,6 +578,30 @@ def _py_interpreter_range(py_version: PythonVersion) -> Iterator[str]:
yield f"py{_version_nodot((py_version[0], minor))}"
def pure_python_tags(
python_version: PythonVersion | None = None,
) -> Iterator[Tag]:
"""
Yields the pure-Python tags compatible with ``python_version``.
The tags use the ``"none"`` ABI and ``"any"`` platform, so their
generation does not depend on the running platform.
.. versionadded:: 26.3
:param Sequence python_version: A one- or two-item sequence representing the
compatible version of Python. Defaults to
``sys.version_info[:2]``.
:raises ValueError: If ``python_version`` is an empty sequence.
"""
if python_version is None:
python_version = sys.version_info[:2]
elif not python_version:
raise ValueError("python_version must contain at least one item")
for version in _py_interpreter_range(python_version):
yield Tag(version, "none", "any")
def compatible_tags(
python_version: PythonVersion | None = None,
interpreter: str | None = None,
@@ -520,6 +624,8 @@ def compatible_tags(
``"cp38"``. Defaults to the current interpreter.
:param Iterable platforms: Iterable of compatible platforms. Defaults to the
platforms compatible with the current system.
.. versionadded:: 20.0
"""
if not python_version:
python_version = sys.version_info[:2]
@@ -529,8 +635,7 @@ def compatible_tags(
yield Tag(version, "none", platform_)
if interpreter:
yield Tag(interpreter, "none", "any")
for version in _py_interpreter_range(python_version):
yield Tag(version, "none", "any")
yield from pure_python_tags(python_version)
def _mac_arch(arch: str, is_32bit: bool = _32_BIT_INTERPRETER) -> str:
@@ -548,12 +653,12 @@ def _mac_binary_formats(version: AppleVersion, cpu_arch: str) -> list[str]:
if cpu_arch == "x86_64":
if version < (10, 4):
return []
formats.extend(["intel", "fat64", "fat32"])
formats.extend(["intel", "fat64", "fat3"])
elif cpu_arch == "i386":
if version < (10, 4):
return []
formats.extend(["intel", "fat32", "fat"])
formats.extend(["intel", "fat3", "fat"])
elif cpu_arch == "ppc64":
# TODO: Need to care about 32-bit PPC for ppc64 through 10.2?
@@ -564,7 +669,7 @@ def _mac_binary_formats(version: AppleVersion, cpu_arch: str) -> list[str]:
elif cpu_arch == "ppc":
if version > (10, 6):
return []
formats.extend(["fat32", "fat"])
formats.extend(["fat3", "fat"])
if cpu_arch in {"arm64", "x86_64"}:
formats.append("universal2")
@@ -598,29 +703,33 @@ def mac_platforms(
- On Windows, platform compatibility is statically specified
- On Linux, code must be run on the system itself to determine
compatibility
"""
version_str, _, cpu_arch = platform.mac_ver()
if version is None:
version = cast("AppleVersion", tuple(map(int, version_str.split(".")[:2])))
if version == (10, 16):
# When built against an older macOS SDK, Python will report macOS 10.16
# instead of the real version.
version_str = subprocess.run(
[
sys.executable,
"-sS",
"-c",
"import platform; print(platform.mac_ver()[0])",
],
check=True,
env={"SYSTEM_VERSION_COMPAT": "0"},
stdout=subprocess.PIPE,
text=True,
).stdout
version = cast("AppleVersion", tuple(map(int, version_str.split(".")[:2])))
if arch is None:
arch = _mac_arch(cpu_arch)
.. versionadded:: 20.0
"""
if version is None or arch is None:
version_str, _, cpu_arch = platform.mac_ver()
if version is None:
version = cast("AppleVersion", tuple(map(int, version_str.split(".")[:2])))
if version == (10, 16):
# When built against an older macOS SDK, Python will report macOS 10.16
# instead of the real version.
version_str = subprocess.run(
[
sys.executable,
"-sS",
"-c",
"import platform; print(platform.mac_ver()[0])",
],
check=True,
env={"SYSTEM_VERSION_COMPAT": "0"},
stdout=subprocess.PIPE,
text=True,
).stdout
version = cast(
"AppleVersion", tuple(map(int, version_str.split(".")[:2]))
)
if arch is None:
arch = _mac_arch(cpu_arch)
if (10, 0) <= version < (11, 0):
# Prior to Mac OS 11, each yearly release of Mac OS bumped the
@@ -642,7 +751,6 @@ def mac_platforms(
for binary_format in binary_formats:
yield f"macosx_{major_version}_{minor_version}_{binary_format}"
if version >= (11, 0):
# Mac OS 11 on x86_64 is compatible with binaries from previous releases.
# Arm64 support was introduced in 11.0, so no Arm binaries from previous
# releases exist.
@@ -681,6 +789,8 @@ def ios_platforms(
.. note::
Behavior of this method is undefined if invoked on non-iOS platforms
without providing explicit version and multiarch arguments.
.. versionadded:: 24.2
"""
if version is None:
# if iOS is the current platform, ios_ver *must* be defined. However,
@@ -740,6 +850,8 @@ def android_platforms(
e.g. ``arm64_v8a``. Defaults to the current system's ABI , as returned by
``sysconfig.get_platform``. Hyphens and periods will be replaced with
underscores.
.. versionadded:: 25.0
"""
if platform.system() != "Android" and (api_level is None or abi is None):
raise TypeError(
@@ -776,10 +888,10 @@ def _linux_platforms(is_32bit: bool = _32_BIT_INTERPRETER) -> Iterator[str]:
linux = "linux_armv8l"
_, arch = linux.split("_", 1)
archs = {"armv8l": ["armv8l", "armv7l"]}.get(arch, [arch])
yield from _manylinux.platform_tags(archs)
yield from _musllinux.platform_tags(archs)
for arch in archs:
yield f"linux_{arch}"
yield from _manylinux.platform_tags(archs)
yield from _musllinux.platform_tags(archs)
def _emscripten_platforms() -> Iterator[str]:
@@ -798,6 +910,8 @@ def _generic_platforms() -> Iterator[str]:
def platform_tags() -> Iterator[str]:
"""
Yields the :attr:`~Tag.platform` tags for the running interpreter.
.. versionadded:: 21.1
"""
if platform.system() == "Darwin":
return mac_platforms()
@@ -821,6 +935,8 @@ def interpreter_name() -> str:
be returned when appropriate.
This typically acts as the prefix to the :attr:`~Tag.interpreter` tag.
.. versionadded:: 20.0
"""
name = sys.implementation.name
return INTERPRETER_SHORT_NAMES.get(name) or name
@@ -833,6 +949,8 @@ def interpreter_version(*, warn: bool = False) -> str:
This typically acts as the suffix to the :attr:`~Tag.interpreter` tag.
:param bool warn: Whether warnings should be logged. Defaults to ``False``.
.. versionadded:: 20.0
"""
version = _get_config_var("py_version_nodot", warn=warn)
return str(version) if version else _version_nodot(sys.version_info[:2])
@@ -867,15 +985,18 @@ def sys_tags(*, warn: bool = False) -> Iterator[Tag]:
.. versionchanged:: 21.3
Added the `pp3-none-any` tag (:issue:`311`).
.. versionchanged:: 27.0
.. versionchanged:: 26.1
Added the `abi3t` tag (:issue:`1099`).
.. versionchanged:: 26.3
Native ``linux_*`` platform tags are now ordered before ``manylinux``
and ``musllinux`` tags (:issue:`160`).
"""
interp_name = interpreter_name()
if interp_name == "cp":
yield from cpython_tags(warn=warn)
else:
yield from generic_tags()
yield from generic_tags(warn=warn)
if interp_name == "pp":
interp = "pp3"
+84 -10
View File
@@ -5,9 +5,9 @@
from __future__ import annotations
import re
from typing import NewType, Tuple, Union, cast
from typing import NewType, Union, cast
from .tags import Tag, UnsortedTagsError, parse_tag
from .tags import InvalidTag, Tag, UnsortedTagsError, parse_tag
from .version import InvalidVersion, Version, _TrimmedRelease
__all__ = [
@@ -28,29 +28,42 @@ def __dir__() -> list[str]:
return __all__
BuildTag = Union[Tuple[()], Tuple[int, str]]
BuildTag = Union[tuple[()], tuple[int, str]]
"""
A wheel build tag: an empty tuple, or a ``(build number, build tag suffix)`` pair.
.. versionadded:: 20.9
"""
NormalizedName = NewType("NormalizedName", str)
"""
A :class:`typing.NewType` of :class:`str`, representing a normalized name.
.. versionadded:: 20.4
"""
class InvalidName(ValueError):
"""
An invalid distribution name; users should refer to the packaging user guide.
.. versionadded:: 23.2
"""
class InvalidWheelFilename(ValueError):
"""
An invalid wheel filename was found, users should refer to PEP 427.
.. versionadded:: 20.9
"""
class InvalidSdistFilename(ValueError):
"""
An invalid sdist filename was found, users should refer to the packaging user guide.
.. versionadded:: 20.9
"""
@@ -58,9 +71,12 @@ class InvalidSdistFilename(ValueError):
_validate_regex = re.compile(
r"[a-z0-9]|[a-z0-9][a-z0-9._-]*[a-z0-9]", re.IGNORECASE | re.ASCII
)
_normalized_regex = re.compile(r"[a-z0-9]|[a-z0-9]([a-z0-9-](?!--))*[a-z0-9]", re.ASCII)
_normalized_regex = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*", re.ASCII)
# PEP 427: The build number must start with a digit.
_build_tag_regex = re.compile(r"(\d+)(.*)", re.ASCII)
# PEP 427: Valid characters for an escaped project name in a wheel filename.
# Requires at least one character so an empty project name is rejected.
_wheel_name_regex = re.compile(r"^[\w._]+\Z", re.UNICODE)
def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName:
@@ -87,6 +103,14 @@ def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName:
'oslo-concurrency'
>>> canonicalize_name("requests")
'requests'
.. versionadded:: 16.2
.. versionchanged:: 20.4
The return type was changed to :class:`NormalizedName`.
.. versionchanged:: 23.2
Added the *validate* keyword parameter.
"""
if validate and not _validate_regex.fullmatch(name):
raise InvalidName(f"name is invalid: {name!r}")
@@ -102,16 +126,27 @@ def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName:
def is_normalized_name(name: str) -> bool:
"""
Check if a name is already normalized (i.e. :func:`canonicalize_name` would
roundtrip to the same value).
Check if a name is a normalized project name (i.e. a valid name that
:func:`canonicalize_name` would roundtrip to the same value).
The roundtrip only characterizes normalized names for *valid* names. A name
must start and end with an ASCII letter or digit, which
:func:`canonicalize_name` does not enforce: it leaves a leading or trailing
hyphen in place, so such a name roundtrips without being normalized.
:param str name: The name to check.
>>> from packaging.utils import is_normalized_name
>>> from packaging.utils import canonicalize_name, is_normalized_name
>>> is_normalized_name("requests")
True
>>> is_normalized_name("Django")
False
>>> canonicalize_name("_not_legal")
'-not-legal'
>>> is_normalized_name("-not-legal") # roundtrips, but not a valid name
False
.. versionadded:: 23.2
"""
return _normalized_regex.fullmatch(name) is not None
@@ -145,6 +180,14 @@ def canonicalize_version(
>>> canonicalize_version('1.4.0.0.0')
'1.4'
.. versionadded:: 17.1
.. versionchanged:: 21.0
The return type was narrowed to :class:`str`.
.. versionchanged:: 22.0
Added the *strip_trailing_zero* keyword parameter.
"""
if isinstance(version, str):
try:
@@ -196,8 +239,18 @@ def parse_wheel_filename(
>>> not build
True
.. versionadded:: 20.9
.. versionchanged:: 23.2
Raises :class:`InvalidWheelFilename` when the version component is invalid.
.. versionadded:: 26.1
The *validate_order* parameter.
.. versionchanged:: 26.3
Raises :class:`InvalidWheelFilename` when an interpreter component is
not an identifier, a tag set component is empty, or the project name is
empty.
"""
if not filename.endswith(".whl"):
raise InvalidWheelFilename(
@@ -214,7 +267,7 @@ def parse_wheel_filename(
parts = filename.split("-", dashes - 2)
name_part = parts[0]
# See PEP 427 for the rules on escaping the project name.
if "__" in name_part or re.match(r"^[\w\d._]*$", name_part, re.UNICODE) is None:
if "__" in name_part or _wheel_name_regex.match(name_part) is None:
raise InvalidWheelFilename(f"Invalid project name: {filename!r}")
name = canonicalize_name(name_part)
@@ -243,6 +296,10 @@ def parse_wheel_filename(
f"Invalid wheel filename (compressed tag set components must be in "
f"sorted order per PEP 425): {filename!r}"
) from None
except InvalidTag:
raise InvalidWheelFilename(
f"Invalid wheel filename (invalid tag component): {filename!r}"
) from None
return (name, version, build, tags)
@@ -255,8 +312,10 @@ def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]:
:param str filename: The name of the sdist file.
:raises InvalidSdistFilename: If the filename does not end
with an sdist extension (``.zip`` or ``.tar.gz``), or if it does not
contain a dash separating the name and the version of the distribution.
with an sdist extension (``.zip`` or ``.tar.gz``), if it does not
contain a dash separating the name and the version of the distribution,
if the project name is empty, or if the version portion is not a valid
version.
>>> from packaging.utils import parse_sdist_filename
>>> from packaging.version import Version
@@ -266,6 +325,17 @@ def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]:
>>> ver == Version('1.0')
True
.. versionadded:: 20.9
.. versionchanged:: 21.0
Added support for ``.zip`` source distributions.
.. versionchanged:: 23.2
Raises :class:`InvalidSdistFilename` when the version component is invalid.
.. versionchanged:: 26.3
Raises :class:`InvalidSdistFilename` on an empty project name.
.. _Source distribution format: https://packaging.python.org/specifications/source-distribution-format/#source-distribution-file-name
"""
if filename.endswith(".tar.gz"):
@@ -283,6 +353,10 @@ def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]:
name_part, sep, version_part = file_stem.rpartition("-")
if not sep:
raise InvalidSdistFilename(f"Invalid sdist filename: {filename!r}")
if not name_part:
raise InvalidSdistFilename(
f"Invalid sdist filename (empty project name): {filename!r}"
)
name = canonicalize_name(name_part)
+32 -11
View File
@@ -18,7 +18,6 @@ from typing import (
Literal,
NamedTuple,
SupportsInt,
Tuple,
TypedDict,
Union,
)
@@ -67,13 +66,13 @@ def __dir__() -> list[str]:
return __all__
LocalType = Tuple[Union[int, str], ...]
LocalType = tuple[Union[int, str], ...]
CmpLocalType = Tuple[Tuple[int, str], ...]
CmpSuffix = Tuple[int, int, int, int, int, int]
CmpLocalType = tuple[tuple[int, str], ...]
CmpSuffix = tuple[int, int, int, int, int, int]
CmpKey = Union[
Tuple[int, Tuple[int, ...], CmpSuffix],
Tuple[int, Tuple[int, ...], CmpSuffix, CmpLocalType],
tuple[int, tuple[int, ...], CmpSuffix],
tuple[int, tuple[int, ...], CmpSuffix, CmpLocalType],
]
VersionComparisonMethod = Callable[[CmpKey, CmpKey], bool]
@@ -288,10 +287,15 @@ def _validate_pre(value: object, /) -> tuple[Literal["a", "b", "rc"], int] | Non
return value
if isinstance(value, tuple) and len(value) == 2:
letter, number = value
letter = normalize_pre(letter)
if letter in {"a", "b", "rc"} and isinstance(number, int) and number >= 0:
# The letter must be a string before it can be normalized.
if (
isinstance(letter, str)
and (normalized := normalize_pre(letter)) in {"a", "b", "rc"}
and isinstance(number, int)
and number >= 0
):
# type checkers can't infer the Literal type here on letter
return (letter, number) # type: ignore[return-value]
return (normalized, number) # type: ignore[return-value]
msg = f"pre must be a tuple of ('a'|'b'|'rc', non-negative int), got {value}"
raise InvalidVersion(msg)
@@ -411,9 +415,16 @@ class Version(_BaseVersion):
If the ``version`` does not conform to PEP 440 in any way then this
exception will be raised.
"""
if _SIMPLE_VERSION_INDICATORS.issuperset(version):
try:
is_simple = _SIMPLE_VERSION_INDICATORS.issuperset(version)
except TypeError:
raise InvalidVersion(f"Invalid version: {version!r}") from None
if is_simple:
try:
self._release = tuple(map(int, version.split(".")))
except AttributeError:
raise InvalidVersion(f"Invalid version: {version!r}") from None
except ValueError:
# Empty parts (from "1..2", ".1", etc.) are invalid versions.
# Any other ValueError (e.g. int str-digits limit) should
@@ -433,7 +444,10 @@ class Version(_BaseVersion):
return
# Validate the version and parse it into pieces
match = self._regex.fullmatch(version)
try:
match = self._regex.fullmatch(version)
except TypeError:
raise InvalidVersion(f"Invalid version: {version!r}") from None
if not match:
raise InvalidVersion(f"Invalid version: {version!r}")
self._epoch = int(match.group("epoch")) if match.group("epoch") else 0
@@ -1041,6 +1055,8 @@ class Version(_BaseVersion):
>>> Version("1.2.3").major
1
.. versionadded:: 20.0
"""
return self.release[0] if len(self.release) >= 1 else 0
@@ -1052,6 +1068,8 @@ class Version(_BaseVersion):
2
>>> Version("1").minor
0
.. versionadded:: 20.0
"""
return self.release[1] if len(self.release) >= 2 else 0
@@ -1063,6 +1081,8 @@ class Version(_BaseVersion):
3
>>> Version("1").micro
0
.. versionadded:: 20.0
"""
return self.release[2] if len(self.release) >= 3 else 0
@@ -1079,6 +1099,7 @@ class _TrimmedRelease(Version):
self._post = version._post
self._local = version._local
self._key_cache = version._key_cache
self._hash_cache = version._hash_cache
return
super().__init__(version) # pragma: no cover
-26
View File
@@ -1,26 +0,0 @@
pygls-1.3.1.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
pygls-1.3.1.dist-info/LICENSE.txt,sha256=b0kVxr8adbxhHDGM8t6T3jWLMbQJ7QLrngwkWnnWCl8,11367
pygls-1.3.1.dist-info/METADATA,sha256=ZZmXz51Jk7TTtWO1hLfSWQynv6rDCM798YWVbloCqHk,4726
pygls-1.3.1.dist-info/RECORD,,
pygls-1.3.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
pygls-1.3.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
pygls/__init__.py,sha256=rdTb3X-53tCjgNpS5dRsxv0ukMDjEly2pVEOPTnjx5k,1488
pygls/capabilities.py,sha256=3tl-cqu82QpxHz-Z3NtlU6z-gbPqIvyvZ6gyZ-dr0-0,16756
pygls/client.py,sha256=loVwqagoY0BnS6RwfpYtXwhg0NiIE6Tvbj2DzpOF7GA,6292
pygls/constants.py,sha256=0YsX4Egp9jVLuAu9v8T_ThrObrVKGP916-X-9bkmId8,1470
pygls/exceptions.py,sha256=skJKYaJCXI5At2eS1pNdQ6r3_B9Z-SMlyqr0_dwUVEw,6302
pygls/feature_manager.py,sha256=7C--ra3GaG44LoZd8MaZ6Jh_8uxQMZo5jzykGwT7Fdg,8494
pygls/lsp/__init__.py,sha256=pp9PCQhGzgPPFfWID47cdz_Q0LpZjT2kFPgTS-N7iVM,5236
pygls/lsp/client.py,sha256=8JVgyjXXOblPlDhLAvNbukK-qrQNf__757elz9XYkCo,76358
pygls/progress.py,sha256=Ml8vgJ9ueFC4YUqwvrgHatX8l_O3odhvmoPOLIcMNok,2789
pygls/protocol/__init__.py,sha256=YI5xMBILWYKexx343wUDrHUVPuJFu9A48VSf0ieXYJM,1822
pygls/protocol/json_rpc.py,sha256=01nqJoCNDmZQ78tyFxPfA9_kEBHyuBe-7ZUXCqwmu4g,20124
pygls/protocol/language_server.py,sha256=b6X30DCLN4sdG85OhLVdrBJ9cweL4eKVgsc10bnMzXk,20109
pygls/protocol/lsp_meta.py,sha256=kX1nL7XGVIYWp6UUyT3yDFnhcvpoCU_3mdyzpDTtUyQ,1593
pygls/py.typed,sha256=ZfGKUcVseOxYpg6BU9EuhkP4dErsepCA4apkj_9YnYc,65
pygls/server.py,sha256=T-k2wsP0W5a6KHmE9Afrv2PTy3qACEuCPGSJPmzC30w,20753
pygls/uris.py,sha256=lknA_8hNYfs47mOFQxnFYW2TWH-8iL68ghTVD0Cccsc,5764
pygls/workspace/__init__.py,sha256=tD6ahYMIPVsDdsWJ32EhF8wK-IJy6IQGInkI-BmIAxY,2883
pygls/workspace/position_codec.py,sha256=UDv1kXFMyCDnu-iGhEbylCJP74L0TAL2hFvhoFrSpOY,8019
pygls/workspace/text_document.py,sha256=8LcxsQeawuPNDrNDm3A2oMjWxYA3YxXm3NiHyQJQp8M,9031
pygls/workspace/workspace.py,sha256=Zpd96kvVM7po7cI_XLenRAq8Wp3mjlMBX_YxO6Ecvs8,11556
@@ -1,27 +1,26 @@
Metadata-Version: 2.1
Metadata-Version: 2.4
Name: pygls
Version: 1.3.1
Version: 2.1.1
Summary: A pythonic generic language server (pronounced like 'pie glass')
Home-page: https://github.com/openlawlibrary/pygls
License: Apache-2.0
License-Expression: Apache-2.0
License-File: LICENSE.txt
Author: Open Law Library
Author-email: info@openlawlib.org
Maintainer: Tom BH
Maintainer-email: tom@tombh.co.uk
Requires-Python: >=3.8
Classifier: License :: OSI Approved :: Apache Software License
Requires-Python: >=3.9
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Provides-Extra: ws
Requires-Dist: attrs (>=24.3.0)
Requires-Dist: cattrs (>=23.1.2)
Requires-Dist: lsprotocol (==2023.0.1)
Requires-Dist: websockets (>=11.0.3) ; extra == "ws"
Project-URL: Documentation, https://pygls.readthedocs.io/en/latest
Project-URL: Repository, https://github.com/openlawlibrary/pygls
Requires-Dist: lsprotocol (==2025.0.0)
Requires-Dist: websockets (>=13.0) ; extra == "ws"
Description-Content-Type: text/markdown
[![PyPI Version](https://img.shields.io/pypi/v/pygls.svg)](https://pypi.org/project/pygls/) ![!pyversions](https://img.shields.io/pypi/pyversions/pygls.svg) ![license](https://img.shields.io/pypi/l/pygls.svg) [![Documentation Status](https://img.shields.io/badge/docs-latest-green.svg)](https://pygls.readthedocs.io/en/latest/)
@@ -32,27 +31,22 @@ _pygls_ (pronounced like "pie glass") is a pythonic generic implementation of th
## Quickstart
```python
from pygls.server import LanguageServer
from lsprotocol.types import (
TEXT_DOCUMENT_COMPLETION,
CompletionItem,
CompletionList,
CompletionParams,
)
from pygls.lsp.server import LanguageServer
from lsprotocol import types
server = LanguageServer("example-server", "v0.1")
@server.feature(TEXT_DOCUMENT_COMPLETION)
def completions(params: CompletionParams):
@server.feature(types.TEXT_DOCUMENT_COMPLETION)
def completions(params: types.CompletionParams):
items = []
document = server.workspace.get_document(params.text_document.uri)
document = server.workspace.get_text_document(params.text_document.uri)
current_line = document.lines[params.position.line].strip()
if current_line.endswith("hello."):
items = [
CompletionItem(label="world"),
CompletionItem(label="friend"),
types.CompletionItem(label="world"),
types.CompletionItem(label="friend"),
]
return CompletionList(is_incomplete=False, items=items)
return types.CompletionList(is_incomplete=False, items=items)
server.start_io()
```
@@ -79,12 +73,10 @@ There are also other Language Servers with "general" in their descriptons, or at
* https://github.com/jose-elias-alvarez/null-ls.nvim (Neovim only)
## Tests
All Pygls sub-tasks require the Poetry `poe` plugin: https://github.com/nat-n/poethepoet
* `poetry install --all-extras`
* `poetry run poe test`
* `poetry run poe test-pyodide`
All Pygls sub-tasks require the `uv`: https://docs.astral.sh/uv/getting-started/installation
* `uv run --all-extras poe test`
* `uv run --all-extras poe test-pyodide`
## Contributing
+31
View File
@@ -0,0 +1,31 @@
pygls-2.1.1.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
pygls-2.1.1.dist-info/METADATA,sha256=irBCbsPjhAwIG_0lZcboG7qJQ7R8OwGyI4heqlbWW4I,4531
pygls-2.1.1.dist-info/RECORD,,
pygls-2.1.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
pygls-2.1.1.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
pygls-2.1.1.dist-info/licenses/LICENSE.txt,sha256=b0kVxr8adbxhHDGM8t6T3jWLMbQJ7QLrngwkWnnWCl8,11367
pygls/__init__.py,sha256=pK-9Xb-tbL9uXGziKDv8Av3tQCJuMiktoqKdkV80V4w,1553
pygls/capabilities.py,sha256=bVu1cR9pTDypI9pEMGE2i87wGmMot6CdfC6gwjH2p-o,17596
pygls/cli.py,sha256=EC99J1HzlitNY6DB-9CmjKbE0NsDN427YkT9Num4TCU,2319
pygls/client.py,sha256=hlhN5nqrI-CV64Cu0yCtiawF2w9vORLSlW0uVMHSSkI,7585
pygls/constants.py,sha256=0YsX4Egp9jVLuAu9v8T_ThrObrVKGP916-X-9bkmId8,1470
pygls/exceptions.py,sha256=MXJO0-qgqck6RiXYBrtROEeXtAw0AddQRnBu8nQMxOk,6724
pygls/feature_manager.py,sha256=ozsF68bUYCFKyv0ycB9pISDEmUns7LEDflTdkRRKXSw,8895
pygls/io_.py,sha256=HeP3Q0j7fg0KvZtKDxEf1WZJ7C5O4_CpZ_S0P1DSJOA,9244
pygls/lsp/__init__.py,sha256=pp9PCQhGzgPPFfWID47cdz_Q0LpZjT2kFPgTS-N7iVM,5236
pygls/lsp/_base_client.py,sha256=kFEuMlXk1skc6Y8x02EIwOod1oD5Qs8pMfsIW1oiZMo,81733
pygls/lsp/_base_server.py,sha256=HVvEndKfPamr5FrGILD5naZIQDuOT3H4SbOhoZfMImE,17854
pygls/lsp/_capabilities.py,sha256=YIgOkdqcUl9tTZ4pz40ZmuGaDjWnauM4L5MOz2Oz9Gs,121441
pygls/lsp/client.py,sha256=zah7RZWnFSF5GYoomsbdz7VR2xuZS1krCaBzFJIHw5Y,160
pygls/lsp/server.py,sha256=2rkJ6Chq_pC7DonZ2j1MTsIBTij1N1MOkFbkRlidxvs,4174
pygls/progress.py,sha256=Ml8vgJ9ueFC4YUqwvrgHatX8l_O3odhvmoPOLIcMNok,2789
pygls/protocol/__init__.py,sha256=XYUCEBA9Cl4sF1gtmjruHWVcKRdYN-LYsdbqIuqQCkg,1718
pygls/protocol/json_rpc.py,sha256=wKy8Knv3xbszSe5PFjq7wauDQBfWtXJ4MHdeBp3q1P8,23796
pygls/protocol/language_server.py,sha256=qLFRnlEaDrycm0nJKzXI0tI21E-m_Ewk9GlyPMZcGjc,15495
pygls/py.typed,sha256=ZfGKUcVseOxYpg6BU9EuhkP4dErsepCA4apkj_9YnYc,65
pygls/server.py,sha256=MvRmgoGqEBPQGrwcvZl021CHGhWeDH9NvdeFaCDKye0,9627
pygls/uris.py,sha256=wAjK_kL-gOWcSe86BnBlsDPZ47aTm3S1EuCr9gmcMEU,5800
pygls/workspace/__init__.py,sha256=AxU6NydTtfHIih_Xd8lSUm8AydhPopVXKGRuSGnzOok,274
pygls/workspace/position_codec.py,sha256=CFRzod1pr-2vdpyGV2wNk2cxq6cqJo8WNtLH3KktfNs,9381
pygls/workspace/text_document.py,sha256=4SKRRF9gRRtljtghiPHJYram2_h-4zj2DE0MyjxZYkE,12303
pygls/workspace/workspace.py,sha256=QgeN0URYraABW6W_qATVYdStVhMCw5LGN0DFuPmx5fo,10112
@@ -1,4 +1,4 @@
Wheel-Version: 1.0
Generator: poetry-core 1.9.0
Generator: poetry-core 2.3.1
Root-Is-Purelib: true
Tag: py3-none-any
+2
View File
@@ -21,5 +21,7 @@ import sys
IS_WIN = os.name == "nt"
IS_PYODIDE = "pyodide" in sys.modules
IS_WASI = sys.platform == "wasi"
IS_WASM = IS_PYODIDE or IS_WASI
pygls = "pygls"
+70 -52
View File
@@ -14,31 +14,23 @@
# See the License for the specific language governing permissions and #
# limitations under the License. #
############################################################################
from functools import reduce
from typing import Any, Dict, List, Optional, Set, Union, TypeVar
import logging
from typing import Any, Dict, List, Optional, Set, TypeVar, Union
from lsprotocol import types
from pygls.lsp._capabilities import get_capability as get_capability
logger = logging.getLogger(__name__)
T = TypeVar("T")
def get_capability(
client_capabilities: types.ClientCapabilities, field: str, default: Any = None
) -> Any:
"""Check if ClientCapabilities has some nested value without raising
AttributeError.
e.g. get_capability('text_document.synchronization.will_save')
"""
try:
value = reduce(getattr, field.split("."), client_capabilities)
except AttributeError:
return default
# If we reach the desired leaf value but it's None, return the default.
return default if value is None else value
_SUPPORTED_ENCODINGS = frozenset(
[
types.PositionEncodingKind.Utf8,
types.PositionEncodingKind.Utf16,
types.PositionEncodingKind.Utf32,
]
)
class ServerCapabilitiesBuilder:
@@ -54,6 +46,9 @@ class ServerCapabilitiesBuilder:
commands: List[str],
text_document_sync_kind: types.TextDocumentSyncKind,
notebook_document_sync: Optional[types.NotebookDocumentSyncOptions] = None,
position_encoding: Union[
types.PositionEncodingKind, str
] = types.PositionEncodingKind.Utf16,
):
self.client_capabilities = client_capabilities
self.features = features
@@ -63,12 +58,37 @@ class ServerCapabilitiesBuilder:
self.notebook_document_sync = notebook_document_sync
self.server_cap = types.ServerCapabilities()
self.server_cap.position_encoding = position_encoding
def _provider_options(self, feature: str, default: T) -> Optional[Union[T, Any]]:
if feature in self.features:
return self.feature_options.get(feature, default)
return None
@classmethod
def choose_position_encoding(
cls, client_capabilities: types.ClientCapabilities
) -> Union[types.PositionEncodingKind, str]:
server_encoding: Union[types.PositionEncodingKind, str] = (
types.PositionEncodingKind.Utf16
)
if (general := client_capabilities.general) is None:
return server_encoding
if (encodings := general.position_encodings) is None:
return server_encoding
# We match client preference where this an overlap between its and our supported encodings.
for client_encoding in encodings:
if client_encoding in _SUPPORTED_ENCODINGS:
server_encoding = client_encoding
return server_encoding
logger.warning(f"Unknown `PositionEncoding`s: {encodings}")
return server_encoding
def _with_text_document_sync(self):
open_close = (
types.TEXT_DOCUMENT_DID_OPEN in self.features
@@ -145,7 +165,7 @@ class ServerCapabilitiesBuilder:
def _with_type_definition(self):
value = self._provider_options(
types.TEXT_DOCUMENT_TYPE_DEFINITION, default=types.TypeDefinitionOptions()
types.TEXT_DOCUMENT_TYPE_DEFINITION, default=True
)
if value is not None:
self.server_cap.type_definition_provider = value
@@ -161,9 +181,7 @@ class ServerCapabilitiesBuilder:
return self
def _with_implementation(self):
value = self._provider_options(
types.TEXT_DOCUMENT_IMPLEMENTATION, default=types.ImplementationOptions()
)
value = self._provider_options(types.TEXT_DOCUMENT_IMPLEMENTATION, default=True)
if value is not None:
self.server_cap.implementation_provider = value
return self
@@ -201,6 +219,7 @@ class ServerCapabilitiesBuilder:
types.TEXT_DOCUMENT_CODE_LENS, default=types.CodeLensOptions()
)
if value is not None:
value.resolve_provider = types.CODE_LENS_RESOLVE in self.features
self.server_cap.code_lens_provider = value
return self
@@ -209,6 +228,7 @@ class ServerCapabilitiesBuilder:
types.TEXT_DOCUMENT_DOCUMENT_LINK, default=types.DocumentLinkOptions()
)
if value is not None:
value.resolve_provider = types.DOCUMENT_LINK_RESOLVE in self.features
self.server_cap.document_link_provider = value
return self
@@ -241,9 +261,25 @@ class ServerCapabilitiesBuilder:
return self
def _with_rename(self):
value = self._provider_options(types.TEXT_DOCUMENT_RENAME, default=True)
if value is not None:
self.server_cap.rename_provider = value
server_supports_rename = types.TEXT_DOCUMENT_RENAME in self.features
if server_supports_rename is False:
return self
client_prepare_support = get_capability(
self.client_capabilities, "text_document.rename.prepare_support", False
)
# From the spec:
# > RenameOptions may only be specified if the client states that it supports
# > prepareSupport in its initial initialize request.
if not client_prepare_support:
self.server_cap.rename_provider = server_supports_rename
else:
self.server_cap.rename_provider = types.RenameOptions(
prepare_provider=types.TEXT_DOCUMENT_PREPARE_RENAME in self.features
)
return self
def _with_folding_range(self):
@@ -302,12 +338,12 @@ class ServerCapabilitiesBuilder:
self.server_cap.semantic_tokens_provider = value
return self
full_support: Union[bool, types.SemanticTokensOptionsFullType1] = (
full_support: Union[bool, types.SemanticTokensFullDelta] = (
types.TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL in self.features
)
if types.TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL_DELTA in self.features:
full_support = types.SemanticTokensOptionsFullType1(delta=True)
full_support = types.SemanticTokensFullDelta(delta=True)
options = types.SemanticTokensOptions(
legend=value,
@@ -364,7 +400,7 @@ class ServerCapabilitiesBuilder:
value = self._provider_options(method_name, default=None)
setattr(file_operations, capability_name, value)
self.server_cap.workspace = types.ServerCapabilitiesWorkspaceType(
self.server_cap.workspace = types.WorkspaceOptions(
workspace_folders=types.WorkspaceFoldersServerCapabilities(
supported=True,
change_notifications=True,
@@ -391,30 +427,12 @@ class ServerCapabilitiesBuilder:
self.server_cap.inline_value_provider = value
return self
def _with_position_encodings(self):
self.server_cap.position_encoding = types.PositionEncodingKind.Utf16
general = self.client_capabilities.general
if general is None:
return self
encodings = general.position_encodings
if encodings is None:
return self
if types.PositionEncodingKind.Utf16 in encodings:
return self
if types.PositionEncodingKind.Utf32 in encodings:
self.server_cap.position_encoding = types.PositionEncodingKind.Utf32
return self
if types.PositionEncodingKind.Utf8 in encodings:
self.server_cap.position_encoding = types.PositionEncodingKind.Utf8
return self
logger.warning(f"Unknown `PositionEncoding`s: {encodings}")
def _with_inline_completion_provider(self):
value = self._provider_options(
types.TEXT_DOCUMENT_INLINE_COMPLETION, default=None
)
if value is not None:
self.server_cap.inline_completion_provider = value
return self
def _build(self):
@@ -455,6 +473,6 @@ class ServerCapabilitiesBuilder:
._with_workspace_capabilities()
._with_diagnostic_provider()
._with_inline_value_provider()
._with_position_encodings()
._with_inline_completion_provider()
._build()
)
+45
View File
@@ -0,0 +1,45 @@
############################################################################
# Copyright(c) Open Law Library. All rights reserved. #
# See ThirdPartyNotices.txt in the project root for additional notices. #
# #
# Licensed under the Apache License, Version 2.0 (the "License") #
# you may not use this file except in compliance with the License. #
# You may obtain a copy of the License at #
# #
# http: // www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
############################################################################
"""A simple cli wrapper for pygls servers."""
from __future__ import annotations
import argparse
import typing
if typing.TYPE_CHECKING:
from pygls.server import JsonRPCServer
def start_server(server: JsonRPCServer, args: list[str] | None = None):
"""A helper function that implements a simple cli wrapper for a pygls server
allowing the user to select between the supported transports."""
name = type(server).__name__
parser = argparse.ArgumentParser(description=f"start a {name} instance")
parser.add_argument("--tcp", action="store_true", help="start a TCP server")
parser.add_argument("--ws", action="store_true", help="start a WebSocket server")
parser.add_argument("--host", default="127.0.0.1", help="bind to this address")
parser.add_argument("--port", type=int, default=8888, help="bind to this port")
arguments = parser.parse_args(args)
if arguments.tcp:
server.start_tcp(arguments.host, arguments.port)
elif arguments.ws:
server.start_ws(arguments.host, arguments.port)
else:
server.start_io()
+105 -66
View File
@@ -14,63 +14,30 @@
# See the License for the specific language governing permissions and #
# limitations under the License. #
############################################################################
from __future__ import annotations
import asyncio
import logging
import re
import sys
import typing
from threading import Event
from typing import Any
from typing import Callable
from typing import List
from typing import Optional
from typing import Type
from typing import Union
from cattrs import Converter
from pygls.exceptions import PyglsError, JsonRpcException
from pygls.exceptions import JsonRpcException, PyglsError
from pygls.io_ import run_async, run_websocket
from pygls.protocol import JsonRPCProtocol, default_converter
if typing.TYPE_CHECKING:
from typing import Any
from typing import Callable
from typing import List
from typing import Optional
from typing import Type
from cattrs import Converter
logger = logging.getLogger(__name__)
async def aio_readline(stop_event, reader, message_handler):
CONTENT_LENGTH_PATTERN = re.compile(rb"^Content-Length: (\d+)\r\n$")
# Initialize message buffer
message = []
content_length = 0
while not stop_event.is_set():
# Read a header line
header = await reader.readline()
if not header:
break
message.append(header)
# Extract content length if possible
if not content_length:
match = CONTENT_LENGTH_PATTERN.fullmatch(header)
if match:
content_length = int(match.group(1))
logger.debug("Content length: %s", content_length)
# Check if all headers have been read (as indicated by an empty line \r\n)
if content_length and not header.strip():
# Read body
body = await reader.readexactly(content_length)
if not body:
break
message.append(body)
# Pass message to protocol
message_handler(b"".join(message))
# Reset the buffer
message = []
content_length = 0
class JsonRPCClient:
"""Base JSON-RPC client."""
@@ -79,14 +46,14 @@ class JsonRPCClient:
protocol_cls: Type[JsonRPCProtocol] = JsonRPCProtocol,
converter_factory: Callable[[], Converter] = default_converter,
):
# Strictly speaking `JsonRPCProtocol` wants a `LanguageServer`, not a
# `JsonRPCClient`. However there similar enough for our purposes, which is
# that this client will mostly be used in testing contexts.
# Strictly speaking, `JsonRPCProtocol` wants a `JsonRPCServer`, not a
# `JsonRPCClient`. However they're similar enough for our purposes, which
# is that this client will mostly be used in testing contexts.
self.protocol = protocol_cls(self, converter_factory()) # type: ignore
self._server: Optional[asyncio.subprocess.Process] = None
self._stop_event = Event()
self._async_tasks: List[asyncio.Task] = []
self._async_tasks: List[asyncio.Task[Any]] = []
@property
def stopped(self) -> bool:
@@ -128,39 +95,112 @@ class JsonRPCClient:
**kwargs,
)
self.protocol.connection_made(server.stdin) # type: ignore
# Keep mypy happy
if server.stdout is None:
raise RuntimeError("Server process is missing a stdout stream")
# Keep mypy happy
if server.stdin is None:
raise RuntimeError("Server process is missing a stdin stream")
self.protocol.set_writer(server.stdin)
connection = asyncio.create_task(
aio_readline(self._stop_event, server.stdout, self.protocol.data_received)
run_async(
stop_event=self._stop_event,
reader=server.stdout,
protocol=self.protocol,
logger=logger,
error_handler=self.report_server_error,
)
)
notify_exit = asyncio.create_task(self._server_exit())
self._server = server
self._async_tasks.extend([connection, notify_exit])
async def _server_exit(self):
if self._server is not None:
await self._server.wait()
logger.debug(
"Server process %s exited with return code: %s",
self._server.pid,
self._server.returncode,
async def start_tcp(self, host: str, port: int):
"""Start communicating with a server over TCP."""
reader, writer = await asyncio.open_connection(host, port)
self.protocol.set_writer(writer)
connection = asyncio.create_task(
run_async(
stop_event=self._stop_event,
reader=reader,
protocol=self.protocol,
logger=logger,
error_handler=self.report_server_error,
)
)
self._async_tasks.extend([connection])
async def start_ws(self, host: str, port: int):
"""Start communicating with a server over WebSockets."""
try:
from websockets.asyncio.client import connect
except ImportError:
logger.exception(
"Run `pip install pygls[ws]` to install dependencies required for websockets."
)
sys.exit(1)
uri = f"ws://{host}:{port}"
websocket = await connect(uri)
connection = asyncio.create_task(
run_websocket(
stop_event=self._stop_event,
websocket=websocket,
protocol=self.protocol,
logger=logger,
error_handler=self.report_server_error,
)
)
self._async_tasks.extend([connection])
# Yield control to the event loop, gives the run_websocket task chance to spin up.
await asyncio.sleep(0)
async def _server_exit(self):
"""Cleanup handler that runs when the server process managed by the client exits"""
if self._server is None:
return
await self._server.wait()
pid = self._server.pid
returncode = self._server.returncode
reason = f"Server process {pid} exited with return code: {returncode}"
logger.debug(reason)
# Cancel any pending requests
for id_, fut in self.protocol._request_futures.items():
if not fut.done():
fut.set_exception(RuntimeError(reason))
logger.debug("Cancelled pending request '%s': %s", id_, reason)
try:
await self.server_exit(self._server)
self._stop_event.set()
except Exception:
logger.exception("Error in server_exit handler")
self._stop_event.set()
async def server_exit(self, server: asyncio.subprocess.Process):
"""Called when the server process exits."""
def _report_server_error(
self, error: Exception, source: Union[PyglsError, JsonRpcException]
self, error: Exception, source: type[PyglsError] | type[JsonRpcException]
):
try:
self.report_server_error(error, source)
except Exception:
logger.error("Unable to report error", exc_info=True)
logger.exception("Unable to report error")
def report_server_error(
self, error: Exception, source: Union[PyglsError, JsonRpcException]
self, error: Exception, source: type[PyglsError] | type[JsonRpcException]
):
"""Called when the server does something unexpected e.g. respond with malformed
JSON."""
@@ -169,8 +209,7 @@ class JsonRPCClient:
self._stop_event.set()
if self._server is not None and self._server.returncode is None:
logger.debug("Terminating server process: %s", self._server.pid)
self._server.terminate()
await self._server.wait()
if len(self._async_tasks) > 0:
await asyncio.gather(*self._async_tasks)
+29 -8
View File
@@ -16,7 +16,10 @@
# See the License for the specific language governing permissions and #
# limitations under the License. #
############################################################################
from __future__ import annotations
import traceback
from typing import Any
from typing import Set
from typing import Type
from lsprotocol.types import ResponseError
@@ -25,14 +28,23 @@ from lsprotocol.types import ResponseError
class JsonRpcException(Exception):
"""A class used as a base class for json rpc exceptions."""
def __init__(self, message=None, code=None, data=None):
message = message or getattr(self.__class__, "MESSAGE")
CODE = -32603
MESSAGE = ""
def __init__(
self,
message: str | None = None,
code: int | None = None,
data: Any | None = None,
):
message = message or self.MESSAGE
super().__init__(message)
self.message = message
self.code = code or getattr(self.__class__, "CODE")
self.message: str = message
self.code: int = code or self.CODE
self.data = data
def __eq__(self, other):
def __eq__(self, other: Any):
return (
isinstance(other, self.__class__)
and self.code == other.code
@@ -43,7 +55,7 @@ class JsonRpcException(Exception):
return hash((self.code, self.message))
@staticmethod
def from_error(error):
def from_error(error: ResponseError):
for exc_class in _EXCEPTIONS:
if exc_class.supports_code(error.code):
return exc_class(
@@ -53,7 +65,16 @@ class JsonRpcException(Exception):
return JsonRpcException(code=error.code, message=error.message, data=error.data)
@classmethod
def supports_code(cls, code):
def of(cls, exc: Any):
"""Default ``of`` implementation that raises a ``JsonRpcException`` derived from
the given exception
"""
return cls(
message=f"{cls.MESSAGE}: {exc}",
)
@classmethod
def supports_code(cls, code: int):
# Defaults to UnknownErrorCode
return getattr(cls, "CODE", -32001) == code
@@ -91,7 +112,7 @@ class JsonRpcMethodNotFound(JsonRpcException):
MESSAGE = "Method Not Found"
@classmethod
def of(cls, method):
def of(cls, method: str):
return cls(message=cls.MESSAGE + ": " + method)
+18 -5
View File
@@ -55,13 +55,21 @@ def get_help_attrs(f):
)
def has_ls_param_or_annotation(f, annotation):
"""Returns true if callable has first parameter named `ls` or type of
annotation"""
def has_ls_param_or_annotation(f, actual_type):
"""Returns true if the given callable's first parameter is
- named `ls`
- has a type annotation compatible with the given type
"""
try:
sig = inspect.signature(f)
first_p = next(itertools.islice(sig.parameters.values(), 0, 1))
return first_p.name == PARAM_LS or get_type_hints(f)[first_p.name] == annotation
if first_p.name == PARAM_LS:
return True
expected_type = get_type_hints(f)[first_p.name]
return issubclass(actual_type, expected_type)
except Exception:
return False
@@ -80,6 +88,10 @@ def wrap_with_server(f, server):
async def wrapped(*args, **kwargs):
return await f(server, *args, **kwargs)
# Used by `workspace/executeCommand` to access the original function's
# signature. Mirrors how functools.partial works.
wrapped.func = f # type: ignore[attr-defined]
else:
wrapped = functools.partial(f, server)
if is_thread_function(f):
@@ -196,7 +208,8 @@ class FeatureManager:
raise TypeError(
(
f'Options of method "{feature_name}"'
f" should be instance of type {options_type}"
f" is instance of type {type(options)}"
f" which is not a subtype of {options_type}"
)
)
self._feature_options[feature_name] = options
+296
View File
@@ -0,0 +1,296 @@
############################################################################
# Copyright(c) Open Law Library. All rights reserved. #
# See ThirdPartyNotices.txt in the project root for additional notices. #
# #
# Licensed under the Apache License, Version 2.0 (the "License") #
# you may not use this file except in compliance with the License. #
# You may obtain a copy of the License at #
# #
# http: // www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
############################################################################
from __future__ import annotations
import asyncio
import json
import logging
import re
import typing
from pygls.exceptions import JsonRpcException
if typing.TYPE_CHECKING:
import logging
import threading
from collections.abc import Awaitable
from concurrent.futures import ThreadPoolExecutor
from typing import Any, BinaryIO, Callable, Protocol
from websockets.asyncio.client import ClientConnection
from websockets.asyncio.server import ServerConnection
from pygls.protocol import JsonRPCProtocol
class Reader(Protocol):
"""An synchronous reader."""
def readline(self) -> bytes: ...
def read(self, n: int) -> bytes: ...
class Writer(Protocol):
"""An synchronous writer."""
def close(self) -> None: ...
def write(self, data: bytes) -> None: ...
class AsyncReader(typing.Protocol):
"""An asynchronous reader."""
def readline(self) -> Awaitable[bytes]: ...
def readexactly(self, n: int) -> Awaitable[bytes]: ...
class AsyncWriter(typing.Protocol):
"""An asynchronous writer."""
def close(self) -> Awaitable[None]: ...
def write(self, data: bytes) -> Awaitable[None]: ...
class StdinAsyncReader:
"""Read from stdin asynchronously."""
def __init__(self, stdin: BinaryIO, executor: ThreadPoolExecutor | None = None):
self.stdin = stdin
self._loop: asyncio.AbstractEventLoop | None = None
self.executor = executor
@property
def loop(self):
if self._loop is None:
self._loop = asyncio.get_running_loop()
return self._loop
def readline(self) -> Awaitable[bytes]:
return self.loop.run_in_executor(self.executor, self.stdin.readline)
def readexactly(self, n: int) -> Awaitable[bytes]:
return self.loop.run_in_executor(self.executor, self.stdin.read, n)
class StdoutWriter:
"""Align a stdout stream with pygls' writer interface."""
def __init__(self, stdout: BinaryIO):
self._stdout = stdout
def close(self):
self._stdout.close()
def write(self, data: bytes) -> None:
self._stdout.write(data)
self._stdout.flush()
class WebSocketWriter:
"""Align a websocket connection with pygls' writer interface"""
def __init__(self, ws: ServerConnection | ClientConnection):
self._ws = ws
def close(self) -> Awaitable[None]:
return self._ws.close()
def write(self, data: bytes) -> Awaitable[None]:
return self._ws.send(data)
async def run_async(
stop_event: threading.Event,
reader: AsyncReader,
protocol: JsonRPCProtocol,
logger: logging.Logger | None = None,
error_handler: Callable[[Exception, type[JsonRpcException]], Any] | None = None,
):
"""Run a main message processing loop, asynchronously
Parameters
----------
stop_event
A ``threading.Event`` used to break the main loop
reader
The reader to read messages from
protocol
The protocol instance that should handle the messages
logger
The logger instance to use
"""
CONTENT_LENGTH_PATTERN = re.compile(rb"^Content-Length: (\d+)\r\n$")
content_length = 0
logger = logger or logging.getLogger(__name__)
while not stop_event.is_set():
# Read a header line
header = await reader.readline()
if not header:
break
# Extract content length if possible
if not content_length:
match = CONTENT_LENGTH_PATTERN.fullmatch(header)
if match:
content_length = int(match.group(1))
logger.debug("Content length: %s", content_length)
# Check if all headers have been read (as indicated by an empty line \r\n)
if content_length and not header.strip():
# Read body
body = await reader.readexactly(content_length)
if not body:
break
try:
message = json.loads(body, object_hook=protocol.structure_message)
protocol.handle_message(message)
except Exception as exc:
logger.exception("Unable to handle message")
if error_handler:
error_handler(exc, JsonRpcException)
finally:
# Reset
content_length = 0
def run(
stop_event: threading.Event,
reader: Reader,
protocol: JsonRPCProtocol,
logger: logging.Logger | None = None,
error_handler: Callable[[Exception, type[JsonRpcException]], Any] | None = None,
):
"""Run a main message processing loop, synchronously
Parameters
----------
stop_event
A ``threading.Event`` used to break the main loop
reader
The reader to read messages from
protocol
The protocol instance that should handle the messages
logger
The logger instance to use
error_handler
Function to call when an error is encountered.
"""
CONTENT_LENGTH_PATTERN = re.compile(rb"^Content-Length: (\d+)\r\n$")
content_length = 0
logger = logger or logging.getLogger(__name__)
while not stop_event.is_set():
# Read a header line
header = reader.readline()
if not header:
break
# Extract content length if possible
if not content_length:
match = CONTENT_LENGTH_PATTERN.fullmatch(header)
if match:
content_length = int(match.group(1))
logger.debug("Content length: %s", content_length)
# Check if all headers have been read (as indicated by an empty line \r\n)
if content_length and not header.strip():
# Read body
body = reader.read(content_length)
if not body:
break
try:
message = json.loads(body, object_hook=protocol.structure_message)
protocol.handle_message(message)
except Exception as exc:
logger.exception("Unable to handle message")
if error_handler:
error_handler(exc, JsonRpcException)
finally:
# Reset
content_length = 0
async def run_websocket(
websocket: ClientConnection | ServerConnection,
stop_event: threading.Event,
protocol: JsonRPCProtocol,
logger: logging.Logger | None = None,
error_handler: Callable[[Exception, type[JsonRpcException]], Any] | None = None,
):
"""Run the main message processing loop, over websockets.
Parameters
----------
stop_event
A ``threading.Event`` used to break the main loop
websocket
The websocket to read messages from
protocol
The protocol instance that should handle the messages
logger
The logger instance to use
error_handler
Function to call when an error is encountered.
"""
logger = logger or logging.getLogger(__name__)
protocol.set_writer(WebSocketWriter(websocket), include_headers=False)
try:
from websockets.exceptions import ConnectionClosed
except ImportError:
logger.exception(
"Run `pip install pygls[ws]` to install dependencies required for websockets."
)
return
while not stop_event.is_set():
try:
logger.debug("waiting for a message...")
data = await websocket.recv(decode=False)
except ConnectionClosed:
logger.debug("Websocket connection closed.")
stop_event.set()
break
try:
message = json.loads(data, object_hook=protocol.structure_message)
protocol.handle_message(message)
except Exception as exc:
logger.exception("Unable to handle message")
if error_handler:
error_handler(exc, JsonRpcException)
logger.debug("Exiting main loop")
await websocket.close()
File diff suppressed because it is too large Load Diff
+463
View File
@@ -0,0 +1,463 @@
############################################################################
# Copyright(c) Open Law Library. All rights reserved. #
# See ThirdPartyNotices.txt in the project root for additional notices. #
# #
# Licensed under the Apache License, Version 2.0 (the "License") #
# you may not use this file except in compliance with the License. #
# You may obtain a copy of the License at #
# #
# http: // www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
############################################################################
# GENERATED FROM scripts/generate_code.py -- DO NOT EDIT
# flake8: noqa
from __future__ import annotations
from lsprotocol import types
from pygls.protocol import LanguageServerProtocol
from pygls.protocol import default_converter
from pygls.server import JsonRPCServer
import typing
if typing.TYPE_CHECKING:
from cattrs import Converter
from concurrent.futures import Future
from typing import Any
from typing import Callable
from typing import Optional
from typing import Sequence
class BaseLanguageServer(JsonRPCServer):
protocol: LanguageServerProtocol
def __init__(
self,
protocol_cls: type[LanguageServerProtocol] = LanguageServerProtocol,
converter_factory: Callable[[], Converter] = default_converter,
max_workers: int | None = None,
):
super().__init__(protocol_cls, converter_factory, max_workers)
def client_register_capability(
self,
params: types.RegistrationParams,
callback: Optional[Callable[[None], None]] = None,
) -> Future[None]:
"""Make a :lsp:`client/registerCapability` request.
The `client/registerCapability` request is sent from the server to the client to register a new capability
handler on the client side.
"""
return self.protocol.send_request("client/registerCapability", params, callback)
async def client_register_capability_async(
self,
params: types.RegistrationParams,
) -> None:
"""Make a :lsp:`client/registerCapability` request.
The `client/registerCapability` request is sent from the server to the client to register a new capability
handler on the client side.
"""
return await self.protocol.send_request_async("client/registerCapability", params)
def client_unregister_capability(
self,
params: types.UnregistrationParams,
callback: Optional[Callable[[None], None]] = None,
) -> Future[None]:
"""Make a :lsp:`client/unregisterCapability` request.
The `client/unregisterCapability` request is sent from the server to the client to unregister a previously registered capability
handler on the client side.
"""
return self.protocol.send_request("client/unregisterCapability", params, callback)
async def client_unregister_capability_async(
self,
params: types.UnregistrationParams,
) -> None:
"""Make a :lsp:`client/unregisterCapability` request.
The `client/unregisterCapability` request is sent from the server to the client to unregister a previously registered capability
handler on the client side.
"""
return await self.protocol.send_request_async("client/unregisterCapability", params)
def window_show_document(
self,
params: types.ShowDocumentParams,
callback: Optional[Callable[[types.ShowDocumentResult], None]] = None,
) -> Future[types.ShowDocumentResult]:
"""Make a :lsp:`window/showDocument` request.
A request to show a document. This request might open an
external program depending on the value of the URI to open.
For example a request to open `https://code.visualstudio.com/`
will very likely open the URI in a WEB browser.
@since 3.16.0
"""
return self.protocol.send_request("window/showDocument", params, callback)
async def window_show_document_async(
self,
params: types.ShowDocumentParams,
) -> types.ShowDocumentResult:
"""Make a :lsp:`window/showDocument` request.
A request to show a document. This request might open an
external program depending on the value of the URI to open.
For example a request to open `https://code.visualstudio.com/`
will very likely open the URI in a WEB browser.
@since 3.16.0
"""
return await self.protocol.send_request_async("window/showDocument", params)
def window_show_message_request(
self,
params: types.ShowMessageRequestParams,
callback: Optional[Callable[[Optional[types.MessageActionItem]], None]] = None,
) -> Future[Optional[types.MessageActionItem]]:
"""Make a :lsp:`window/showMessageRequest` request.
The show message request is sent from the server to the client to show a message
and a set of options actions to the user.
"""
return self.protocol.send_request("window/showMessageRequest", params, callback)
async def window_show_message_request_async(
self,
params: types.ShowMessageRequestParams,
) -> Optional[types.MessageActionItem]:
"""Make a :lsp:`window/showMessageRequest` request.
The show message request is sent from the server to the client to show a message
and a set of options actions to the user.
"""
return await self.protocol.send_request_async("window/showMessageRequest", params)
def window_work_done_progress_create(
self,
params: types.WorkDoneProgressCreateParams,
callback: Optional[Callable[[None], None]] = None,
) -> Future[None]:
"""Make a :lsp:`window/workDoneProgress/create` request.
The `window/workDoneProgress/create` request is sent from the server to the client to initiate progress
reporting from the server.
"""
return self.protocol.send_request("window/workDoneProgress/create", params, callback)
async def window_work_done_progress_create_async(
self,
params: types.WorkDoneProgressCreateParams,
) -> None:
"""Make a :lsp:`window/workDoneProgress/create` request.
The `window/workDoneProgress/create` request is sent from the server to the client to initiate progress
reporting from the server.
"""
return await self.protocol.send_request_async("window/workDoneProgress/create", params)
def workspace_apply_edit(
self,
params: types.ApplyWorkspaceEditParams,
callback: Optional[Callable[[types.ApplyWorkspaceEditResult], None]] = None,
) -> Future[types.ApplyWorkspaceEditResult]:
"""Make a :lsp:`workspace/applyEdit` request.
A request sent from the server to the client to modified certain resources.
"""
return self.protocol.send_request("workspace/applyEdit", params, callback)
async def workspace_apply_edit_async(
self,
params: types.ApplyWorkspaceEditParams,
) -> types.ApplyWorkspaceEditResult:
"""Make a :lsp:`workspace/applyEdit` request.
A request sent from the server to the client to modified certain resources.
"""
return await self.protocol.send_request_async("workspace/applyEdit", params)
def workspace_code_lens_refresh(
self,
params: None,
callback: Optional[Callable[[None], None]] = None,
) -> Future[None]:
"""Make a :lsp:`workspace/codeLens/refresh` request.
A request to refresh all code actions
@since 3.16.0
"""
return self.protocol.send_request("workspace/codeLens/refresh", params, callback)
async def workspace_code_lens_refresh_async(
self,
params: None,
) -> None:
"""Make a :lsp:`workspace/codeLens/refresh` request.
A request to refresh all code actions
@since 3.16.0
"""
return await self.protocol.send_request_async("workspace/codeLens/refresh", params)
def workspace_configuration(
self,
params: types.ConfigurationParams,
callback: Optional[Callable[[Sequence[Optional[Any]]], None]] = None,
) -> Future[Sequence[Optional[Any]]]:
"""Make a :lsp:`workspace/configuration` request.
The 'workspace/configuration' request is sent from the server to the client to fetch a certain
configuration setting.
This pull model replaces the old push model were the client signaled configuration change via an
event. If the server still needs to react to configuration changes (since the server caches the
result of `workspace/configuration` requests) the server should register for an empty configuration
change event and empty the cache if such an event is received.
"""
return self.protocol.send_request("workspace/configuration", params, callback)
async def workspace_configuration_async(
self,
params: types.ConfigurationParams,
) -> Sequence[Optional[Any]]:
"""Make a :lsp:`workspace/configuration` request.
The 'workspace/configuration' request is sent from the server to the client to fetch a certain
configuration setting.
This pull model replaces the old push model were the client signaled configuration change via an
event. If the server still needs to react to configuration changes (since the server caches the
result of `workspace/configuration` requests) the server should register for an empty configuration
change event and empty the cache if such an event is received.
"""
return await self.protocol.send_request_async("workspace/configuration", params)
def workspace_diagnostic_refresh(
self,
params: None,
callback: Optional[Callable[[None], None]] = None,
) -> Future[None]:
"""Make a :lsp:`workspace/diagnostic/refresh` request.
The diagnostic refresh request definition.
@since 3.17.0
"""
return self.protocol.send_request("workspace/diagnostic/refresh", params, callback)
async def workspace_diagnostic_refresh_async(
self,
params: None,
) -> None:
"""Make a :lsp:`workspace/diagnostic/refresh` request.
The diagnostic refresh request definition.
@since 3.17.0
"""
return await self.protocol.send_request_async("workspace/diagnostic/refresh", params)
def workspace_folding_range_refresh(
self,
params: None,
callback: Optional[Callable[[None], None]] = None,
) -> Future[None]:
"""Make a :lsp:`workspace/foldingRange/refresh` request.
@since 3.18.0
@proposed
"""
return self.protocol.send_request("workspace/foldingRange/refresh", params, callback)
async def workspace_folding_range_refresh_async(
self,
params: None,
) -> None:
"""Make a :lsp:`workspace/foldingRange/refresh` request.
@since 3.18.0
@proposed
"""
return await self.protocol.send_request_async("workspace/foldingRange/refresh", params)
def workspace_inlay_hint_refresh(
self,
params: None,
callback: Optional[Callable[[None], None]] = None,
) -> Future[None]:
"""Make a :lsp:`workspace/inlayHint/refresh` request.
@since 3.17.0
"""
return self.protocol.send_request("workspace/inlayHint/refresh", params, callback)
async def workspace_inlay_hint_refresh_async(
self,
params: None,
) -> None:
"""Make a :lsp:`workspace/inlayHint/refresh` request.
@since 3.17.0
"""
return await self.protocol.send_request_async("workspace/inlayHint/refresh", params)
def workspace_inline_value_refresh(
self,
params: None,
callback: Optional[Callable[[None], None]] = None,
) -> Future[None]:
"""Make a :lsp:`workspace/inlineValue/refresh` request.
@since 3.17.0
"""
return self.protocol.send_request("workspace/inlineValue/refresh", params, callback)
async def workspace_inline_value_refresh_async(
self,
params: None,
) -> None:
"""Make a :lsp:`workspace/inlineValue/refresh` request.
@since 3.17.0
"""
return await self.protocol.send_request_async("workspace/inlineValue/refresh", params)
def workspace_semantic_tokens_refresh(
self,
params: None,
callback: Optional[Callable[[None], None]] = None,
) -> Future[None]:
"""Make a :lsp:`workspace/semanticTokens/refresh` request.
@since 3.16.0
"""
return self.protocol.send_request("workspace/semanticTokens/refresh", params, callback)
async def workspace_semantic_tokens_refresh_async(
self,
params: None,
) -> None:
"""Make a :lsp:`workspace/semanticTokens/refresh` request.
@since 3.16.0
"""
return await self.protocol.send_request_async("workspace/semanticTokens/refresh", params)
def workspace_text_document_content_refresh(
self,
params: types.TextDocumentContentRefreshParams,
callback: Optional[Callable[[None], None]] = None,
) -> Future[None]:
"""Make a :lsp:`workspace/textDocumentContent/refresh` request.
The `workspace/textDocumentContent` request is sent from the server to the client to refresh
the content of a specific text document.
@since 3.18.0
@proposed
"""
return self.protocol.send_request("workspace/textDocumentContent/refresh", params, callback)
async def workspace_text_document_content_refresh_async(
self,
params: types.TextDocumentContentRefreshParams,
) -> None:
"""Make a :lsp:`workspace/textDocumentContent/refresh` request.
The `workspace/textDocumentContent` request is sent from the server to the client to refresh
the content of a specific text document.
@since 3.18.0
@proposed
"""
return await self.protocol.send_request_async("workspace/textDocumentContent/refresh", params)
def workspace_workspace_folders(
self,
params: None,
callback: Optional[Callable[[Optional[Sequence[types.WorkspaceFolder]]], None]] = None,
) -> Future[Optional[Sequence[types.WorkspaceFolder]]]:
"""Make a :lsp:`workspace/workspaceFolders` request.
The `workspace/workspaceFolders` is sent from the server to the client to fetch the open workspace folders.
"""
return self.protocol.send_request("workspace/workspaceFolders", params, callback)
async def workspace_workspace_folders_async(
self,
params: None,
) -> Optional[Sequence[types.WorkspaceFolder]]:
"""Make a :lsp:`workspace/workspaceFolders` request.
The `workspace/workspaceFolders` is sent from the server to the client to fetch the open workspace folders.
"""
return await self.protocol.send_request_async("workspace/workspaceFolders", params)
def cancel_request(self, params: types.CancelParams) -> None:
"""Send a :lsp:`$/cancelRequest` notification.
"""
self.protocol.notify("$/cancelRequest", params)
def log_trace(self, params: types.LogTraceParams) -> None:
"""Send a :lsp:`$/logTrace` notification.
"""
self.protocol.notify("$/logTrace", params)
def progress(self, params: types.ProgressParams) -> None:
"""Send a :lsp:`$/progress` notification.
"""
self.protocol.notify("$/progress", params)
def telemetry_event(self, params: typing.Optional[typing.Any]) -> None:
"""Send a :lsp:`telemetry/event` notification.
The telemetry event notification is sent from the server to the client to ask
the client to log telemetry data.
"""
self.protocol.notify("telemetry/event", params)
def text_document_publish_diagnostics(self, params: types.PublishDiagnosticsParams) -> None:
"""Send a :lsp:`textDocument/publishDiagnostics` notification.
Diagnostics notification are sent from the server to the client to signal
results of validation runs.
"""
self.protocol.notify("textDocument/publishDiagnostics", params)
def window_log_message(self, params: types.LogMessageParams) -> None:
"""Send a :lsp:`window/logMessage` notification.
The log message notification is sent from the server to the client to ask
the client to log a particular message.
"""
self.protocol.notify("window/logMessage", params)
def window_show_message(self, params: types.ShowMessageParams) -> None:
"""Send a :lsp:`window/showMessage` notification.
The show message notification is sent from a server to a client to ask
the client to display a particular message in the user interface.
"""
self.protocol.notify("window/showMessage", params)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
import typing
from lsprotocol import types
from pygls.exceptions import FeatureRequestError
from ._base_server import BaseLanguageServer
if typing.TYPE_CHECKING:
from typing import Callable
from typing import TypeVar
from pygls.server import ServerErrors
from pygls.progress import Progress
from pygls.workspace import Workspace
F = TypeVar("F", bound=Callable)
class LanguageServer(BaseLanguageServer):
"""The default LanguageServer
This class can be extended and it can be passed as a first argument to
registered commands/features.
.. |ServerInfo| replace:: :class:`~lsprotocol.types.ServerInfo`
Parameters
----------
name
Name of the server, used to populate |ServerInfo| which is sent to
the client during initialization
version
Version of the server, used to populate |ServerInfo| which is sent to
the client during initialization
protocol_cls
The :class:`~pygls.protocol.LanguageServerProtocol` class definition, or any
subclass of it.
max_workers
Maximum number of workers for ``ThreadPool`` and ``ThreadPoolExecutor``
text_document_sync_kind
Text document synchronization method
None
No synchronization
:attr:`~lsprotocol.types.TextDocumentSyncKind.Full`
Send entire document text with each update
:attr:`~lsprotocol.types.TextDocumentSyncKind.Incremental`
Send only the region of text that changed with each update
notebook_document_sync
Advertise :lsp:`NotebookDocument` support to the client.
"""
def __init__(
self,
name: str,
version: str,
text_document_sync_kind: types.TextDocumentSyncKind = types.TextDocumentSyncKind.Incremental,
notebook_document_sync: types.NotebookDocumentSyncOptions | None = None,
*args,
**kwargs,
):
self.name = name
self.version = version
self._text_document_sync_kind = text_document_sync_kind
self._notebook_document_sync = notebook_document_sync
self.process_id: int | None = None
super().__init__(*args, **kwargs)
@property
def client_capabilities(self) -> types.ClientCapabilities:
"""The client's capabilities."""
return self.protocol.client_capabilities
@property
def server_capabilities(self) -> types.ServerCapabilities:
"""The server's capabilities."""
return self.protocol.server_capabilities
@property
def workspace(self) -> Workspace:
"""Returns in-memory workspace."""
return self.protocol.workspace
@property
def work_done_progress(self) -> Progress:
"""Gets the object to manage client's progress bar."""
return self.protocol.progress
def report_server_error(self, error: Exception, source: ServerErrors):
"""
Sends error to the client for displaying.
By default this function does not handle LSP request errors. This is because LSP requests
require direct responses and so already have a mechanism for including unexpected errors
in the response body.
All other errors are "out of band" in the sense that the client isn't explicitly waiting
for them. For example diagnostics are returned as notifications, not responses to requests,
and so can seemingly be sent at random. Also for example consider JSON RPC serialization
and deserialization, if a payload cannot be parsed then the whole request/response cycle
cannot be completed and so one of these "out of band" error messages is sent.
These "out of band" error messages are not a requirement of the LSP spec. Pygls simply
offers this behaviour as a recommended default. It is perfectly reasonble to override this
default.
"""
if source == FeatureRequestError:
return
self.window_show_message(
types.ShowMessageParams(
message=f"Error in server: {error}",
type=types.MessageType.Error,
)
)
+1 -5
View File
@@ -1,7 +1,6 @@
import json
from typing import Any
from collections import namedtuple
from typing import Any
from lsprotocol import converters
@@ -12,7 +11,6 @@ from pygls.protocol.json_rpc import (
JsonRPCResponseMessage,
)
from pygls.protocol.language_server import LanguageServerProtocol, lsp_method
from pygls.protocol.lsp_meta import LSPMeta, call_user_feature
def _dict_to_object(d: Any):
@@ -68,8 +66,6 @@ __all__ = (
"JsonRPCRequestMessage",
"JsonRPCResponseMessage",
"JsonRPCNotification",
"LSPMeta",
"call_user_feature",
"_dict_to_object",
"_params_field_structure_hook",
"_result_field_structure_hook",
+396 -221
View File
@@ -15,54 +15,90 @@
# limitations under the License. #
############################################################################
from __future__ import annotations
import asyncio
import contextvars
import enum
import inspect
import json
import logging
import re
import sys
import uuid
import traceback
import typing
import uuid
from concurrent.futures import Future
from functools import partial
from typing import (
Any,
Dict,
List,
Optional,
Type,
Union,
TYPE_CHECKING,
)
if TYPE_CHECKING:
from pygls.server import LanguageServer, WebSocketTransportAdapter
from typing import Any, Callable, Protocol, Type, Union, runtime_checkable
import attrs
from cattrs.errors import ClassValidationError
from lsprotocol.types import (
CANCEL_REQUEST,
EXIT,
WORKSPACE_EXECUTE_COMMAND,
ResponseError,
ResponseErrorMessage,
)
from pygls.exceptions import (
FeatureNotificationError,
FeatureRequestError,
JsonRpcException,
JsonRpcInternalError,
JsonRpcInvalidParams,
JsonRpcMethodNotFound,
JsonRpcRequestCancelled,
FeatureNotificationError,
FeatureRequestError,
)
from pygls.feature_manager import FeatureManager, is_thread_function
if typing.TYPE_CHECKING:
from collections.abc import Generator
from cattrs import Converter
from pygls.io_ import AsyncWriter, Writer
from pygls.server import JsonRPCServer
MessageHandler = Union[Callable[[Any], Any],]
MessageCallback = Callable[[Future[Any]], None]
logger = logging.getLogger(__name__)
# cattrs needs access to this type definition so we cannot include it in the
# TYPE_CHECKING block above
MsgId = Union[str, int]
@runtime_checkable
class RPCNotification(Protocol):
method: str
jsonrpc: str
params: Any
@runtime_checkable
class RPCRequest(Protocol):
id: MsgId
method: str
jsonrpc: str
params: Any
@runtime_checkable
class RPCResponse(Protocol):
id: MsgId
jsonrpc: str
result: Any
@runtime_checkable
class RPCError(Protocol):
id: MsgId
jsonrpc: str
error: Any
RPCMessage = Union[RPCNotification, RPCResponse, RPCRequest, RPCError]
@attrs.define
class JsonRPCNotification:
@@ -81,7 +117,7 @@ class JsonRPCRequestMessage:
Used as a fallback for unknown types.
"""
id: Union[int, str]
id: MsgId
method: str
jsonrpc: str
params: Any
@@ -93,13 +129,13 @@ class JsonRPCResponseMessage:
Used as a fallback for unknown types.
"""
id: Union[int, str]
id: MsgId
jsonrpc: str
result: Any
class JsonRPCProtocol(asyncio.Protocol):
"""Json RPC protocol implementation using on top of `asyncio.Protocol`.
class JsonRPCProtocol:
"""Json RPC protocol implementation
Specification of the protocol can be found here:
https://www.jsonrpc.org/specification
@@ -109,86 +145,156 @@ class JsonRPCProtocol(asyncio.Protocol):
CHARSET = "utf-8"
CONTENT_TYPE = "application/vscode-jsonrpc"
MESSAGE_PATTERN = re.compile(
rb"^(?:[^\r\n]+\r\n)*"
+ rb"Content-Length: (?P<length>\d+)\r\n"
+ rb"(?:[^\r\n]+\r\n)*\r\n"
+ rb"(?P<body>{.*)",
re.DOTALL,
)
VERSION = "2.0"
def __init__(self, server: LanguageServer, converter):
def __init__(self, server: JsonRPCServer, converter: Converter):
self._server = server
self._converter = converter
self._shutdown = False
# Book keeping for in-flight requests
self._request_futures: Dict[str, Future[Any]] = {}
self._result_types: Dict[str, Any] = {}
self._ctx_msg_id: contextvars.ContextVar[MsgId | None] = contextvars.ContextVar(
"msg_id", default=None
)
self._request_futures: dict[MsgId, Future[Any]] = {}
self._result_types: dict[MsgId, Any] = {}
self.fm = FeatureManager(server, converter)
self.transport: Optional[
Union[asyncio.WriteTransport, WebSocketTransportAdapter]
] = None
self._message_buf: List[bytes] = []
self._send_only_body = False
self.writer: AsyncWriter | Writer | None = None
self._include_headers = False
def __call__(self):
return self
def _execute_notification(self, handler, *params):
"""Executes notification message handler."""
if asyncio.iscoroutinefunction(handler):
future = asyncio.ensure_future(handler(*params))
future.add_done_callback(self._execute_notification_callback)
else:
if is_thread_function(handler):
self._server.thread_pool.apply_async(handler, (*params,))
else:
handler(*params)
@property
def msg_id(self) -> MsgId | None:
"""Returns the id of the current context (if it exists)."""
ctx = contextvars.copy_context()
return ctx.get(self._ctx_msg_id)
def _execute_notification_callback(self, future):
"""Success callback used for coroutine notification message."""
if future.exception():
try:
raise future.exception()
except Exception:
error = JsonRpcInternalError.of(sys.exc_info())
logger.exception('Exception occurred in notification: "%s"', error)
def _execute_handler(
self,
msg_id: MsgId,
handler: MessageHandler,
callback: MessageCallback,
args: tuple[Any, ...] | None = None,
kwargs: dict[str, Any] | None = None,
):
"""Execute the given message handler.
# Revisit. Client does not support response with msg_id = None
# https://stackoverflow.com/questions/31091376/json-rpc-2-0-allow-notifications-to-have-an-error-response
# self._send_response(None, error=error)
Parameters
----------
msg_id
The id of the message being handled
def _execute_request(self, msg_id, handler, params):
"""Executes request message handler."""
handler
The request handler to call
callback
An optional callback function to call upon completion of the handler
args
Positional arguments to pass to the handler
kwargs
Keyword arguments to pass to the handler
"""
future: Future[Any]
args = args or tuple()
kwargs = kwargs or {}
if asyncio.iscoroutinefunction(handler):
future = asyncio.ensure_future(handler(params))
future = asyncio.ensure_future(handler(*args, **kwargs))
self._request_futures[msg_id] = future
future.add_done_callback(partial(self._execute_request_callback, msg_id))
future.add_done_callback(callback)
elif is_thread_function(handler):
future = self._server.thread_pool.submit(handler, *args, **kwargs)
self._request_futures[msg_id] = future
future.add_done_callback(callback)
elif inspect.isgeneratorfunction(handler):
future = Future()
self._request_futures[msg_id] = future
future.add_done_callback(callback)
try:
self._run_generator(
future=None, gen=handler(*args, **kwargs), result_future=future
)
except Exception as exc:
future.set_exception(exc)
else:
# Can't be canceled
if is_thread_function(handler):
self._server.thread_pool.apply_async(
handler,
(params,),
callback=partial(
self._send_response,
msg_id,
),
error_callback=partial(self._execute_request_err_callback, msg_id),
)
else:
self._send_response(msg_id, handler(params))
# While a future is not necessary for a synchronous function, it allows us to use a single
# pattern across all handler types
future = Future()
future.add_done_callback(callback)
try:
result = handler(*args, **kwargs)
future.set_result(result)
except Exception as exc:
future.set_exception(exc)
def _run_generator(
self,
future: Future[Any] | None,
*,
gen: Generator[Any, Any, Any],
result_future: Future[Any],
):
"""Run the next portion of the given generator.
Generator handlers are designed to ``yield`` to other handlers that are executed
separately before their results are sent back into the generator allowing
execution to continue.
Generator handlers are primarily used in the implementation of pygls' builtin
feature handlers.
Parameters
----------
future
The future that contains the result of the previously executed handler, if any
gen
The generator to run
result_future
The future to send the final result to once the generator stops.
"""
if result_future.cancelled():
return
try:
value = future.result() if future is not None else None
handler, args, kwargs = gen.send(value)
self._execute_handler(
str(uuid.uuid4()),
handler,
args=args,
kwargs=kwargs,
callback=partial(
self._run_generator, gen=gen, result_future=result_future
),
)
except StopIteration as result:
result_future.set_result(result.value)
except Exception as exc:
result_future.set_exception(exc)
def _send_handler_result(self, future: Future[Any], *, msg_id: MsgId):
"""Callback function that sends the result of the given future to the client.
Used to respond to request messages.
"""
self._request_futures.pop(msg_id, None)
def _execute_request_callback(self, msg_id, future):
"""Success callback used for coroutine request message."""
try:
if not future.cancelled():
self._send_response(msg_id, result=future.result())
@@ -199,30 +305,41 @@ class JsonRPCProtocol(asyncio.Protocol):
f'Request with id "{msg_id}" is canceled'
).to_response_error(),
)
self._request_futures.pop(msg_id, None)
except JsonRpcException as exc:
logger.exception('Exception occurred for message "%s"', msg_id)
self._send_response(msg_id, error=exc.to_response_error())
self._server._report_server_error(exc, FeatureRequestError)
except Exception:
error = JsonRpcInternalError.of(sys.exc_info())
logger.exception('Exception occurred for message "%s": %s', msg_id, error)
logger.exception('Exception occurred for message "%s"', msg_id)
self._send_response(msg_id, error=error.to_response_error())
self._server._report_server_error(error, FeatureRequestError)
def _execute_request_err_callback(self, msg_id, exc):
"""Error callback used for coroutine request message."""
exc_info = (type(exc), exc, None)
error = JsonRpcInternalError.of(exc_info)
logger.exception('Exception occurred for message "%s": %s', msg_id, error)
self._send_response(msg_id, error=error.to_response_error())
def _check_handler_result(self, future: Future[Any]):
"""Check the result of the future to see if an error occurred.
def _get_handler(self, feature_name):
"""Returns builtin or used defined feature by name if exists."""
try:
return self.fm.builtin_features[feature_name]
except KeyError:
Used when handling notification messages
"""
if not future.cancelled() and (exc := future.exception()) is not None:
try:
return self.fm.features[feature_name]
except KeyError:
raise JsonRpcMethodNotFound.of(feature_name)
raise exc
except Exception:
error = JsonRpcInternalError.of(sys.exc_info())
self._server._report_server_error(error, FeatureNotificationError)
def _handle_cancel_notification(self, msg_id):
def _get_handler(self, feature_name: str) -> MessageHandler:
"""Returns builtin or used defined feature by name if exists."""
if (handler := self.fm.builtin_features.get(feature_name)) is not None:
return handler
if (handler := self.fm.features.get(feature_name)) is not None:
return handler
raise JsonRpcMethodNotFound.of(feature_name)
def _handle_cancel_notification(self, msg_id: MsgId):
"""Handles a cancel notification from the client."""
future = self._request_futures.pop(msg_id, None)
@@ -234,7 +351,7 @@ class JsonRPCProtocol(asyncio.Protocol):
if future.cancel():
logger.info('Cancelled request with id "%s"', msg_id)
def _handle_notification(self, method_name, params):
def _handle_notification(self, method_name: str, params: Any):
"""Handles a notification from the client."""
if method_name == CANCEL_REQUEST:
self._handle_cancel_notification(params.id)
@@ -242,29 +359,45 @@ class JsonRPCProtocol(asyncio.Protocol):
try:
handler = self._get_handler(method_name)
self._execute_notification(handler, params)
except (KeyError, JsonRpcMethodNotFound):
logger.warning('Ignoring notification for unknown method "%s"', method_name)
self._execute_handler(
msg_id=str(uuid.uuid4()),
handler=handler,
args=(params,),
callback=self._check_handler_result,
)
except JsonRpcMethodNotFound:
logger.warning("Ignoring notification for unknown method %r", method_name)
except Exception as error:
logger.exception(
'Failed to handle notification "%s": %s',
"Failed to handle notification %r: %s",
method_name,
params,
exc_info=True,
)
self._server._report_server_error(error, FeatureNotificationError)
def _handle_request(self, msg_id, method_name, params):
def _handle_request(self, msg_id: MsgId, method_name: str, params: Any):
"""Handles a request from the client."""
try:
handler = self._get_handler(method_name)
# workspace/executeCommand is a special case
if method_name == WORKSPACE_EXECUTE_COMMAND:
handler(params, msg_id)
else:
self._execute_request(msg_id, handler, params)
# Set the request id within the current context.
self._ctx_msg_id.set(msg_id)
self._execute_handler(
msg_id=msg_id,
handler=handler,
args=(params,),
callback=partial(self._send_handler_result, msg_id=msg_id),
)
except JsonRpcMethodNotFound as error:
logger.warning(
"Failed to handle request %r, unknown method %r",
msg_id,
method_name,
)
self._send_response(msg_id, None, error.to_response_error())
self._server._report_server_error(error, FeatureRequestError)
except JsonRpcException as error:
logger.exception(
"Failed to handle request %s %s %s",
@@ -287,7 +420,12 @@ class JsonRPCProtocol(asyncio.Protocol):
self._send_response(msg_id, None, err)
self._server._report_server_error(error, FeatureRequestError)
def _handle_response(self, msg_id, result=None, error=None):
def _handle_response(
self,
msg_id: MsgId,
result: Any | None = None,
error: ResponseError | None = None,
):
"""Handles a response from the client."""
future = self._request_futures.pop(msg_id, None)
@@ -302,7 +440,7 @@ class JsonRPCProtocol(asyncio.Protocol):
logger.debug('Received result for message "%s": %s', msg_id, result)
future.set_result(result)
def _serialize_message(self, data):
def _serialize_message(self, data: Any) -> dict[str, Any]:
"""Function used to serialize data sent to the client."""
if hasattr(data, "__attrs_attrs__"):
@@ -313,7 +451,7 @@ class JsonRPCProtocol(asyncio.Protocol):
return data.__dict__
def _deserialize_message(self, data):
def structure_message(self, data: dict[str, Any]):
"""Function used to deserialize data recevied from the client."""
if "jsonrpc" not in data:
@@ -330,7 +468,8 @@ class JsonRPCProtocol(asyncio.Protocol):
return self._converter.structure(data, request_type)
else:
response_type = (
self._result_types.pop(data["id"]) or JsonRPCResponseMessage
self._result_types.pop(data["id"], None)
or JsonRPCResponseMessage
)
return self._converter.structure(data, response_type)
@@ -347,7 +486,7 @@ class JsonRPCProtocol(asyncio.Protocol):
logger.error("Unable to deserialize message\n%s", traceback.format_exc())
raise JsonRpcInternalError() from exc
def _procedure_handler(self, message):
def handle_message(self, message: RPCMessage):
"""Delegates message to handlers depending on message type."""
if message.jsonrpc != JsonRPCProtocol.VERSION:
@@ -358,27 +497,31 @@ class JsonRPCProtocol(asyncio.Protocol):
logger.warning("Server shutting down. No more requests!")
return
if hasattr(message, "method"):
if hasattr(message, "id"):
logger.debug("Request message received.")
self._handle_request(message.id, message.method, message.params)
else:
logger.debug("Notification message received.")
self._handle_notification(message.method, message.params)
else:
if hasattr(message, "error"):
logger.debug("Error message received.")
self._handle_response(message.id, None, message.error)
else:
logger.debug("Response message received.")
self._handle_response(message.id, message.result)
# Run each handler within its own context.
ctx = contextvars.copy_context()
def _send_data(self, data):
if isinstance(message, RPCRequest):
logger.debug("Request %r received", message.method)
ctx.run(self._handle_request, message.id, message.method, message.params)
elif isinstance(message, RPCNotification):
logger.debug("Notification %r received", message.method)
ctx.run(self._handle_notification, message.method, message.params)
elif isinstance(message, RPCResponse):
logger.debug("Response message received.")
ctx.run(self._handle_response, message.id, message.result)
else:
logger.debug("Error message received.")
ctx.run(self._handle_response, message.id, None, message.error)
def _send_data(self, data: Any):
"""Sends data to the client."""
if not data:
return
if self.transport is None:
if self.writer is None:
logger.error("Unable to send data, no available transport!")
return
@@ -386,31 +529,49 @@ class JsonRPCProtocol(asyncio.Protocol):
body = json.dumps(data, default=self._serialize_message)
logger.info("Sending data: %s", body)
if self._send_only_body:
# Mypy/Pyright seem to think `write()` wants `"bytes | bytearray | memoryview"`
# But runtime errors with anything but `str`.
self.transport.write(body) # type: ignore
return
if self._include_headers:
header = (
f"Content-Length: {len(body)}\r\n"
f"Content-Type: {self.CONTENT_TYPE}; charset={self.CHARSET}\r\n\r\n"
)
data = header + body
else:
data = body
header = (
f"Content-Length: {len(body)}\r\n"
f"Content-Type: {self.CONTENT_TYPE}; charset={self.CHARSET}\r\n\r\n"
).encode(self.CHARSET)
res = self.writer.write(data.encode(self.CHARSET))
if inspect.isawaitable(res):
asyncio.ensure_future(res)
self.transport.write(header + body.encode(self.CHARSET))
except BrokenPipeError:
logger.exception("Error sending data. BrokenPipeError", exc_info=True)
raise
except Exception as error:
logger.exception("Error sending data", exc_info=True)
self._server._report_server_error(error, JsonRpcInternalError)
def _send_response(
self, msg_id, result=None, error: Union[ResponseError, None] = None
self,
msg_id: MsgId,
result: Any | None = None,
error: Union[ResponseError, None] = None,
):
"""Sends a JSON RPC response to the client.
"""Send a JSON-RPC response
Args:
msg_id(str): Id from request
result(any): Result returned by handler
error(any): Error returned by handler
.. important::
You should only set ``result`` OR ``error``.
If both are set, then the ``result`` value will be ignored.
Parameters
----------
msg_id
The id of the message to respond to
result
The result to send in the event of a success
error
The error to send in the event of a failure
"""
if error is not None:
@@ -424,70 +585,51 @@ class JsonRPCProtocol(asyncio.Protocol):
self._send_data(response)
def connection_lost(self, exc):
"""Method from base class, called when connection is lost, in which case we
want to shutdown the server's process as well.
"""
logger.error("Connection to the client is lost! Shutting down the server.")
sys.exit(1)
def connection_made( # type: ignore # see: https://github.com/python/typeshed/issues/3021
def set_writer(
self,
transport: asyncio.Transport,
writer: AsyncWriter | Writer,
include_headers: bool = True,
):
"""Method from base class, called when connection is established"""
self.transport = transport
"""Set the writer object to use when sending data
def data_received(self, data: bytes):
try:
self._data_received(data)
except Exception as error:
logger.exception("Error receiving data", exc_info=True)
self._server._report_server_error(error, JsonRpcInternalError)
Parameters
----------
writer
The writer object
def _data_received(self, data: bytes):
"""Method from base class, called when server receives the data"""
logger.debug("Received %r", data)
include_headers
Flag indicating if headers like ``Content-Length`` should be included when
sending data. (Default ``True``)
"""
self.writer = writer
self._include_headers = include_headers
while len(data):
# Append the incoming chunk to the message buffer
self._message_buf.append(data)
# Look for the body of the message
message = b"".join(self._message_buf)
found = JsonRPCProtocol.MESSAGE_PATTERN.fullmatch(message)
body = found.group("body") if found else b""
length = int(found.group("length")) if found else 1
if len(body) < length:
# Message is incomplete; bail until more data arrives
return
# Message is complete;
# extract the body and any remaining data,
# and reset the buffer for the next message
body, data = body[:length], body[length:]
self._message_buf = []
# Parse the body
self._procedure_handler(
json.loads(
body.decode(self.CHARSET), object_hook=self._deserialize_message
)
)
def get_message_type(self, method: str) -> Optional[Type]:
def get_message_type(self, method: str) -> Type[Any] | None:
"""Return the type definition of the message associated with the given method."""
return None
def get_result_type(self, method: str) -> Optional[Type]:
def get_result_type(self, method: str) -> Type[Any] | None:
"""Return the type definition of the result associated with the given method."""
return None
def notify(self, method: str, params=None):
"""Sends a JSON RPC notification to the client."""
def notify(self, method: str, params: Any | None = None):
"""Send a JSON-RPC notification.
.. note::
Notifications are "fire-and-forget", there is no way for the recipient to
respond directly to a notification. If you expect a response to this message,
use ``send_request``.
Parameters
----------
method
The method name of the message to send
params
The payload of the message
"""
logger.debug("Sending notification: '%s' %s", method, params)
notification_type = self.get_message_type(method) or JsonRPCNotification
@@ -497,15 +639,35 @@ class JsonRPCProtocol(asyncio.Protocol):
self._send_data(notification)
def send_request(self, method, params=None, callback=None, msg_id=None):
"""Sends a JSON RPC request to the client.
def send_request(
self,
method: str,
params: Any | None = None,
callback: Callable[[Any], None] | None = None,
msg_id: MsgId | None = None,
) -> Future[Any]:
"""Send a JSON-RPC request
Args:
method(str): The method name of the message to send
params(any): The payload of the message
Parameters
----------
method
The method name of the message to send
Returns:
Future that will be resolved once a response has been received
params
The payload of the message
callback
If set, the given callback will be called with the result of the future
when it resolves
msg_id
Send the request using the given id, if ``None``, an id will be automatically
generated
Returns
-------
Future[Any]
A future that will resolve once a response has been received
"""
if msg_id is None:
@@ -521,12 +683,12 @@ class JsonRPCProtocol(asyncio.Protocol):
jsonrpc=JsonRPCProtocol.VERSION,
)
future = Future() # type: ignore[var-annotated]
future: Future[Any] = Future()
# If callback function is given, call it when result is received
if callback:
def wrapper(future: Future):
result = future.result()
def wrapper(fut: Future[Any]):
result = fut.result()
logger.info("Client response for %s received: %s", params, result)
callback(result)
@@ -539,22 +701,35 @@ class JsonRPCProtocol(asyncio.Protocol):
return future
def send_request_async(self, method, params=None, msg_id=None):
"""Calls `send_request` and wraps `concurrent.futures.Future` with
`asyncio.Future` so it can be used with `await` keyword.
def send_request_async(
self, method: str, params: Any | None = None, msg_id: MsgId | None = None
):
"""Send a JSON-RPC request, asynchronously.
Args:
method(str): The method name of the message to send
params(any): The payload of the message
msg_id(str|int): Optional, message id
This method calls `send_request`, wrapping the resulting future with
``asyncio.wrap_future`` so it can be used in an ``async def`` function and
awaited with the ``await`` keyword.
Returns:
`asyncio.Future` that can be awaited
Parameters
----------
method
The method name of the message to send
params
The payload of the message
callback
If set, the given callback will be called with the result of the future
when it resolves
msg_id
Send the request using the given id, if ``None``, an id will be automatically
generated
Returns
-------
`asyncio.Future` that can be awaited
"""
return asyncio.wrap_future(
self.send_request(method, params=params, msg_id=msg_id)
)
def thread(self):
"""Decorator that mark function to execute it in a thread."""
return self.fm.thread()
+231 -372
View File
@@ -15,88 +15,34 @@
# limitations under the License. #
############################################################################
from __future__ import annotations
import asyncio
import inspect
import json
import logging
import sys
from concurrent.futures import Future
import typing
from functools import lru_cache
from itertools import zip_longest
from typing import (
Callable,
List,
Optional,
Type,
TypeVar,
Union,
)
from lsprotocol import types
from pygls.capabilities import ServerCapabilitiesBuilder
from pygls.lsp import ConfigCallbackType, ShowDocumentCallbackType
from lsprotocol.types import (
CLIENT_REGISTER_CAPABILITY,
CLIENT_UNREGISTER_CAPABILITY,
EXIT,
INITIALIZE,
INITIALIZED,
METHOD_TO_TYPES,
NOTEBOOK_DOCUMENT_DID_CHANGE,
NOTEBOOK_DOCUMENT_DID_CLOSE,
NOTEBOOK_DOCUMENT_DID_OPEN,
LOG_TRACE,
SET_TRACE,
SHUTDOWN,
TEXT_DOCUMENT_DID_CHANGE,
TEXT_DOCUMENT_DID_CLOSE,
TEXT_DOCUMENT_DID_OPEN,
TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS,
WINDOW_LOG_MESSAGE,
WINDOW_SHOW_DOCUMENT,
WINDOW_SHOW_MESSAGE,
WINDOW_WORK_DONE_PROGRESS_CANCEL,
WORKSPACE_APPLY_EDIT,
WORKSPACE_CONFIGURATION,
WORKSPACE_DID_CHANGE_WORKSPACE_FOLDERS,
WORKSPACE_EXECUTE_COMMAND,
WORKSPACE_SEMANTIC_TOKENS_REFRESH,
)
from lsprotocol.types import (
ApplyWorkspaceEditParams,
Diagnostic,
DidChangeNotebookDocumentParams,
DidChangeTextDocumentParams,
DidChangeWorkspaceFoldersParams,
DidCloseNotebookDocumentParams,
DidCloseTextDocumentParams,
DidOpenNotebookDocumentParams,
DidOpenTextDocumentParams,
ExecuteCommandParams,
InitializeParams,
InitializeResult,
LogMessageParams,
LogTraceParams,
MessageType,
PublishDiagnosticsParams,
RegistrationParams,
SetTraceParams,
ShowDocumentParams,
ShowMessageParams,
TraceValues,
UnregistrationParams,
WorkspaceApplyEditResponse,
WorkspaceEdit,
InitializeResultServerInfoType,
WorkspaceConfigurationParams,
WorkDoneProgressCancelParams,
)
from pygls.constants import PARAM_LS
from pygls.exceptions import JsonRpcInvalidParams
from pygls.protocol.json_rpc import JsonRPCProtocol
from pygls.protocol.lsp_meta import LSPMeta
from pygls.uris import from_fs_path
from pygls.workspace import Workspace
if typing.TYPE_CHECKING:
from collections.abc import Generator
from typing import Any, Callable, Optional, Type, TypeVar
F = TypeVar("F", bound=Callable)
from cattrs import Converter
from pygls.lsp.server import LanguageServer
F = TypeVar("F", bound=Callable)
logger = logging.getLogger(__name__)
@@ -109,7 +55,7 @@ def lsp_method(method_name: str) -> Callable[[F], F]:
return decorator
class LanguageServerProtocol(JsonRPCProtocol, metaclass=LSPMeta):
class LanguageServerProtocol(JsonRPCProtocol):
"""A class that represents language server protocol.
It contains implementations for generic LSP features.
@@ -118,17 +64,19 @@ class LanguageServerProtocol(JsonRPCProtocol, metaclass=LSPMeta):
workspace(Workspace): In memory workspace
"""
def __init__(self, server, converter):
_server: LanguageServer
def __init__(self, server: LanguageServer, converter: Converter):
super().__init__(server, converter)
self._workspace: Optional[Workspace] = None
self.trace = None
self.trace = types.TraceValue.Off
from pygls.progress import Progress
self.progress = Progress(self)
self.server_info = InitializeResultServerInfoType(
self.server_info = types.ServerInfo(
name=server.name,
version=server.version,
)
@@ -155,40 +103,38 @@ class LanguageServerProtocol(JsonRPCProtocol, metaclass=LSPMeta):
return self._workspace
@lru_cache()
def get_message_type(self, method: str) -> Optional[Type]:
def get_message_type(self, method: str) -> Type[Any] | None:
"""Return LSP type definitions, as provided by `lsprotocol`"""
return METHOD_TO_TYPES.get(method, (None,))[0]
return types.METHOD_TO_TYPES.get(method, (None,))[0]
@lru_cache()
def get_result_type(self, method: str) -> Optional[Type]:
return METHOD_TO_TYPES.get(method, (None, None))[1]
def get_result_type(self, method: str) -> Type[Any] | None:
return types.METHOD_TO_TYPES.get(method, (None, None))[1]
def apply_edit(
self, edit: WorkspaceEdit, label: Optional[str] = None
) -> WorkspaceApplyEditResponse:
"""Sends apply edit request to the client."""
return self.send_request(
WORKSPACE_APPLY_EDIT, ApplyWorkspaceEditParams(edit=edit, label=label)
)
def apply_edit_async(
self, edit: WorkspaceEdit, label: Optional[str] = None
) -> WorkspaceApplyEditResponse:
"""Sends apply edit request to the client. Should be called with `await`"""
return self.send_request_async(
WORKSPACE_APPLY_EDIT, ApplyWorkspaceEditParams(edit=edit, label=label)
)
@lsp_method(EXIT)
def lsp_exit(self, *args) -> None:
@lsp_method(types.EXIT)
def lsp_exit(self, *args) -> Generator[Any, Any, None]:
"""Stops the server process."""
if self.transport is not None:
self.transport.close()
sys.exit(0 if self._shutdown else 1)
# Ensure that the user handler is called first
if (user_handler := self.fm.features.get(types.EXIT)) is not None:
yield user_handler, args, None
@lsp_method(INITIALIZE)
def lsp_initialize(self, params: InitializeParams) -> InitializeResult:
returncode = 0 if self._shutdown else 1
if self.writer is None:
sys.exit(returncode)
res = self.writer.close()
if inspect.isawaitable(res):
# Only call sys.exit once the close task has completed.
fut = asyncio.ensure_future(res)
fut.add_done_callback(lambda t: sys.exit(returncode))
else:
sys.exit(returncode)
@lsp_method(types.INITIALIZE)
def lsp_initialize(
self, params: types.InitializeParams
) -> Generator[Any, Any, types.InitializeResult]:
"""Method that initializes language server.
It will compute and return server capabilities based on
registered features.
@@ -200,19 +146,9 @@ class LanguageServerProtocol(JsonRPCProtocol, metaclass=LSPMeta):
text_document_sync_kind = self._server._text_document_sync_kind
notebook_document_sync = self._server._notebook_document_sync
# Initialize server capabilities
self.client_capabilities = params.capabilities
self.server_capabilities = ServerCapabilitiesBuilder(
self.client_capabilities,
set({**self.fm.features, **self.fm.builtin_features}.keys()),
self.fm.feature_options,
list(self.fm.commands.keys()),
text_document_sync_kind,
notebook_document_sync,
).build()
logger.debug(
"Server capabilities: %s",
json.dumps(self.server_capabilities, default=self._serialize_message),
position_encoding = ServerCapabilitiesBuilder.choose_position_encoding(
self.client_capabilities
)
root_path = params.root_path
@@ -220,86 +156,144 @@ class LanguageServerProtocol(JsonRPCProtocol, metaclass=LSPMeta):
if root_path is not None and root_uri is None:
root_uri = from_fs_path(root_path)
# Initialize the workspace
# Initialize the workspace before yielding to the user's initialize handler
workspace_folders = params.workspace_folders or []
self._workspace = Workspace(
root_uri,
text_document_sync_kind,
workspace_folders,
self.server_capabilities.position_encoding,
position_encoding,
)
self.trace = TraceValues.Off
if (user_handler := self.fm.features.get(types.INITIALIZE)) is not None:
yield user_handler, (params,), None
return InitializeResult(
# Now that the user has had the opportunity to setup additional features, calculate
# the server's capabilities
self.server_capabilities = ServerCapabilitiesBuilder(
self.client_capabilities,
set({**self.fm.features, **self.fm.builtin_features}.keys()),
self.fm.feature_options,
list(self.fm.commands.keys()),
text_document_sync_kind,
notebook_document_sync,
position_encoding,
).build()
logger.debug(
"Server capabilities: %s",
json.dumps(self.server_capabilities, default=self._serialize_message),
)
return types.InitializeResult(
capabilities=self.server_capabilities,
server_info=self.server_info,
)
@lsp_method(INITIALIZED)
def lsp_initialized(self, *args) -> None:
@lsp_method(types.INITIALIZED)
def lsp_initialized(self, *args):
"""Notification received when client and server are connected."""
pass
@lsp_method(SHUTDOWN)
def lsp_shutdown(self, *args) -> None:
if (user_handler := self.fm.features.get(types.INITIALIZED)) is not None:
yield user_handler, args, None
@lsp_method(types.SHUTDOWN)
def lsp_shutdown(self, *args) -> Generator[Any, Any, None]:
"""Request from client which asks server to shutdown."""
for future in self._request_futures.values():
future.cancel()
if (user_handler := self.fm.features.get(types.SHUTDOWN)) is not None:
yield user_handler, args, None
# Don't cancel the future for this request!
current_id = self.msg_id
for msg_id, future in self._request_futures.items():
if msg_id != current_id and not future.done():
future.cancel()
self._shutdown = True
return None
@lsp_method(TEXT_DOCUMENT_DID_CHANGE)
def lsp_text_document__did_change(
self, params: DidChangeTextDocumentParams
) -> None:
@lsp_method(types.TEXT_DOCUMENT_DID_CHANGE)
def lsp_text_document__did_change(self, params: types.DidChangeTextDocumentParams):
"""Updates document's content.
(Incremental(from server capabilities); not configurable for now)
"""
for change in params.content_changes:
self.workspace.update_text_document(params.text_document, change)
@lsp_method(TEXT_DOCUMENT_DID_CLOSE)
def lsp_text_document__did_close(self, params: DidCloseTextDocumentParams) -> None:
if (
user_handler := self.fm.features.get(types.TEXT_DOCUMENT_DID_CHANGE)
) is not None:
yield user_handler, (params,), None
@lsp_method(types.TEXT_DOCUMENT_DID_CLOSE)
def lsp_text_document__did_close(self, params: types.DidCloseTextDocumentParams):
"""Removes document from workspace."""
self.workspace.remove_text_document(params.text_document.uri)
@lsp_method(TEXT_DOCUMENT_DID_OPEN)
def lsp_text_document__did_open(self, params: DidOpenTextDocumentParams) -> None:
if (
user_handler := self.fm.features.get(types.TEXT_DOCUMENT_DID_CLOSE)
) is not None:
yield user_handler, (params,), None
@lsp_method(types.TEXT_DOCUMENT_DID_OPEN)
def lsp_text_document__did_open(self, params: types.DidOpenTextDocumentParams):
"""Puts document to the workspace."""
self.workspace.put_text_document(params.text_document)
@lsp_method(NOTEBOOK_DOCUMENT_DID_OPEN)
if (
user_handler := self.fm.features.get(types.TEXT_DOCUMENT_DID_OPEN)
) is not None:
yield user_handler, (params,), None
@lsp_method(types.NOTEBOOK_DOCUMENT_DID_OPEN)
def lsp_notebook_document__did_open(
self, params: DidOpenNotebookDocumentParams
) -> None:
self, params: types.DidOpenNotebookDocumentParams
):
"""Put a notebook document into the workspace"""
self.workspace.put_notebook_document(params)
@lsp_method(NOTEBOOK_DOCUMENT_DID_CHANGE)
if (
user_handler := self.fm.features.get(types.NOTEBOOK_DOCUMENT_DID_OPEN)
) is not None:
yield user_handler, (params,), None
@lsp_method(types.NOTEBOOK_DOCUMENT_DID_CHANGE)
def lsp_notebook_document__did_change(
self, params: DidChangeNotebookDocumentParams
) -> None:
self, params: types.DidChangeNotebookDocumentParams
):
"""Update a notebook's contents"""
self.workspace.update_notebook_document(params)
@lsp_method(NOTEBOOK_DOCUMENT_DID_CLOSE)
if (
user_handler := self.fm.features.get(types.NOTEBOOK_DOCUMENT_DID_CHANGE)
) is not None:
yield user_handler, (params,), None
@lsp_method(types.NOTEBOOK_DOCUMENT_DID_CLOSE)
def lsp_notebook_document__did_close(
self, params: DidCloseNotebookDocumentParams
) -> None:
self, params: types.DidCloseNotebookDocumentParams
):
"""Remove a notebook document from the workspace."""
self.workspace.remove_notebook_document(params)
@lsp_method(SET_TRACE)
def lsp_set_trace(self, params: SetTraceParams) -> None:
if (
user_handler := self.fm.features.get(types.NOTEBOOK_DOCUMENT_DID_CLOSE)
) is not None:
yield user_handler, (params,), None
@lsp_method(types.SET_TRACE)
def lsp_set_trace(self, params: types.SetTraceParams) -> Generator[Any, Any, None]:
"""Changes server trace value."""
self.trace = params.value
@lsp_method(WORKSPACE_DID_CHANGE_WORKSPACE_FOLDERS)
if (user_handler := self.fm.features.get(types.SET_TRACE)) is not None:
yield user_handler, (params,), None
@lsp_method(types.WORKSPACE_DID_CHANGE_WORKSPACE_FOLDERS)
def lsp_workspace__did_change_workspace_folders(
self, params: DidChangeWorkspaceFoldersParams
) -> None:
self, params: types.DidChangeWorkspaceFoldersParams
):
"""Adds/Removes folders from the workspace."""
logger.info("Workspace folders changed: %s", params)
@@ -312,18 +306,35 @@ class LanguageServerProtocol(JsonRPCProtocol, metaclass=LSPMeta):
if f_remove:
self.workspace.remove_folder(f_remove.uri)
@lsp_method(WORKSPACE_EXECUTE_COMMAND)
def lsp_workspace__execute_command(
self, params: ExecuteCommandParams, msg_id: str
) -> None:
"""Executes commands with passed arguments and returns a value."""
cmd_handler = self.fm.commands[params.command]
self._execute_request(msg_id, cmd_handler, params.arguments)
if (
user_handler := self.fm.features.get(
types.WORKSPACE_DID_CHANGE_WORKSPACE_FOLDERS
)
) is not None:
yield user_handler, (params,), None
@lsp_method(WINDOW_WORK_DONE_PROGRESS_CANCEL)
def lsp_work_done_progress_cancel(
self, params: WorkDoneProgressCancelParams
) -> None:
@lsp_method(types.WORKSPACE_EXECUTE_COMMAND)
def lsp_workspace__execute_command(
self, params: types.ExecuteCommandParams
) -> Generator[Any, Any, Any]:
"""Executes commands with passed arguments and returns a value."""
if (handler := self.fm.commands.get(params.command, None)) is None:
raise JsonRpcInvalidParams.of(
ValueError(f"Command name {params.command!r} is not defined")
)
try:
args, kwargs = _prepare_command_arguments(handler, params, self._converter)
except Exception as exc:
raise JsonRpcInvalidParams.of(exc)
# Call the user's command handler.
result = yield handler, args, kwargs
return result
@lsp_method(types.WINDOW_WORK_DONE_PROGRESS_CANCEL)
def lsp_work_done_progress_cancel(self, params: types.WorkDoneProgressCancelParams):
"""Received a progress cancellation from client."""
future = self.progress.tokens.get(params.token)
if future is None:
@@ -333,237 +344,85 @@ class LanguageServerProtocol(JsonRPCProtocol, metaclass=LSPMeta):
else:
future.cancel()
def get_configuration(
self,
params: WorkspaceConfigurationParams,
callback: Optional[ConfigCallbackType] = None,
) -> Future:
"""Sends configuration request to the client.
if (
user_handler := self.fm.features.get(types.WINDOW_WORK_DONE_PROGRESS_CANCEL)
) is not None:
yield user_handler, (params,), None
Args:
params(WorkspaceConfigurationParams): WorkspaceConfigurationParams from lsp specs
callback(callable): Callabe which will be called after
response from the client is received
Returns:
concurrent.futures.Future object that will be resolved once a
response has been received
"""
return self.send_request(WORKSPACE_CONFIGURATION, params, callback)
def get_configuration_async(
self, params: WorkspaceConfigurationParams
) -> asyncio.Future:
"""Calls `get_configuration` method but designed to use with coroutines
def _prepare_command_arguments(
handler: Callable[..., Any],
params: types.ExecuteCommandParams,
converter: Converter,
) -> tuple[tuple[Any, ...], dict[str, Any]]:
"""Prepare the arguments to pass to the command handler."""
Args:
params(WorkspaceConfigurationParams): WorkspaceConfigurationParams from lsp specs
Returns:
asyncio.Future that can be awaited
"""
return asyncio.wrap_future(self.get_configuration(params))
if params.arguments is None:
return tuple(), {}
def log_trace(self, message: str, verbose: Optional[str] = None) -> None:
"""Sends trace notification to the client."""
if self.trace == TraceValues.Off:
return
# Import this here to not introduce an import cycle at the module level
from pygls.lsp.server import LanguageServer
params = LogTraceParams(message=message)
if verbose and self.trace == TraceValues.Verbose:
params.verbose = verbose
param_vals = iter(params.arguments)
param_defs, annotations = _get_handler_params_annotations(handler)
self.notify(LOG_TRACE, params)
args: list[Any] = []
kwargs: dict[str, Any] = {}
def _publish_diagnostics_deprecator(
self,
params_or_uri: Union[str, PublishDiagnosticsParams],
diagnostics: Optional[List[Diagnostic]],
version: Optional[int],
**kwargs,
) -> PublishDiagnosticsParams:
if isinstance(params_or_uri, str):
message = "DEPRECATION: "
"`publish_diagnostics("
"self, doc_uri: str, diagnostics: List[Diagnostic], version: Optional[int] = None)`"
"will be replaced with `publish_diagnostics(self, params: PublishDiagnosticsParams)`"
logging.warning(message)
# param_defs is an OrderedDict so *in theory* at least we don't have to
# worry about argument order.
found_ls = False
for idx, (name, param) in enumerate(param_defs.items()):
ptype = annotations.get(name, None)
# We don't need to provide the injected server instance here.
# The @server.command decorator will have already handled it.
if idx == 0:
if name == PARAM_LS:
found_ls = True
continue
if (ptype is not None) and issubclass(ptype, LanguageServer):
found_ls = True
continue
if param.kind == inspect.Parameter.VAR_POSITIONAL: # i.e. *args
# consume the remaining values
args.extend(param_vals)
params = self._construct_publish_diagnostic_type(
params_or_uri, diagnostics, version, **kwargs
)
else:
params = params_or_uri
return params
try:
value = converter.structure(next(param_vals), ptype)
except StopIteration as exc:
raise TypeError(
f"Expected {len(param_defs) - found_ls} arguments, "
f"got {len(params.arguments)}"
) from exc
def _construct_publish_diagnostic_type(
self,
uri: str,
diagnostics: Optional[List[Diagnostic]],
version: Optional[int],
**kwargs,
) -> PublishDiagnosticsParams:
if diagnostics is None:
diagnostics = []
args.append(value)
args = {
**{"uri": uri, "diagnostics": diagnostics, "version": version},
**kwargs,
}
params = PublishDiagnosticsParams(**args) # type:ignore
return params
def publish_diagnostics(
self,
params_or_uri: Union[str, PublishDiagnosticsParams],
diagnostics: Optional[List[Diagnostic]] = None,
version: Optional[int] = None,
**kwargs,
):
"""Sends diagnostic notification to the client.
.. deprecated:: 1.0.1
Passing ``(uri, diagnostics, version)`` as arguments is deprecated.
Pass an instance of :class:`~lsprotocol.types.PublishDiagnosticParams`
instead.
Parameters
----------
params_or_uri
The :class:`~lsprotocol.types.PublishDiagnosticParams` to send to the client.
diagnostics
*Deprecated*. The diagnostics to publish
version
*Deprecated*: The version number
"""
params = self._publish_diagnostics_deprecator(
params_or_uri, diagnostics, version, **kwargs
)
self.notify(TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS, params)
def register_capability(
self, params: RegistrationParams, callback: Optional[Callable[[], None]] = None
) -> Future:
"""Register a new capability on the client.
Args:
params(RegistrationParams): RegistrationParams from lsp specs
callback(callable): Callabe which will be called after
response from the client is received
Returns:
concurrent.futures.Future object that will be resolved once a
response has been received
"""
return self.send_request(CLIENT_REGISTER_CAPABILITY, params, callback)
def register_capability_async(self, params: RegistrationParams) -> asyncio.Future:
"""Register a new capability on the client.
Args:
params(RegistrationParams): RegistrationParams from lsp specs
Returns:
asyncio.Future object that will be resolved once a
response has been received
"""
return asyncio.wrap_future(self.register_capability(params, None))
def semantic_tokens_refresh(
self, callback: Optional[Callable[[], None]] = None
) -> Future:
"""Requesting a refresh of all semantic tokens.
Args:
callback(callable): Callabe which will be called after
response from the client is received
Returns:
concurrent.futures.Future object that will be resolved once a
response has been received
"""
return self.send_request(WORKSPACE_SEMANTIC_TOKENS_REFRESH, callback=callback)
def semantic_tokens_refresh_async(self) -> asyncio.Future:
"""Requesting a refresh of all semantic tokens.
Returns:
asyncio.Future object that will be resolved once a
response has been received
"""
return asyncio.wrap_future(self.semantic_tokens_refresh(None))
def show_document(
self,
params: ShowDocumentParams,
callback: Optional[ShowDocumentCallbackType] = None,
) -> Future:
"""Display a particular document in the user interface.
Args:
params(ShowDocumentParams): ShowDocumentParams from lsp specs
callback(callable): Callabe which will be called after
response from the client is received
Returns:
concurrent.futures.Future object that will be resolved once a
response has been received
"""
return self.send_request(WINDOW_SHOW_DOCUMENT, params, callback)
def show_document_async(self, params: ShowDocumentParams) -> asyncio.Future:
"""Display a particular document in the user interface.
Args:
params(ShowDocumentParams): ShowDocumentParams from lsp specs
Returns:
asyncio.Future object that will be resolved once a
response has been received
"""
return asyncio.wrap_future(self.show_document(params, None))
def show_message(self, message, msg_type=MessageType.Info):
"""Sends message to the client to display message."""
self.notify(
WINDOW_SHOW_MESSAGE, ShowMessageParams(type=msg_type, message=message)
# did we consume all the values?
if len(list(param_vals)) > 0:
raise TypeError(
f"Expected {len(param_defs) - found_ls} arguments, "
f"got {len(params.arguments)}"
)
def show_message_log(self, message, msg_type=MessageType.Log):
"""Sends message to the client's output channel."""
self.notify(
WINDOW_LOG_MESSAGE, LogMessageParams(type=msg_type, message=message)
)
return tuple(args), kwargs
def unregister_capability(
self,
params: UnregistrationParams,
callback: Optional[Callable[[], None]] = None,
) -> Future:
"""Unregister a new capability on the client.
Args:
params(UnregistrationParams): UnregistrationParams from lsp specs
callback(callable): Callabe which will be called after
response from the client is received
Returns:
concurrent.futures.Future object that will be resolved once a
response has been received
"""
return self.send_request(CLIENT_UNREGISTER_CAPABILITY, params, callback)
def _get_handler_params_annotations(handler: Callable[..., Any]):
"""Return the parameters and corresponding type annotations for the given handler
function."""
def unregister_capability_async(
self, params: UnregistrationParams
) -> asyncio.Future:
"""Unregister a new capability on the client.
# If the user's handler requests the language server instance, the real function
# is wrapped inside whatever `functools.partial()` returns.
if hasattr(handler, "func"):
annotations = typing.get_type_hints(handler.func)
params = inspect.signature(handler.func).parameters
Args:
params(UnregistrationParams): UnregistrationParams from lsp specs
callback(callable): Callabe which will be called after
response from the client is received
Returns:
asyncio.Future object that will be resolved once a
response has been received
"""
return asyncio.wrap_future(self.unregister_capability(params, None))
else:
annotations = typing.get_type_hints(handler)
params = inspect.signature(handler).parameters
return params, annotations
-51
View File
@@ -1,51 +0,0 @@
import functools
import logging
from pygls.constants import ATTR_FEATURE_TYPE
from pygls.feature_manager import assign_help_attrs
logger = logging.getLogger(__name__)
def call_user_feature(base_func, method_name):
"""Wraps generic LSP features and calls user registered feature
immediately after it.
"""
@functools.wraps(base_func)
def decorator(self, *args, **kwargs):
ret_val = base_func(self, *args, **kwargs)
try:
user_func = self.fm.features[method_name]
self._execute_notification(user_func, *args, **kwargs)
except KeyError:
pass
except Exception:
logger.exception(
'Failed to handle user defined notification "%s": %s', method_name, args
)
return ret_val
return decorator
class LSPMeta(type):
"""Wraps LSP built-in features (`lsp_` naming convention).
Built-in features cannot be overridden but user defined features with
the same LSP name will be called after them.
"""
def __new__(mcs, cls_name, cls_bases, cls):
for attr_name, attr_val in cls.items():
if callable(attr_val) and hasattr(attr_val, "method_name"):
method_name = attr_val.method_name
wrapped = call_user_feature(attr_val, method_name)
assign_help_attrs(wrapped, method_name, ATTR_FEATURE_TYPE)
cls[attr_name] = wrapped
logger.debug('Added decorator for lsp method: "%s"', attr_name)
return super().__new__(mcs, cls_name, cls_bases, cls)
+156 -489
View File
@@ -14,213 +14,66 @@
# See the License for the specific language governing permissions and #
# limitations under the License. #
############################################################################
from __future__ import annotations
import asyncio
import json
import logging
import re
import sys
from concurrent.futures import Future, ThreadPoolExecutor
import typing
from concurrent.futures import ThreadPoolExecutor
from threading import Event
from typing import (
Any,
Callable,
List,
Optional,
TextIO,
Type,
TypeVar,
Union,
)
import cattrs
from pygls import IS_PYODIDE
from pygls.lsp import ConfigCallbackType, ShowDocumentCallbackType
from pygls.exceptions import (
FeatureNotificationError,
JsonRpcInternalError,
PyglsError,
JsonRpcException,
FeatureRequestError,
)
from lsprotocol.types import (
ClientCapabilities,
Diagnostic,
MessageType,
NotebookDocumentSyncOptions,
RegistrationParams,
ServerCapabilities,
ShowDocumentParams,
TextDocumentSyncKind,
UnregistrationParams,
WorkspaceApplyEditResponse,
WorkspaceEdit,
WorkspaceConfigurationParams,
)
from pygls.progress import Progress
from pygls.protocol import JsonRPCProtocol, LanguageServerProtocol, default_converter
from pygls.workspace import Workspace
if not IS_PYODIDE:
from multiprocessing.pool import ThreadPool
from pygls import IS_WASM
from pygls.exceptions import JsonRpcException, PyglsError
from pygls.io_ import StdinAsyncReader, StdoutWriter, run, run_async, run_websocket
from pygls.protocol import JsonRPCProtocol
if typing.TYPE_CHECKING:
from typing import Any, BinaryIO, Callable, Optional, Type, TypeVar, Union
from websockets.asyncio.server import Server as WSServer
from websockets.asyncio.server import ServerConnection
F = TypeVar("F", bound=Callable)
ServerErrors = Union[type[PyglsError], type[JsonRpcException]]
logger = logging.getLogger(__name__)
F = TypeVar("F", bound=Callable)
ServerErrors = Union[
PyglsError,
JsonRpcException,
Type[JsonRpcInternalError],
Type[FeatureNotificationError],
Type[FeatureRequestError],
]
async def aio_readline(loop, executor, stop_event, rfile, proxy):
"""Reads data from stdin in separate thread (asynchronously)."""
CONTENT_LENGTH_PATTERN = re.compile(rb"^Content-Length: (\d+)\r\n$")
# Initialize message buffer
message = []
content_length = 0
while not stop_event.is_set() and not rfile.closed:
# Read a header line
header = await loop.run_in_executor(executor, rfile.readline)
if not header:
break
message.append(header)
# Extract content length if possible
if not content_length:
match = CONTENT_LENGTH_PATTERN.fullmatch(header)
if match:
content_length = int(match.group(1))
logger.debug("Content length: %s", content_length)
# Check if all headers have been read (as indicated by an empty line \r\n)
if content_length and not header.strip():
# Read body
body = await loop.run_in_executor(executor, rfile.read, content_length)
if not body:
break
message.append(body)
# Pass message to language server protocol
proxy(b"".join(message))
# Reset the buffer
message = []
content_length = 0
class StdOutTransportAdapter:
"""Protocol adapter which overrides write method.
Write method sends data to stdout.
"""
def __init__(self, rfile, wfile):
self.rfile = rfile
self.wfile = wfile
def close(self):
self.rfile.close()
self.wfile.close()
def write(self, data):
self.wfile.write(data)
self.wfile.flush()
class PyodideTransportAdapter:
"""Protocol adapter which overrides write method.
Write method sends data to stdout.
"""
def __init__(self, wfile):
self.wfile = wfile
def close(self):
self.wfile.close()
def write(self, data):
self.wfile.write(data)
self.wfile.flush()
class WebSocketTransportAdapter:
"""Protocol adapter which calls write method.
Write method sends data via the WebSocket interface.
"""
def __init__(self, ws, loop):
self._ws = ws
self._loop = loop
def close(self) -> None:
"""Stop the WebSocket server."""
self._ws.close()
def write(self, data: Any) -> None:
"""Create a task to write specified data into a WebSocket."""
asyncio.ensure_future(self._ws.send(data))
class Server:
class JsonRPCServer:
"""Base server class
Parameters
----------
protocol_cls
Protocol implementation that must be derive from :class:`~pygls.protocol.JsonRPCProtocol`
Protocol implementation that should derive from
:class:`~pygls.protocol.JsonRPCProtocol`
converter_factory
Factory function to use when constructing a cattrs converter.
loop
The asyncio event loop
max_workers
Maximum number of workers for `ThreadPool` and `ThreadPoolExecutor`
Maximum number of workers for `ThreadPoolExecutor`
"""
protocol: JsonRPCProtocol
def __init__(
self,
protocol_cls: Type[JsonRPCProtocol],
converter_factory: Callable[[], cattrs.Converter],
loop: Optional[asyncio.AbstractEventLoop] = None,
max_workers: int = 2,
sync_kind: TextDocumentSyncKind = TextDocumentSyncKind.Incremental,
max_workers: int | None = None,
):
if not issubclass(protocol_cls, asyncio.Protocol):
raise TypeError("Protocol class should be subclass of asyncio.Protocol")
self._max_workers = max_workers
self._server = None
self._stop_event: Optional[Event] = None
self._thread_pool: Optional[ThreadPool] = None
self._thread_pool_executor: Optional[ThreadPoolExecutor] = None
self._server: asyncio.Server | WSServer | None = None
self._stop_event: Event | None = None
self._thread_pool: ThreadPoolExecutor | None = None
if sync_kind is not None:
self.text_document_sync_kind = sync_kind
if loop is None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self._owns_loop = True
else:
self._owns_loop = False
self.loop = loop
# TODO: Will move this to `LanguageServer` soon
self.lsp = protocol_cls(self, converter_factory()) # type: ignore
self.protocol = protocol_cls(self, converter_factory())
def shutdown(self):
"""Shutdown server."""
@@ -230,38 +83,55 @@ class Server:
self._stop_event.set()
if self._thread_pool:
self._thread_pool.terminate()
self._thread_pool.join()
if self._thread_pool_executor:
self._thread_pool_executor.shutdown()
self._thread_pool.shutdown()
if self._server:
self._server.close()
self.loop.run_until_complete(self._server.wait_closed())
if self._owns_loop and not self.loop.is_closed():
logger.info("Closing the event loop.")
self.loop.close()
def _report_server_error(
self,
error: Exception,
source: ServerErrors,
):
# Prevent recursive error reporting
try:
self.report_server_error(error, source)
except Exception:
logger.warning("Failed to report error")
def start_io(self, stdin: Optional[TextIO] = None, stdout: Optional[TextIO] = None):
"""Starts IO server."""
logger.info("Starting IO server")
def report_server_error(self, error: Exception, source: ServerErrors):
"""Default error reporter."""
logger.error("%s", error)
def start_io(
self, stdin: Optional[BinaryIO] = None, stdout: Optional[BinaryIO] = None
):
"""Starts an IO server."""
if IS_WASM:
self._start_io_sync(stdin, stdout)
else:
self._start_io_async(stdin, stdout)
def _start_io_async(
self, stdin: Optional[BinaryIO] = None, stdout: Optional[BinaryIO] = None
):
"""Starts an asynchronous IO server."""
logger.info("Starting async IO server")
self._stop_event = Event()
transport = StdOutTransportAdapter(
stdin or sys.stdin.buffer, stdout or sys.stdout.buffer
)
self.lsp.connection_made(transport) # type: ignore[arg-type]
reader = StdinAsyncReader(stdin or sys.stdin.buffer, self.thread_pool)
writer = StdoutWriter(stdout or sys.stdout.buffer)
self.protocol.set_writer(writer)
try:
self.loop.run_until_complete(
aio_readline(
self.loop,
self.thread_pool_executor,
self._stop_event,
stdin or sys.stdin.buffer,
self.lsp.data_received,
asyncio.run(
run_async(
stop_event=self._stop_event,
reader=reader,
protocol=self.protocol,
logger=logger,
error_handler=self.report_server_error,
)
)
except BrokenPipeError:
@@ -271,169 +141,104 @@ class Server:
finally:
self.shutdown()
def start_pyodide(self):
logger.info("Starting Pyodide server")
def _start_io_sync(
self, stdin: Optional[BinaryIO] = None, stdout: Optional[BinaryIO] = None
):
"""Starts an synchronous IO server."""
logger.info("Starting sync IO server")
# Note: We don't actually start anything running as the main event
# loop will be handled by the web platform.
transport = PyodideTransportAdapter(sys.stdout)
self.lsp.connection_made(transport) # type: ignore[arg-type]
self.lsp._send_only_body = True # Don't send headers within the payload
self._stop_event = Event()
writer = StdoutWriter(stdout or sys.stdout.buffer)
self.protocol.set_writer(writer)
try:
asyncio.run(
run(
stop_event=self._stop_event,
reader=stdin or sys.stdin.buffer,
protocol=self.protocol,
logger=logger,
error_handler=self.report_server_error,
)
)
except BrokenPipeError:
logger.error("Connection to the client is lost! Shutting down the server.")
except (KeyboardInterrupt, SystemExit):
pass
finally:
self.shutdown()
def start_tcp(self, host: str, port: int) -> None:
"""Starts TCP server."""
logger.info("Starting TCP server on %s:%s", host, port)
self._stop_event = Event()
self._server = self.loop.run_until_complete( # type: ignore[assignment]
self.loop.create_server(self.lsp, host, port)
)
try:
self.loop.run_forever()
except (KeyboardInterrupt, SystemExit):
pass
finally:
self._stop_event = stop_event = Event()
async def lsp_connection(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
):
logger.debug("Connected to client")
self.protocol.set_writer(writer) # type: ignore
await run_async(
stop_event=stop_event,
reader=reader,
protocol=self.protocol,
logger=logger,
error_handler=self.report_server_error,
)
logger.debug("Main loop finished")
self.shutdown()
async def tcp_server(h: str, p: int):
self._server = await asyncio.start_server(lsp_connection, h, p)
addrs = ", ".join(str(sock.getsockname()) for sock in self._server.sockets)
logger.info(f"Serving on {addrs}")
async with self._server:
await self._server.serve_forever()
try:
asyncio.run(tcp_server(host, port))
except asyncio.CancelledError:
logger.debug("Server was cancelled")
def start_ws(self, host: str, port: int) -> None:
"""Starts WebSocket server."""
try:
from websockets.server import serve
from websockets.asyncio.server import serve
except ImportError:
logger.error("Run `pip install pygls[ws]` to install `websockets`.")
logger.error(
"Run `pip install pygls[ws]` to install dependencies required for websockets."
)
sys.exit(1)
logger.info("Starting WebSocket server on {}:{}".format(host, port))
self._stop_event = stop_event = Event()
self._stop_event = Event()
self.lsp._send_only_body = True # Don't send headers within the payload
async def connection_made(websocket, _):
"""Handle new connection wrapped in the WebSocket."""
self.lsp.transport = WebSocketTransportAdapter(websocket, self.loop)
async for message in websocket:
self.lsp._procedure_handler(
json.loads(message, object_hook=self.lsp._deserialize_message)
)
start_server = serve(connection_made, host, port, loop=self.loop)
self._server = start_server.ws_server # type: ignore[assignment]
self.loop.run_until_complete(start_server)
try:
self.loop.run_forever()
except (KeyboardInterrupt, SystemExit):
pass
finally:
self._stop_event.set()
async def lsp_connection(websocket: ServerConnection):
await run_websocket(
stop_event=stop_event,
websocket=websocket,
protocol=self.protocol,
logger=logger,
error_handler=self.report_server_error,
)
self.shutdown()
if not IS_PYODIDE:
async def ws_server(h: str, p: int):
self._server = await serve(lsp_connection, host, port)
@property
def thread_pool(self) -> ThreadPool:
"""Returns thread pool instance (lazy initialization)."""
if not self._thread_pool:
self._thread_pool = ThreadPool(processes=self._max_workers)
addrs = ", ".join(str(sock.getsockname()) for sock in self._server.sockets)
logger.info(f"Serving on {addrs}")
return self._thread_pool
async with self._server:
await self._server.serve_forever()
@property
def thread_pool_executor(self) -> ThreadPoolExecutor:
"""Returns thread pool instance (lazy initialization)."""
if not self._thread_pool_executor:
self._thread_pool_executor = ThreadPoolExecutor(
max_workers=self._max_workers
)
return self._thread_pool_executor
class LanguageServer(Server):
"""The default LanguageServer
This class can be extended and it can be passed as a first argument to
registered commands/features.
.. |ServerInfo| replace:: :class:`~lsprotocol.types.InitializeResultServerInfoType`
Parameters
----------
name
Name of the server, used to populate |ServerInfo| which is sent to
the client during initialization
version
Version of the server, used to populate |ServerInfo| which is sent to
the client during initialization
protocol_cls
The :class:`~pygls.protocol.LanguageServerProtocol` class definition, or any
subclass of it.
max_workers
Maximum number of workers for ``ThreadPool`` and ``ThreadPoolExecutor``
text_document_sync_kind
Text document synchronization method
None
No synchronization
:attr:`~lsprotocol.types.TextDocumentSyncKind.Full`
Send entire document text with each update
:attr:`~lsprotocol.types.TextDocumentSyncKind.Incremental`
Send only the region of text that changed with each update
notebook_document_sync
Advertise :lsp:`NotebookDocument` support to the client.
"""
lsp: LanguageServerProtocol
default_error_message = (
"Unexpected error in LSP server, see server's logs for details"
)
"""
The default error message sent to the user's editor when this server encounters an uncaught
exception.
"""
def __init__(
self,
name: str,
version: str,
loop=None,
protocol_cls: Type[LanguageServerProtocol] = LanguageServerProtocol,
converter_factory=default_converter,
text_document_sync_kind: TextDocumentSyncKind = TextDocumentSyncKind.Incremental,
notebook_document_sync: Optional[NotebookDocumentSyncOptions] = None,
max_workers: int = 2,
):
if not issubclass(protocol_cls, LanguageServerProtocol):
raise TypeError(
"Protocol class should be subclass of LanguageServerProtocol"
)
self.name = name
self.version = version
self._text_document_sync_kind = text_document_sync_kind
self._notebook_document_sync = notebook_document_sync
self.process_id: Optional[Union[int, None]] = None
super().__init__(protocol_cls, converter_factory, loop, max_workers)
def apply_edit(
self, edit: WorkspaceEdit, label: Optional[str] = None
) -> WorkspaceApplyEditResponse:
"""Sends apply edit request to the client."""
return self.lsp.apply_edit(edit, label)
def apply_edit_async(
self, edit: WorkspaceEdit, label: Optional[str] = None
) -> WorkspaceApplyEditResponse:
"""Sends apply edit request to the client. Should be called with `await`"""
return self.lsp.apply_edit_async(edit, label)
try:
asyncio.run(ws_server(host, port))
except asyncio.CancelledError:
logger.debug("Server was cancelled")
def command(self, command_name: str) -> Callable[[F], F]:
"""Decorator used to register custom commands.
@@ -446,17 +251,16 @@ class LanguageServer(Server):
def my_cmd(ls, a, b, c):
pass
"""
return self.lsp.fm.command(command_name)
return self.protocol.fm.command(command_name)
@property
def client_capabilities(self) -> ClientCapabilities:
"""The client's capabilities."""
return self.lsp.client_capabilities
def thread(self) -> Callable[[F], F]:
"""Decorator that mark function to execute it in a thread."""
return self.protocol.fm.thread()
def feature(
self,
feature_name: str,
options: Optional[Any] = None,
options: Any | None = None,
) -> Callable[[F], F]:
"""Decorator used to register LSP features.
@@ -468,149 +272,12 @@ class LanguageServer(Server):
def completions(ls, params: CompletionParams):
return CompletionList(is_incomplete=False, items=[CompletionItem("Completion 1")])
"""
return self.lsp.fm.feature(feature_name, options)
def get_configuration(
self,
params: WorkspaceConfigurationParams,
callback: Optional[ConfigCallbackType] = None,
) -> Future:
"""Gets the configuration settings from the client."""
return self.lsp.get_configuration(params, callback)
def get_configuration_async(
self, params: WorkspaceConfigurationParams
) -> asyncio.Future:
"""Gets the configuration settings from the client. Should be called with `await`"""
return self.lsp.get_configuration_async(params)
def log_trace(self, message: str, verbose: Optional[str] = None) -> None:
"""Sends trace notification to the client."""
self.lsp.log_trace(message, verbose)
return self.protocol.fm.feature(feature_name, options)
@property
def progress(self) -> Progress:
"""Gets the object to manage client's progress bar."""
return self.lsp.progress
def thread_pool(self) -> ThreadPoolExecutor:
"""Returns thread pool instance (lazy initialization)."""
if not self._thread_pool:
self._thread_pool = ThreadPoolExecutor(max_workers=self._max_workers)
def publish_diagnostics(
self,
uri: str,
diagnostics: Optional[List[Diagnostic]] = None,
version: Optional[int] = None,
**kwargs
):
"""
Sends diagnostic notification to the client.
"""
params = self.lsp._construct_publish_diagnostic_type(
uri, diagnostics, version, **kwargs
)
self.lsp.publish_diagnostics(params, **kwargs)
def register_capability(
self, params: RegistrationParams, callback: Optional[Callable[[], None]] = None
) -> Future:
"""Register a new capability on the client."""
return self.lsp.register_capability(params, callback)
def register_capability_async(self, params: RegistrationParams) -> asyncio.Future:
"""Register a new capability on the client. Should be called with `await`"""
return self.lsp.register_capability_async(params)
def semantic_tokens_refresh(
self, callback: Optional[Callable[[], None]] = None
) -> Future:
"""Request a refresh of all semantic tokens."""
return self.lsp.semantic_tokens_refresh(callback)
def semantic_tokens_refresh_async(self) -> asyncio.Future:
"""Request a refresh of all semantic tokens. Should be called with `await`"""
return self.lsp.semantic_tokens_refresh_async()
def send_notification(self, method: str, params: object = None) -> None:
"""Sends notification to the client."""
self.lsp.notify(method, params)
@property
def server_capabilities(self) -> ServerCapabilities:
"""Return server capabilities."""
return self.lsp.server_capabilities
def show_document(
self,
params: ShowDocumentParams,
callback: Optional[ShowDocumentCallbackType] = None,
) -> Future:
"""Display a particular document in the user interface."""
return self.lsp.show_document(params, callback)
def show_document_async(self, params: ShowDocumentParams) -> asyncio.Future:
"""Display a particular document in the user interface. Should be called with `await`"""
return self.lsp.show_document_async(params)
def show_message(self, message, msg_type=MessageType.Info) -> None:
"""Sends message to the client to display message."""
self.lsp.show_message(message, msg_type)
def show_message_log(self, message, msg_type=MessageType.Log) -> None:
"""Sends message to the client's output channel."""
self.lsp.show_message_log(message, msg_type)
def _report_server_error(
self,
error: Exception,
source: ServerErrors,
):
# Prevent recursive error reporting
try:
self.report_server_error(error, source)
except Exception:
logger.warning("Failed to report error to client")
def report_server_error(self, error: Exception, source: ServerErrors):
"""
Sends error to the client for displaying.
By default this fucntion does not handle LSP request errors. This is because LSP requests
require direct responses and so already have a mechanism for including unexpected errors
in the response body.
All other errors are "out of band" in the sense that the client isn't explicitly waiting
for them. For example diagnostics are returned as notifications, not responses to requests,
and so can seemingly be sent at random. Also for example consider JSON RPC serialization
and deserialization, if a payload cannot be parsed then the whole request/response cycle
cannot be completed and so one of these "out of band" error messages is sent.
These "out of band" error messages are not a requirement of the LSP spec. Pygls simply
offers this behaviour as a recommended default. It is perfectly reasonble to override this
default.
"""
if source == FeatureRequestError:
return
self.show_message(self.default_error_message, msg_type=MessageType.Error)
def thread(self) -> Callable[[F], F]:
"""Decorator that mark function to execute it in a thread."""
return self.lsp.thread()
def unregister_capability(
self,
params: UnregistrationParams,
callback: Optional[Callable[[], None]] = None,
) -> Future:
"""Unregister a new capability on the client."""
return self.lsp.unregister_capability(params, callback)
def unregister_capability_async(
self, params: UnregistrationParams
) -> asyncio.Future:
"""Unregister a new capability on the client. Should be called with `await`"""
return self.lsp.unregister_capability_async(params)
@property
def workspace(self) -> Workspace:
"""Returns in-memory workspace."""
return self.lsp.workspace
return self._thread_pool
+7 -3
View File
@@ -21,6 +21,8 @@ A collection of URI utilities with logic built on the VSCode URI library.
https://github.com/Microsoft/vscode-uri/blob/e59cab84f5df6265aed18ae5f43552d3eef13bb9/lib/index.ts
"""
from __future__ import annotations
from typing import Optional, Tuple
import re
@@ -75,20 +77,22 @@ def from_fs_path(path: str):
return None
def to_fs_path(uri: str):
def to_fs_path(uri: str) -> str | None:
"""
Returns the filesystem path of the given URI.
Will handle UNC paths and normalize windows drive letters to lower-case.
Also uses the platform specific path separator. Will *not* validate the
path for invalid characters and semantics.
Will *not* look at the scheme of this URI.
"""
try:
# scheme://netloc/path;parameters?query#fragment
scheme, netloc, path, _, _, _ = urlparse(uri)
if netloc and path and scheme == "file":
if scheme != "file":
return None
if netloc and path:
# unc path: file://shares/c$/far/boo
value = f"//{netloc}{path}"
+3 -89
View File
@@ -1,97 +1,11 @@
from typing import List
import warnings
from lsprotocol import types
from .workspace import Workspace
from .text_document import TextDocument
from .position_codec import PositionCodec
# For backwards compatibility
Document = TextDocument
def utf16_unit_offset(chars: str):
warnings.warn(
"'utf16_unit_offset' has been deprecated, instead use "
"'PositionCodec.utf16_unit_offset' via 'workspace.position_codec' "
"or 'text_document.position_codec'",
DeprecationWarning,
stacklevel=2,
)
_codec = PositionCodec()
return _codec.utf16_unit_offset(chars)
def utf16_num_units(chars: str):
warnings.warn(
"'utf16_num_units' has been deprecated, instead use "
"'PositionCodec.client_num_units' via 'workspace.position_codec' "
"or 'text_document.position_codec'",
DeprecationWarning,
stacklevel=2,
)
_codec = PositionCodec()
return _codec.client_num_units(chars)
def position_from_utf16(lines: List[str], position: types.Position):
warnings.warn(
"'position_from_utf16' has been deprecated, instead use "
"'PositionCodec.position_from_client_units' via "
"'workspace.position_codec' or 'text_document.position_codec'",
DeprecationWarning,
stacklevel=2,
)
_codec = PositionCodec()
return _codec.position_from_client_units(lines, position)
def position_to_utf16(lines: List[str], position: types.Position):
warnings.warn(
"'position_to_utf16' has been deprecated, instead use "
"'PositionCodec.position_to_client_units' via "
"'workspace.position_codec' or 'text_document.position_codec'",
DeprecationWarning,
stacklevel=2,
)
_codec = PositionCodec()
return _codec.position_to_client_units(lines, position)
def range_from_utf16(lines: List[str], range: types.Range):
warnings.warn(
"'range_from_utf16' has been deprecated, instead use "
"'PositionCodec.range_from_client_units' via "
"'workspace.position_codec' or 'text_document.position_codec'",
DeprecationWarning,
stacklevel=2,
)
_codec = PositionCodec()
return _codec.range_from_client_units(lines, range)
def range_to_utf16(lines: List[str], range: types.Range):
warnings.warn(
"'range_to_utf16' has been deprecated, instead use "
"'PositionCodec.range_to_client_units' via 'workspace.position_codec' "
"or 'text_document.position_codec'",
DeprecationWarning,
stacklevel=2,
)
_codec = PositionCodec()
return _codec.range_to_client_units(lines, range)
from .position_codec import PositionCodec, ServerTextPosition, ServerTextRange
__all__ = (
"Workspace",
"TextDocument",
"PositionCodec",
"Document",
"utf16_unit_offset",
"utf16_num_units",
"position_from_utf16",
"position_to_utf16",
"range_from_utf16",
"range_to_utf16",
"ServerTextPosition",
"ServerTextRange",
)
+134 -77
View File
@@ -17,14 +17,113 @@
# limitations under the License. #
############################################################################
import logging
from typing import List, Optional, Union
from dataclasses import dataclass
from typing import Optional, Union, Sequence, Any
from lsprotocol import types
log = logging.getLogger(__name__)
@dataclass(order=True)
class ServerTextPosition:
line: int
character: int
def __repr__(self) -> str:
return f"{self.line}:{self.character}"
@dataclass
class ServerTextRange:
start: ServerTextPosition
end: ServerTextPosition
def __repr__(self):
return f"{self.start}-{self.end}"
def __contains__(self, position: Any) -> bool:
if not isinstance(position, ServerTextPosition):
raise TypeError("ServerTextRanges can only contain ServerTextPositions.")
return self.start <= position <= self.end
def includes(self, inner: "ServerTextRange") -> bool:
"""
Returns whether `inner` is entirely contained within self, i.e. all
positions in `inner` are also in `self`.
"""
return self.start <= inner.start and inner.end <= self.end
def overlaps(self, other: "ServerTextRange") -> bool:
"""
Returns whether `self` and `other` overlap, i.e. any positions exist that
are included in both self and other.
"""
return self.start <= other.end and other.start <= self.end
class UnitCounter:
def code_units_for_char(self, char: str) -> int:
"""
Get the number of code units used to encode the given single character.
"""
raise NotImplementedError
def num_units(self, chars: str) -> int:
"""
Get the number of code units used to encode the given string.
"""
return sum(self.code_units_for_char(c) for c in chars)
def column_from_utf32(self, line: str, column: int) -> int:
"""
Convert the codepoint index `column` into code units.
"""
return sum(self.code_units_for_char(c) for c in line[:column])
class Utf32(UnitCounter):
def code_units_for_char(self, char: str) -> int:
return 1
def num_units(self, chars: str) -> int:
# We can avoid the loop needed for other encodings here
return len(chars)
def column_from_utf32(self, line: str, column: int) -> int:
return column
def is_beyond_basic_multilingual_plane(char: str) -> bool:
return ord(char) > 0xFFFF
class Utf16(UnitCounter):
def code_units_for_char(self, char: str) -> int:
if is_beyond_basic_multilingual_plane(char):
return 2
return 1
class Utf8(UnitCounter):
def code_units_for_char(self, char: str) -> int:
codepoint = ord(char)
if codepoint < 0x80:
return 1
if codepoint < 0x800:
return 2
if codepoint < 0x10000:
return 3
return 4
impls: dict["str | types.PositionEncodingKind | None", UnitCounter] = {
types.PositionEncodingKind.Utf8: Utf8(),
types.PositionEncodingKind.Utf16: Utf16(),
types.PositionEncodingKind.Utf32: Utf32(),
}
class PositionCodec:
def __init__(
self,
@@ -33,39 +132,17 @@ class PositionCodec:
] = types.PositionEncodingKind.Utf16,
):
self.encoding = encoding
self.impl = impls.get(encoding, Utf16())
@classmethod
def is_char_beyond_multilingual_plane(cls, char: str) -> bool:
return ord(char) > 0xFFFF
def __repr__(self):
return f"<{self.__class__.__name__}, encoding {self.encoding}>"
def utf16_unit_offset(self, chars: str):
"""
Calculate the number of characters which need two utf-16 code units.
Arguments:
chars (str): The string to count occurrences of utf-16 code units for.
"""
return sum(self.is_char_beyond_multilingual_plane(ch) for ch in chars)
def client_num_units(self, chars: str):
"""
Calculate the length of `str` in client-supported UTF-[32|16|8] code units.
Arguments:
chars (str): The string to return the length in UTF-[32|16|8] code units for.
"""
utf32_units = len(chars)
if self.encoding == types.PositionEncodingKind.Utf32:
return utf32_units
if self.encoding == types.PositionEncodingKind.Utf8:
return utf32_units + (self.utf16_unit_offset(chars) * 2)
return utf32_units + self.utf16_unit_offset(chars)
def client_num_units(self, string: str):
return self.impl.num_units(string)
def position_from_client_units(
self, lines: List[str], position: types.Position
) -> types.Position:
self, lines: Sequence[str], position: types.Position
) -> ServerTextPosition:
"""
Convert the position.character from UTF-[32|16|8] code units to UTF-32.
@@ -84,7 +161,7 @@ class PositionCodec:
see: https://github.com/microsoft/language-server-protocol/issues/376
Arguments:
lines (list):
lines (sequence):
The content of the document which the position refers to.
position (Position):
The line and character offset in UTF-[32|16|8] code units.
@@ -93,59 +170,42 @@ class PositionCodec:
The position with `character` being converted to UTF-32 code units.
"""
if len(lines) == 0:
return types.Position(0, 0)
return ServerTextPosition(0, 0)
if position.line >= len(lines):
return types.Position(len(lines) - 1, self.client_num_units(lines[-1]))
return ServerTextPosition(len(lines) - 1, self.impl.num_units(lines[-1]))
_line = lines[position.line]
_line = _line.replace("\r\n", "\n") # TODO: it's a bit of a hack
_client_len = self.client_num_units(_line)
_utf32_len = len(_line)
_client_len = self.impl.num_units(_line)
if _client_len == 0:
return types.Position(position.line, 0)
return ServerTextPosition(position.line, 0)
_client_end_of_line = self.client_num_units(_line)
if position.character > _client_end_of_line:
position.character = _client_end_of_line - 1
if position.character > _client_len:
position.character = _client_len - 1
_client_index = 0
client_position = 0
utf32_index = 0
while True:
_is_searching_queried_position = _client_index < position.character
_is_before_end_of_line = utf32_index < _utf32_len
_is_searching_for_position = (
_is_searching_queried_position and _is_before_end_of_line
)
if not _is_searching_for_position:
for c in _line:
if client_position >= position.character:
break
_current_char = _line[utf32_index]
_is_double_width = PositionCodec.is_char_beyond_multilingual_plane(
_current_char
)
if _is_double_width:
if self.encoding == types.PositionEncodingKind.Utf32:
_client_index += 1
if self.encoding == types.PositionEncodingKind.Utf8:
_client_index += 4
_client_index += 2
else:
_client_index += 1
client_position += self.impl.code_units_for_char(c)
utf32_index += 1
position = types.Position(line=position.line, character=utf32_index)
return position
if client_position < position.character:
utf32_index = len(_line)
return ServerTextPosition(line=position.line, character=utf32_index)
def position_to_client_units(
self, lines: List[str], position: types.Position
self, lines: Sequence[str], position: "ServerTextPosition | types.Position"
) -> types.Position:
"""
Convert the position.character from its internal UTF-32 representation
to client-supported UTF-[32|16|8] code units.
Arguments:
lines (list):
lines (sequence):
The content of the document which the position refers to.
position (Position):
The line and character offset in UTF-32 code units.
@@ -154,9 +214,7 @@ class PositionCodec:
The position with `character` being converted to UTF-[32|16|8] code units.
"""
try:
character = self.client_num_units(
lines[position.line][: position.character]
)
character = self.impl.num_units(lines[position.line][: position.character])
return types.Position(
line=position.line,
character=character,
@@ -165,13 +223,13 @@ class PositionCodec:
return types.Position(line=len(lines), character=0)
def range_from_client_units(
self, lines: List[str], range: types.Range
) -> types.Range:
self, lines: Sequence[str], range: types.Range
) -> ServerTextRange:
"""
Convert range.[start|end].character from UTF-[32|16|8] code units to UTF-32.
Arguments:
lines (list):
lines (sequence):
The content of the document which the range refers to.
range (Range):
The line and character offset in UTF-[32|16|8] code units.
@@ -179,26 +237,25 @@ class PositionCodec:
Returns:
The range with `character` offsets being converted to UTF-32 code units.
"""
range_new = types.Range(
return ServerTextRange(
start=self.position_from_client_units(lines, range.start),
end=self.position_from_client_units(lines, range.end),
)
return range_new
def range_to_client_units(
self, lines: List[str], range: types.Range
self, lines: Sequence[str], range: "ServerTextRange | types.Range"
) -> types.Range:
"""
Convert range.[start|end].character from UTF-32 to UTF-[32|16|8] code units.
Arguments:
lines (list):
lines (sequence):
The content of the document which the range refers to.
range (Range):
The line and character offset in code units.
The line and character offset in code points.
Returns:
The range with `character` offsets being converted to UTF-[32|16|8] code units.
The range with `character` offsets converted to UTF-[32|16|8] code units.
"""
return types.Range(
start=self.position_to_client_units(lines, range.start),
+96 -19
View File
@@ -19,13 +19,14 @@
import io
import logging
import os
import pathlib
import re
from typing import List, Optional, Pattern
from typing import Optional, Pattern, Sequence
from lsprotocol import types
from pygls.uris import to_fs_path
from .position_codec import PositionCodec
from pygls.uris import urlparse, to_fs_path
from .position_codec import PositionCodec, ServerTextPosition, ServerTextRange
# TODO: this is not the best e.g. we capture numbers
RE_END_WORD = re.compile("^[A-Za-z_0-9]*")
@@ -47,9 +48,10 @@ class TextDocument(object):
):
self.uri = uri
self.version = version
path = to_fs_path(uri)
if path is None:
raise Exception("`path` cannot be None")
if (path := to_fs_path(uri)) is None:
_, _, path, *_ = urlparse(uri)
self.path = path
self.language_id = language_id
self.filename: Optional[str] = os.path.basename(self.path)
@@ -73,7 +75,7 @@ class TextDocument(object):
return self._position_codec
def _apply_incremental_change(
self, change: types.TextDocumentContentChangeEvent_Type1
self, change: types.TextDocumentContentChangePartial
) -> None:
"""Apply an ``Incremental`` text change to the document"""
lines = self.lines
@@ -142,7 +144,7 @@ class TextDocument(object):
content update client requests in the pygls Python library.
"""
if isinstance(change, types.TextDocumentContentChangeEvent_Type1):
if isinstance(change, types.TextDocumentContentChangePartial):
if self._is_sync_kind_incremental:
self._apply_incremental_change(change)
return
@@ -161,26 +163,101 @@ class TextDocument(object):
self._apply_full_change(change)
@property
def lines(self) -> List[str]:
return self.source.splitlines(True)
def lines(self) -> Sequence[str]:
return tuple(self.source.splitlines(True))
def offset_at_server_position(self, server_position: ServerTextPosition) -> int:
"""
Convert server_position to an index into self.source.
The index is the number of code points preceding the client_position in self.source.
"""
row, col = server_position.line, server_position.character
return col + sum(len(line) for line in self.lines[:row])
def offset_at_position(self, client_position: types.Position) -> int:
"""Return the character offset pointed at by the given client_position."""
"""
Convert client_position to an index into self.source.
The index is the number of code points preceding the client_position in self.source.
Example in a code action request handler:
selected_string = document.source[
document.offset_at_position(params.range.start) : document.offset_at_position(params.range.end)
]
"""
lines = self.lines
server_position = self._position_codec.position_from_client_units(
lines, client_position
)
row, col = server_position.line, server_position.character
return col + sum(
self._position_codec.client_num_units(line) for line in lines[:row]
)
return self.offset_at_server_position(server_position)
def server_position_at_offset(self, offset: int) -> ServerTextPosition:
"""
Convert a numeric character offset (index into self.source) into a line-column position.
"""
remaining_offset = offset
for lineno, line in enumerate(self.lines):
if remaining_offset < len(line):
return ServerTextPosition(lineno, remaining_offset)
remaining_offset -= len(line)
# The desired position is beyond the end of the last line.
return ServerTextPosition(lineno + 1, 0)
def client_position_at_offset(self, offset: int) -> types.Position:
"""
Convert a numeric character offset (index into self.source) into a line-column position in client units.
"""
return self.position_to_client_units(self.server_position_at_offset(offset))
def range_from_client_units(self, range: types.Range) -> ServerTextRange:
"""
Convert a range from client units into code points, suitable for indexing into `self.lines`.
"""
return self.position_codec.range_from_client_units(self.lines, range)
def position_from_client_units(
self, position: types.Position
) -> ServerTextPosition:
"""
Convert a position from client units into code points, suitable for indexing into `self.lines`.
"""
return self.position_codec.position_from_client_units(self.lines, position)
def range_to_client_units(self, range: ServerTextRange) -> types.Range:
"""
Convert a range from code points into client units, suitable for sending to the client.
"""
return self.position_codec.range_to_client_units(self.lines, range)
def position_to_client_units(self, position: ServerTextPosition) -> types.Position:
"""
Convert a position from code points into client units, suitable for sending to the client.
"""
return self.position_codec.position_to_client_units(self.lines, position)
def text_in_client_range(self, range: types.Range) -> str:
"""
Given a range in client units, return the text in this range in this document.
"""
return self.text_in_server_range(self.range_from_client_units(range))
def text_in_server_range(self, range: ServerTextRange) -> str:
"""
Given a range in server units, return the text in this range in this document.
"""
return self.source[
self.offset_at_server_position(
range.start
) : self.offset_at_server_position(range.end)
]
@property
def source(self) -> str:
if self._source is None:
with io.open(self.path, "r", encoding="utf-8") as f:
return f.read()
return self._source
if self._source is None and self.path is not None:
return pathlib.Path(self.path).read_text(encoding="utf-8")
return self._source or ""
def word_at_position(
self,
+29 -71
View File
@@ -19,8 +19,8 @@
import copy
import logging
import os
import warnings
from typing import Dict, List, Optional, Union
from typing import Dict, Optional, Sequence, Union
from urllib.parse import unquote
from lsprotocol import types
from lsprotocol.types import (
@@ -40,7 +40,7 @@ class Workspace(object):
self,
root_uri: Optional[str],
sync_kind: TextDocumentSyncKind = TextDocumentSyncKind.Incremental,
workspace_folders: Optional[List[WorkspaceFolder]] = None,
workspace_folders: Optional[Sequence[WorkspaceFolder]] = None,
position_encoding: Optional[
Union[PositionEncodingKind, str]
] = PositionEncodingKind.Utf16,
@@ -48,10 +48,7 @@ class Workspace(object):
self._root_uri = root_uri
if self._root_uri is not None:
self._root_uri_scheme = uri_scheme(self._root_uri)
root_path = to_fs_path(self._root_uri)
if root_path is None:
raise Exception("Couldn't get `root_path` from `root_uri`")
self._root_path = root_path
self._root_path = to_fs_path(self._root_uri)
else:
self._root_path = None
self._sync_kind = sync_kind
@@ -94,17 +91,7 @@ class Workspace(object):
)
def add_folder(self, folder: WorkspaceFolder):
self._folders[folder.uri] = folder
@property
def documents(self):
warnings.warn(
"'workspace.documents' has been deprecated, use "
"'workspace.text_documents' instead",
DeprecationWarning,
stacklevel=2,
)
return self.text_documents
self._folders[unquote(folder.uri)] = folder
@property
def notebook_documents(self):
@@ -141,10 +128,10 @@ class Workspace(object):
The requested notebook document if found, ``None`` otherwise.
"""
if notebook_uri is not None:
return self._notebook_documents.get(notebook_uri)
return self._notebook_documents.get(unquote(notebook_uri))
if cell_uri is not None:
notebook_uri = self._cell_in_notebook.get(cell_uri)
notebook_uri = self._cell_in_notebook.get(unquote(cell_uri))
if notebook_uri is None:
return None
@@ -159,18 +146,25 @@ class Workspace(object):
See https://github.com/Microsoft/language-server-protocol/issues/177
"""
return self._text_documents.get(doc_uri) or self._create_text_document(doc_uri)
return self._text_documents.get(unquote(doc_uri)) or self._create_text_document(
doc_uri
)
def is_local(self):
return (
self._root_uri_scheme == "" or self._root_uri_scheme == "file"
) and os.path.exists(self._root_path)
if self._root_uri_scheme not in {"", "file"}:
return False
if (path := self._root_path) is None:
return False
return os.path.exists(path)
def put_notebook_document(self, params: types.DidOpenNotebookDocumentParams):
notebook = params.notebook_document
# Create a fresh instance to ensure our copy cannot be accidentally modified.
self._notebook_documents[notebook.uri] = copy.deepcopy(notebook)
self._notebook_documents[unquote(notebook.uri)] = copy.deepcopy(notebook)
for cell_document in params.cell_text_documents:
self.put_text_document(cell_document, notebook_uri=notebook.uri)
@@ -193,7 +187,7 @@ class Workspace(object):
"""
doc_uri = text_document.uri
self._text_documents[doc_uri] = self._create_text_document(
self._text_documents[unquote(doc_uri)] = self._create_text_document(
doc_uri,
source=text_document.text,
version=text_document.version,
@@ -201,23 +195,23 @@ class Workspace(object):
)
if notebook_uri:
self._cell_in_notebook[doc_uri] = notebook_uri
self._cell_in_notebook[unquote(doc_uri)] = unquote(notebook_uri)
def remove_notebook_document(self, params: types.DidCloseNotebookDocumentParams):
notebook_uri = params.notebook_document.uri
self._notebook_documents.pop(notebook_uri, None)
self._notebook_documents.pop(unquote(notebook_uri), None)
for cell_document in params.cell_text_documents:
self.remove_text_document(cell_document.uri)
def remove_text_document(self, doc_uri: str):
self._text_documents.pop(doc_uri, None)
self._cell_in_notebook.pop(doc_uri, None)
self._text_documents.pop(unquote(doc_uri), None)
self._cell_in_notebook.pop(unquote(doc_uri), None)
def remove_folder(self, folder_uri: str):
self._folders.pop(folder_uri, None)
self._folders.pop(unquote(folder_uri), None)
try:
del self._folders[folder_uri]
del self._folders[unquote(folder_uri)]
except KeyError:
pass
@@ -231,7 +225,7 @@ class Workspace(object):
def update_notebook_document(self, params: types.DidChangeNotebookDocumentParams):
uri = params.notebook_document.uri
notebook = self._notebook_documents[uri]
notebook = self._notebook_documents[unquote(uri)]
notebook.version = params.notebook_document.version
if params.change.metadata:
@@ -283,41 +277,5 @@ class Workspace(object):
change: types.TextDocumentContentChangeEvent,
):
doc_uri = text_doc.uri
self._text_documents[doc_uri].apply_change(change)
self._text_documents[doc_uri].version = text_doc.version
def get_document(self, *args, **kwargs):
warnings.warn(
"'workspace.get_document' has been deprecated, use "
"'workspace.get_text_document' instead",
DeprecationWarning,
stacklevel=2,
)
return self.get_text_document(*args, **kwargs)
def remove_document(self, *args, **kwargs):
warnings.warn(
"'workspace.remove_document' has been deprecated, use "
"'workspace.remove_text_document' instead",
DeprecationWarning,
stacklevel=2,
)
return self.remove_text_document(*args, **kwargs)
def put_document(self, *args, **kwargs):
warnings.warn(
"'workspace.put_document' has been deprecated, use "
"'workspace.put_text_document' instead",
DeprecationWarning,
stacklevel=2,
)
return self.put_text_document(*args, **kwargs)
def update_document(self, *args, **kwargs):
warnings.warn(
"'workspace.update_document' has been deprecated, use "
"'workspace.update_text_document' instead",
DeprecationWarning,
stacklevel=2,
)
return self.update_text_document(*args, **kwargs)
self._text_documents[unquote(doc_uri)].apply_change(change)
self._text_documents[unquote(doc_uri)].version = text_doc.version
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: tclint
Version: 0.8.0
Version: 0.9.0
Summary: A CLI utility for linting and analyzing Tcl code.
Author-email: Noah Moroze <me@noahmoroze.com>
License: MIT License
@@ -1,17 +1,17 @@
bin/tclfmt.exe,sha256=5o9LOeyU_bfji3TOEQ2sDsjitSg-ZAQISI2LSX9MrCg,47104
bin/tclint.exe,sha256=0rGm-shW-XmBQ_KQkCJvGa9Qe1GCHvErXuL6C1D_kjc,47104
bin/tclsp.exe,sha256=K9fidz6r40fs43dAcfHVRE8RkH7W0dtQxX42EpMBp6I,47104
tclint-0.8.0.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
tclint-0.8.0.dist-info/METADATA,sha256=QiY1JEN7FiNRwRYDcqb2OqYucGyVl5ddIZIm1vC3WQ0,4015
tclint-0.8.0.dist-info/RECORD,,
tclint-0.8.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
tclint-0.8.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
tclint-0.8.0.dist-info/entry_points.txt,sha256=K2vcacREnbnOEZvE7-QfzsmF9vazrD7HgM2WmTl_Wgs,161
tclint-0.8.0.dist-info/licenses/LICENSE,sha256=PGii0wulXro34f25070gTG-JRGM-TAyXyKVObnNJU68,1055
tclint-0.8.0.dist-info/top_level.txt,sha256=_cnnEELsoakzUgD9HHdivUoNbpKG9tUGznWvbGLmHQM,7
tclint-0.9.0.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
tclint-0.9.0.dist-info/METADATA,sha256=apRNFWLq_33SxthxLCUFZfaGZjz2AGucxFQYS7782u4,4015
tclint-0.9.0.dist-info/RECORD,,
tclint-0.9.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
tclint-0.9.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
tclint-0.9.0.dist-info/entry_points.txt,sha256=K2vcacREnbnOEZvE7-QfzsmF9vazrD7HgM2WmTl_Wgs,161
tclint-0.9.0.dist-info/licenses/LICENSE,sha256=PGii0wulXro34f25070gTG-JRGM-TAyXyKVObnNJU68,1055
tclint-0.9.0.dist-info/top_level.txt,sha256=_cnnEELsoakzUgD9HHdivUoNbpKG9tUGznWvbGLmHQM,7
tclint/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
tclint/__main__.py,sha256=Bp_AE4xH3XNsUq3EHISWdLOFUbdvwnZ6YSf3F1NIqWU,65
tclint/_version.py,sha256=Rttl-BDadtcW1QzGnNffCWA_Wc9mUKDMOBPZp--Mnsc,704
tclint/_version.py,sha256=kSrltAJmP76cnQArqSQbTZHSYAIDCyFeWzh2SIC99Q4,520
tclint/checks.py,sha256=wr29RqWd8zuF9gy5YBWjkZs1WQ91pj9ipkfdsxVuDvg,7863
tclint/cli/resolver.py,sha256=kDvSvbQiqk_Dhd7zBkiUrSDkplr02uhdZ6k3CFj6geg,3750
tclint/cli/tclfmt.py,sha256=8_d7TGmV8gU6VfRhxjFxmlTDt1zBmKtk3016CvdnpXo,6949
@@ -19,16 +19,16 @@ tclint/cli/tclint.py,sha256=CTmchC3iL-dEM0paeRVEKJf79cEnUh7_QAQ7c17UAd0,4548
tclint/cli/tclsp.py,sha256=TvBA0K2_6CkRKgHXiTSFIDcNykOhH-qHOox7qQABMaY,18051
tclint/cli/utils.py,sha256=qa1tJ3St0uC9f4yAKCC4atglu8R3fZYVxu-KZ0dqTAo,1926
tclint/commands/__init__.py,sha256=u0zk3di692M1QSZGvOEM7ZYzKBB7r950ytAS8cxmEo0,113
tclint/commands/builtin.py,sha256=XO0z4lX6rbEIpjZIEJ5-nGc0mX8a8VZxpAmOTCNIGSA,41587
tclint/commands/checks.py,sha256=7SkderCq_M36XbOJbNw5ZDKt1XXlmT4ErAG4K8eY4-k,14133
tclint/commands/builtin.py,sha256=WFgDdZzgTogALwbLAHgqt2fGOY1rX5Ukyzc4yCvvbQ4,41938
tclint/commands/checks.py,sha256=TOgJ78ngCgkvOCBhHnbsz4Q67R9hWeQAdaNNNJ8L5KA,16757
tclint/commands/plugins.py,sha256=Q-FqS3h-D6m0lWzdMH5WFSEF19Ol2vTgPySb2ydCg5Q,5407
tclint/commands/schema.py,sha256=nzgPuyQviWZYn1FZYzKs1kGixvIu0RF2GUoXzd3iaLg,1123
tclint/commands/schema.py,sha256=_YEtPQfyV4UEO_CRPSYR6zGyB1wgJdfB32DmVNWCq9o,1150
tclint/comments.py,sha256=kCfSdnoSVofvNqs4zxnOrg45shlbVvu-e2hKia6EXhk,3047
tclint/config.py,sha256=d4QUS9XwZuBNGv9ggz0QpD7jbAXmWVBrenKzGjexx-E,15285
tclint/format.py,sha256=YXX6HRy_AOhcBcJL3Ofo_lAdHtjPAGd0piiVTaGKZzg,21753
tclint/format.py,sha256=5OgSEki7w7_w1HQ9rCiksTKDX2Ni41ZXTpA7hQ42k9M,23717
tclint/lexer.py,sha256=JnDcYCeT8wCGV1kqjXRVjQDUBw48lWBx_5g_4dt-JLc,6069
tclint/parser.py,sha256=sl7bkhKN253VpBALin8Bd5FtNxRfi5cEy7NFYtvzPpo,28822
tclint/plugins/expect.py,sha256=5fbrwJK3108w20Ib7pvfJhgVNpSW3FTAovlhbspDsIQ,1912
tclint/symbol_table.py,sha256=vSbE5Cbgb-_ppdjsXOR4WBNqPQXxhf0k-pZMOFe5vU8,1557
tclint/syntax_tree.py,sha256=kkMtbxUkD1EcLT2bXsV5foDF2W6zAYoxzu-dPZGnBbs,13033
tclint/symbol_table.py,sha256=jqZli_len0f1nlSi7GF-u45FfK-GvBgXbluBYXCrzLY,1612
tclint/syntax_tree.py,sha256=msFx3ihey1EtCzaYtZS3ge0cYOECITuHBhyKoUQp1js,13020
tclint/violations.py,sha256=q9y0j1btlMLhy1Pn6HP0u1347d7SF4t3ZVZpIxH3MVc,1232
@@ -1,5 +1,5 @@
Wheel-Version: 1.0
Generator: setuptools (82.0.1)
Generator: setuptools (83.0.0)
Root-Is-Purelib: true
Tag: py3-none-any
+8 -18
View File
@@ -1,5 +1,6 @@
# file generated by setuptools-scm
# file generated by vcs-versioning
# don't change, don't track in version control
from __future__ import annotations
__all__ = [
"__version__",
@@ -10,25 +11,14 @@ __all__ = [
"commit_id",
]
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import Tuple
from typing import Union
VERSION_TUPLE = Tuple[Union[int, str], ...]
COMMIT_ID = Union[str, None]
else:
VERSION_TUPLE = object
COMMIT_ID = object
version: str
__version__: str
__version_tuple__: VERSION_TUPLE
version_tuple: VERSION_TUPLE
commit_id: COMMIT_ID
__commit_id__: COMMIT_ID
__version_tuple__: tuple[int | str, ...]
version_tuple: tuple[int | str, ...]
commit_id: str | None
__commit_id__: str | None
__version__ = version = '0.8.0'
__version_tuple__ = version_tuple = (0, 8, 0)
__version__ = version = '0.9.0'
__version_tuple__ = version_tuple = (0, 9, 0)
__commit_id__ = commit_id = None
+10 -1
View File
@@ -573,7 +573,16 @@ def _package_ifneeded(args, parser):
def _proc(args, parser):
if len(args) != 3:
required_args = ["name", "args", "body"]
if len(args) < len(required_args):
missing_args = ", ".join(required_args[len(args) :])
raise CommandArgError(
"missing required"
f" argument{'s' if len(args) < len(required_args) - 1 else ''} for proc:"
f" {missing_args}"
)
if len(args) > len(required_args):
raise CommandArgError(f"wrong # of args to proc: got {len(args)}, expected 3")
# Parse args as list, then iterate over each item to parse arg specifier lists and
+102 -9
View File
@@ -2,7 +2,8 @@
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Iterable
from difflib import get_close_matches
from typing import TYPE_CHECKING, Optional
from tclint.syntax_tree import ArgExpansion, BareWord, BracedWord, Node, QuotedWord
@@ -18,6 +19,33 @@ class CommandArgError(Exception):
pass
def get_suggestion(value: str | None, candidates: Iterable[str]) -> Optional[str]:
if value is None:
return None
unique_candidates = sorted({candidate for candidate in candidates if candidate})
if value in unique_candidates:
unique_candidates.remove(value)
if not unique_candidates:
return None
cutoff = 0.85 if len(value) <= 3 else 0.75
matches = get_close_matches(value, unique_candidates, n=1, cutoff=cutoff)
if not matches:
return None
return matches[0]
def _did_you_mean_suffix(value: str | None, candidates: Iterable[str]) -> str:
suggestion = get_suggestion(value, candidates)
if suggestion is None:
return ""
return f"; did you mean {suggestion}?"
def arg_count(args: list[Node], parser: Parser) -> tuple[int, bool]:
"""Returns the number of arguments in args, taking {*} into account.
@@ -83,6 +111,9 @@ def check_count(command, min=None, max=None):
def eval(args: list[Node], parser: Parser, command: str) -> list[Node]:
if len(args) == 1:
return [parser.parse_script(args[0])]
if len(args) > 1 and any(isinstance(arg, (QuotedWord, BracedWord)) for arg in args):
# Slightly odd restriction, but our syntax tree doesn't have a great way
# to handle this case. We require each command argument to correspond to
@@ -174,10 +205,19 @@ def check_arg_spec(
mapping = map_positionals(positionals, arg_spec["positionals"], command)
args = list(args)
for arg_i, map_to_spec in zip(positional_args, mapping):
arg = args[arg_i]
if _positional_has_type("script", arg_spec, map_to_spec):
args[arg_i] = parser.parse_script(args[arg_i])
args[arg_i] = parser.parse_script(arg)
elif _positional_has_type("expression", arg_spec, map_to_spec):
args[arg_i] = parser.parse_expression(args[arg_i])
args[arg_i] = parser.parse_expression(arg)
elif len(map_to_spec) == 1:
positional_spec = arg_spec["positionals"][map_to_spec[0]]
_validate_value(
arg,
positional_spec["value"],
f"{command} {positional_spec['name']}",
)
return args
@@ -201,12 +241,18 @@ def dispatch_subcommands(
if "" in spec:
return check_command(command, args, parser, spec[""])
valid_subcommands = [name for name in spec.keys() if name != ""]
if subcommand is not None:
msg = f"invalid subcommand for {command}: got {subcommand}"
suggestion = _did_you_mean_suffix(subcommand, valid_subcommands)
else:
msg = f"no subcommand provided for {command}"
suggestion = ""
raise CommandArgError(f"{msg}, expected one of {', '.join(spec.keys())}")
raise CommandArgError(
f"{msg}, expected one of {', '.join(valid_subcommands)}{suggestion}"
)
def map_switches(
@@ -250,17 +296,23 @@ def map_switches(
continue
if contents in switches:
if contents in mapped and not switches[contents]["repeated"]:
switch_spec = switches[contents]
if contents in mapped and not switch_spec["repeated"]:
raise CommandArgError(
f"duplicate argument for {command_name}: {contents}"
)
if switches[contents]["value"]:
if switch_spec["value"]:
arg_i += 1
if arg_i > len(args):
expected = _switch_value_description(switch_spec)
raise CommandArgError(
f"invalid arguments for {command_name}: expected value after"
f" {contents}"
f"invalid arguments for {command_name}: expected"
f" {expected} after {contents}"
)
_validate_value(
args[arg_i - 1], switch_spec["value"], f"{command_name} {contents}"
)
mapped.add(contents)
continue
@@ -281,11 +333,52 @@ def map_switches(
f" {', '.join(prefix_matches)}"
)
raise CommandArgError(f"unrecognized argument for {command_name}: {contents}")
raise CommandArgError(
f"unrecognized argument for {command_name}: {contents}"
f"{_did_you_mean_suffix(contents, switches.keys())}"
)
return mapped, positional_args
def _switch_value_description(switch_spec: dict) -> str:
metavar = switch_spec.get("metavar")
if metavar is not None:
return metavar
value_spec = switch_spec.get("value")
if value_spec is None:
return "value"
value_type = value_spec.get("type")
if value_type == "int":
return "int value"
return "value"
def _validate_value(arg: Node, value_spec: dict | None, context: str) -> None:
if value_spec is None:
return
contents = arg.contents
if contents is None:
return
value_type = value_spec.get("type")
if value_type in {"any", "variadic", "script", "expression"}:
return
if value_type == "int":
try:
int(contents, 0)
return
except ValueError as error:
raise CommandArgError(
f"invalid value for {context}: got {contents}, expected {value_type}"
) from error
def map_positionals(
args: list[Node], spec: list[dict], command_name: str
) -> list[list[int]]:
+11 -7
View File
@@ -2,6 +2,15 @@ from collections.abc import Callable
from voluptuous import Optional, Or, Schema, Self
_switch_value = Or({"type": "any"}, {"type": "int"}, None)
_positional_value = Or(
{"type": "any"},
{"type": "int"},
{"type": "variadic"},
{"type": "script"},
{"type": "expression"},
)
# Need to define this as a Schema with required=True to ensure that this requirement
# persists through the Or in the main schema definition.
_command_args = Schema(
@@ -10,19 +19,14 @@ _command_args = Schema(
{
"name": str,
"required": bool,
"value": Or(
{"type": "any"},
{"type": "variadic"},
{"type": "script"},
{"type": "expression"},
),
"value": _positional_value,
}
],
Optional("switches", default={}): {
Optional(str): {
"required": bool,
"repeated": bool,
"value": Or({"type": "any"}, None),
"value": _switch_value,
Optional("metavar"): str,
}
},

Some files were not shown because too many files have changed in this diff Show More