import-linter-ls 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 daniprol
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: import-linter-ls
3
+ Version: 0.1.0
4
+ Summary: A language server for import-linter: see contract errors and import violations in your editor
5
+ Keywords: import-linter,language-server,lsp,architecture
6
+ Author: daniprol
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Software Development :: Quality Assurance
12
+ Classifier: Typing :: Typed
13
+ Requires-Dist: import-linter==2.16
14
+ Requires-Dist: pygls>=2.0,<3
15
+ Requires-Python: >=3.11
16
+ Project-URL: Documentation, https://daniprol.github.io/import-linter-ls/
17
+ Project-URL: Repository, https://github.com/daniprol/import-linter-ls
18
+ Project-URL: Issues, https://github.com/daniprol/import-linter-ls/issues
19
+ Description-Content-Type: text/markdown
20
+
21
+ # import-linter-ls
22
+
23
+ A language server for [import-linter](https://import-linter.readthedocs.io/), with a VS Code extension.
24
+ See your architecture contract errors and import violations in the editor, not only in CI.
25
+
26
+ > **Status: early.** The server starts and recognises import-linter config files. Diagnostics, hover and completion
27
+ > are coming. See the [roadmap](docs/roadmap.md).
28
+
29
+ ## Use it in VS Code
30
+
31
+ 1. Install the server in your project:
32
+
33
+ ```bash
34
+ uv add --dev import-linter-ls # or: pip install import-linter-ls
35
+ ```
36
+
37
+ 2. Install the **Import Linter** extension. Until it is on the Marketplace, download the `.vsix` from the
38
+ [latest release](https://github.com/daniprol/import-linter-ls/releases/latest) and run
39
+ **Extensions: Install from VSIX...**.
40
+ 3. Select your project's Python environment (**Python: Select Interpreter**) and open your project.
41
+
42
+ It works with `.importlinter`, `setup.cfg` and `pyproject.toml` configs. Check **Output > Import Linter** for logs.
43
+
44
+ | Setting | Purpose |
45
+ |---|---|
46
+ | `importLinterLs.interpreter` | Python that has `import-linter-ls`. Empty: use the one selected in VS Code. |
47
+ | `importLinterLs.trace.server` | `off`, `messages` or `verbose`: log the editor/server messages. |
48
+
49
+ Command: **Import Linter: Restart Language Server**.
50
+
51
+ ## Use it in another editor
52
+
53
+ Any editor with an LSP client can run the server over stdio with `import-linter-ls`
54
+ (or `python -m import_linter_ls`).
55
+
56
+ ## Develop
57
+
58
+ ```bash
59
+ uv sync && pnpm install
60
+ uv run prek install # git hooks
61
+ uv run pytest # unit + integration tests
62
+ ```
63
+
64
+ To try the extension, open this folder in VS Code and press `F5`, then open `examples/layered_app`.
65
+ More in [CONTRIBUTING.md](CONTRIBUTING.md) and [AGENTS.md](AGENTS.md).
66
+
67
+ ## Layout
68
+
69
+ | Path | What |
70
+ |---|---|
71
+ | `src/import_linter_ls/` | The language server (Python, [pygls](https://pygls.readthedocs.io/)). |
72
+ | `vscode-extension/` | The VS Code extension (TypeScript), a thin client. |
73
+ | `examples/layered_app/` | Example app with one broken contract, in TOML and INI. |
74
+ | `tests/` | Unit and integration tests. |
75
+ | `docs/` | Documentation site ([zensical](https://zensical.org/)). |
@@ -0,0 +1,55 @@
1
+ # import-linter-ls
2
+
3
+ A language server for [import-linter](https://import-linter.readthedocs.io/), with a VS Code extension.
4
+ See your architecture contract errors and import violations in the editor, not only in CI.
5
+
6
+ > **Status: early.** The server starts and recognises import-linter config files. Diagnostics, hover and completion
7
+ > are coming. See the [roadmap](docs/roadmap.md).
8
+
9
+ ## Use it in VS Code
10
+
11
+ 1. Install the server in your project:
12
+
13
+ ```bash
14
+ uv add --dev import-linter-ls # or: pip install import-linter-ls
15
+ ```
16
+
17
+ 2. Install the **Import Linter** extension. Until it is on the Marketplace, download the `.vsix` from the
18
+ [latest release](https://github.com/daniprol/import-linter-ls/releases/latest) and run
19
+ **Extensions: Install from VSIX...**.
20
+ 3. Select your project's Python environment (**Python: Select Interpreter**) and open your project.
21
+
22
+ It works with `.importlinter`, `setup.cfg` and `pyproject.toml` configs. Check **Output > Import Linter** for logs.
23
+
24
+ | Setting | Purpose |
25
+ |---|---|
26
+ | `importLinterLs.interpreter` | Python that has `import-linter-ls`. Empty: use the one selected in VS Code. |
27
+ | `importLinterLs.trace.server` | `off`, `messages` or `verbose`: log the editor/server messages. |
28
+
29
+ Command: **Import Linter: Restart Language Server**.
30
+
31
+ ## Use it in another editor
32
+
33
+ Any editor with an LSP client can run the server over stdio with `import-linter-ls`
34
+ (or `python -m import_linter_ls`).
35
+
36
+ ## Develop
37
+
38
+ ```bash
39
+ uv sync && pnpm install
40
+ uv run prek install # git hooks
41
+ uv run pytest # unit + integration tests
42
+ ```
43
+
44
+ To try the extension, open this folder in VS Code and press `F5`, then open `examples/layered_app`.
45
+ More in [CONTRIBUTING.md](CONTRIBUTING.md) and [AGENTS.md](AGENTS.md).
46
+
47
+ ## Layout
48
+
49
+ | Path | What |
50
+ |---|---|
51
+ | `src/import_linter_ls/` | The language server (Python, [pygls](https://pygls.readthedocs.io/)). |
52
+ | `vscode-extension/` | The VS Code extension (TypeScript), a thin client. |
53
+ | `examples/layered_app/` | Example app with one broken contract, in TOML and INI. |
54
+ | `tests/` | Unit and integration tests. |
55
+ | `docs/` | Documentation site ([zensical](https://zensical.org/)). |
@@ -0,0 +1,108 @@
1
+ [project]
2
+ name = "import-linter-ls"
3
+ version = "0.1.0"
4
+ description = "A language server for import-linter: see contract errors and import violations in your editor"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ keywords = [
9
+ "import-linter",
10
+ "language-server",
11
+ "lsp",
12
+ "architecture",
13
+ ]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Software Development :: Quality Assurance",
18
+ "Typing :: Typed",
19
+ ]
20
+ requires-python = ">=3.11"
21
+ dependencies = [
22
+ "import-linter==2.16",
23
+ "pygls>=2.0,<3",
24
+ ]
25
+
26
+ [[project.authors]]
27
+ name = "daniprol"
28
+
29
+ [project.scripts]
30
+ import-linter-ls = "import_linter_ls.__main__:main"
31
+
32
+ [project.urls]
33
+ Documentation = "https://daniprol.github.io/import-linter-ls/"
34
+ Repository = "https://github.com/daniprol/import-linter-ls"
35
+ Issues = "https://github.com/daniprol/import-linter-ls/issues"
36
+
37
+ [build-system]
38
+ requires = ["uv_build>=0.9,<0.12"]
39
+ build-backend = "uv_build"
40
+
41
+ [tool.uv.workspace]
42
+ members = ["examples/layered_app"]
43
+
44
+ [tool.uv.sources.layered-app]
45
+ workspace = true
46
+
47
+ [tool.ruff]
48
+ line-length = 120
49
+ target-version = "py311"
50
+ src = [
51
+ "src",
52
+ "tests",
53
+ ]
54
+
55
+ [tool.ruff.lint]
56
+ select = [
57
+ "E",
58
+ "W",
59
+ "F",
60
+ "I",
61
+ "N",
62
+ "UP",
63
+ "B",
64
+ "A",
65
+ "C4",
66
+ "SIM",
67
+ "RET",
68
+ "PIE",
69
+ "PTH",
70
+ "PT",
71
+ "ASYNC",
72
+ "RUF",
73
+ ]
74
+
75
+ [tool.ruff.format]
76
+ docstring-code-format = true
77
+
78
+ [tool.ty.rules]
79
+ missing-type-argument = "error"
80
+
81
+ [tool.pytest.ini_options]
82
+ testpaths = ["tests"]
83
+ asyncio_mode = "auto"
84
+ asyncio_default_fixture_loop_scope = "function"
85
+ addopts = [
86
+ "--strict-markers",
87
+ "--strict-config",
88
+ ]
89
+ markers = [
90
+ "unit: fast tests of one function, no server",
91
+ "integration: starts the real language server and talks LSP to it",
92
+ ]
93
+
94
+ [tool.coverage.run]
95
+ source = ["import_linter_ls"]
96
+ branch = true
97
+
98
+ [dependency-groups]
99
+ dev = [
100
+ "layered-app",
101
+ "prek>=0.2",
102
+ "pytest>=8",
103
+ "pytest-cov>=6",
104
+ "pytest-lsp>=1.0",
105
+ "ruff>=0.14",
106
+ "ty>=0.0.1",
107
+ ]
108
+ docs = ["zensical>=0.0.20"]
@@ -0,0 +1,97 @@
1
+ [project]
2
+ name = "import-linter-ls"
3
+ version = "0.1.0"
4
+ description = "A language server for import-linter: see contract errors and import violations in your editor"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "daniprol" }]
9
+ keywords = ["import-linter", "language-server", "lsp", "architecture"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Programming Language :: Python :: 3",
13
+ "Topic :: Software Development :: Quality Assurance",
14
+ "Typing :: Typed",
15
+ ]
16
+ # Same minimum as import-linter itself.
17
+ requires-python = ">=3.11"
18
+ dependencies = [
19
+ "import-linter==2.16",
20
+ "pygls>=2.0,<3",
21
+ ]
22
+
23
+ [project.scripts]
24
+ import-linter-ls = "import_linter_ls.__main__:main"
25
+
26
+ [project.urls]
27
+ Documentation = "https://daniprol.github.io/import-linter-ls/"
28
+ Repository = "https://github.com/daniprol/import-linter-ls"
29
+ Issues = "https://github.com/daniprol/import-linter-ls/issues"
30
+
31
+ [build-system]
32
+ requires = ["uv_build>=0.9,<0.12"]
33
+ build-backend = "uv_build"
34
+
35
+ # The example app is a workspace member so `uv sync` installs it too.
36
+ [tool.uv.workspace]
37
+ members = ["examples/layered_app"]
38
+
39
+ [dependency-groups]
40
+ dev = [
41
+ "layered-app",
42
+ "prek>=0.2",
43
+ "pytest>=8",
44
+ "pytest-cov>=6",
45
+ "pytest-lsp>=1.0",
46
+ "ruff>=0.14",
47
+ "ty>=0.0.1",
48
+ ]
49
+ docs = ["zensical>=0.0.20"] # uv run --only-group docs zensical serve
50
+
51
+ [tool.uv.sources]
52
+ layered-app = { workspace = true }
53
+
54
+ [tool.ruff]
55
+ line-length = 120
56
+ target-version = "py311"
57
+ src = ["src", "tests"]
58
+
59
+ [tool.ruff.lint]
60
+ select = [
61
+ "E", "W", # pycodestyle
62
+ "F", # pyflakes
63
+ "I", # import sorting
64
+ "N", # naming
65
+ "UP", # modern syntax for Python 3.11+
66
+ "B", # likely bugs
67
+ "A", # shadowing builtins
68
+ "C4", # comprehensions
69
+ "SIM", # simplifications
70
+ "RET", # return statements
71
+ "PIE", # misc. clean-ups
72
+ "PTH", # pathlib over os.path
73
+ "PT", # pytest style
74
+ "ASYNC", # asyncio pitfalls
75
+ "RUF", # ruff-specific
76
+ ]
77
+
78
+ [tool.ruff.format]
79
+ docstring-code-format = true
80
+
81
+ [tool.ty.rules]
82
+ missing-type-argument = "error" # `list[str]`, not `list`: a bare generic hides mistakes
83
+
84
+ [tool.pytest.ini_options]
85
+ testpaths = ["tests"]
86
+ asyncio_mode = "auto"
87
+ asyncio_default_fixture_loop_scope = "function"
88
+ # `unit` tests are fast and need no server. `integration` tests start the real server and talk LSP to it.
89
+ addopts = ["--strict-markers", "--strict-config"]
90
+ markers = [
91
+ "unit: fast tests of one function, no server",
92
+ "integration: starts the real language server and talks LSP to it",
93
+ ]
94
+
95
+ [tool.coverage.run]
96
+ source = ["import_linter_ls"]
97
+ branch = true
@@ -0,0 +1,5 @@
1
+ """A language server for import-linter."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("import-linter-ls")
@@ -0,0 +1,13 @@
1
+ """Entry point: `python -m import_linter_ls` or the `import-linter-ls` command."""
2
+
3
+ from import_linter_ls.server import server
4
+
5
+
6
+ def main() -> None:
7
+ # Editors start the server as a child process and talk to it over stdin and stdout,
8
+ # so never print to stdout: it would corrupt the protocol messages. Use `log` from server.py instead.
9
+ server.start_io()
10
+
11
+
12
+ if __name__ == "__main__":
13
+ main()
@@ -0,0 +1,24 @@
1
+ """Finding out which files are import-linter config files."""
2
+
3
+ from pathlib import Path
4
+
5
+ # import-linter reads its config from these files (see its docs, "Usage").
6
+ # `.importlinter` and `setup.cfg` use the INI format, `pyproject.toml` uses TOML.
7
+ INI_CONFIG_FILE_NAMES = frozenset({".importlinter", "setup.cfg"})
8
+ TOML_CONFIG_FILE_NAMES = frozenset({"pyproject.toml"})
9
+
10
+ # Some projects pass their own file with `lint-imports --config my_file`. We cannot know those names,
11
+ # so we only recognise the three default ones for now.
12
+
13
+
14
+ def config_format(path: Path) -> str | None:
15
+ """Return "ini" or "toml" if `path` can hold import-linter config, otherwise None.
16
+
17
+ Only the file name is checked. A `pyproject.toml` or `setup.cfg` is only a *candidate*: it
18
+ holds import-linter config only if it has an `importlinter` section.
19
+ """
20
+ if path.name in INI_CONFIG_FILE_NAMES:
21
+ return "ini"
22
+ if path.name in TOML_CONFIG_FILE_NAMES:
23
+ return "toml"
24
+ return None
File without changes
@@ -0,0 +1,90 @@
1
+ """The language server: connects pygls to our features.
2
+
3
+ This file only wires things together. The actual logic lives in small modules next to it.
4
+ """
5
+
6
+ import asyncio
7
+ from pathlib import Path
8
+
9
+ from lsprotocol import types
10
+ from pygls.lsp.server import LanguageServer
11
+ from pygls.uris import to_fs_path
12
+
13
+ from import_linter_ls import __version__
14
+ from import_linter_ls.violations import Violation, find_project_dir, parse_report, run_import_linter
15
+
16
+ # A language server is a program that an editor starts and talks to with JSON messages (the Language
17
+ # Server Protocol, LSP). The editor sends events ("the user opened this file") and we answer or
18
+ # send back results ("these lines have problems"). pygls handles the JSON and the protocol for us.
19
+ server = LanguageServer("import-linter-ls", __version__)
20
+
21
+
22
+ def log(message: str) -> None:
23
+ """Send a message to the editor's log (VS Code shows it in the extension's output channel)."""
24
+ server.window_log_message(types.LogMessageParams(type=types.MessageType.Info, message=message))
25
+
26
+
27
+ @server.feature(types.INITIALIZED)
28
+ def on_initialized(_params: types.InitializedParams) -> None:
29
+ """The editor finished the handshake: from now on it may send us events."""
30
+ log(f"import-linter-ls {__version__} is ready")
31
+
32
+
33
+ # Files that currently show our diagnostics. We must send an empty list for a file once it is fixed.
34
+ files_with_diagnostics: set[str] = set()
35
+
36
+
37
+ @server.feature(types.TEXT_DOCUMENT_DID_OPEN)
38
+ async def on_document_opened(params: types.DidOpenTextDocumentParams) -> None:
39
+ """The user opened a file: check the project, so the editor shows problems right away."""
40
+ await check_project(params.text_document.uri)
41
+
42
+
43
+ @server.feature(types.TEXT_DOCUMENT_DID_SAVE)
44
+ async def on_document_saved(params: types.DidSaveTextDocumentParams) -> None:
45
+ """The user saved a file: check the project again."""
46
+ await check_project(params.text_document.uri)
47
+
48
+
49
+ async def check_project(document_uri: str) -> None:
50
+ """Run import-linter for the project that owns the document and publish what it finds."""
51
+ file_path = to_fs_path(document_uri)
52
+ if file_path is None: # not a file on disk, for example an unsaved "untitled:" document
53
+ return
54
+ project_dir = find_project_dir(Path(file_path))
55
+ if project_dir is None: # no import-linter config nearby, nothing to check
56
+ return
57
+
58
+ log(f"Running import-linter in {project_dir}")
59
+ # import-linter takes a few seconds on big projects. Run it in a thread so the server can keep answering.
60
+ report = await asyncio.to_thread(run_import_linter, project_dir)
61
+ publish_diagnostics(parse_report(report, project_dir))
62
+
63
+
64
+ def publish_diagnostics(violations: list[Violation]) -> None:
65
+ """Send the violations to the editor. A "diagnostic" is a problem on a range of a file (a squiggly line)."""
66
+ diagnostics_by_uri: dict[str, list[types.Diagnostic]] = {uri: [] for uri in files_with_diagnostics}
67
+ for violation in violations:
68
+ uri = violation.file.as_uri()
69
+ diagnostics_by_uri.setdefault(uri, []).append(to_diagnostic(violation))
70
+
71
+ for uri, diagnostics in diagnostics_by_uri.items():
72
+ server.text_document_publish_diagnostics(types.PublishDiagnosticsParams(uri=uri, diagnostics=diagnostics))
73
+ files_with_diagnostics.clear()
74
+ files_with_diagnostics.update(uri for uri, diagnostics in diagnostics_by_uri.items() if diagnostics)
75
+
76
+
77
+ def to_diagnostic(violation: Violation) -> types.Diagnostic:
78
+ """Underline the whole line of the import."""
79
+ # LSP positions start at 0 for both line and character, while import-linter's line numbers start at 1.
80
+ line_index = violation.line - 1
81
+ line_text = violation.file.read_text(errors="replace").splitlines()[line_index]
82
+ return types.Diagnostic(
83
+ range=types.Range(
84
+ start=types.Position(line=line_index, character=0),
85
+ end=types.Position(line=line_index, character=len(line_text)),
86
+ ),
87
+ message=f"{violation.importer} imports {violation.imported}, which breaks an import-linter contract",
88
+ severity=types.DiagnosticSeverity.Error,
89
+ source="import-linter",
90
+ )
@@ -0,0 +1,74 @@
1
+ """Run import-linter on a project and turn its report into a list of violations.
2
+
3
+ This is a throwaway first version: it runs `lint-imports` and reads its text output. It will be
4
+ replaced by a module that talks to import-linter directly (see docs/roadmap.md).
5
+ """
6
+
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from import_linter_ls.config_files import config_format
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Violation:
18
+ """One import that breaks a contract. `line` starts at 1, like in an editor's status bar."""
19
+
20
+ file: Path
21
+ line: int
22
+ importer: str
23
+ imported: str
24
+
25
+
26
+ def find_project_dir(file: Path) -> Path | None:
27
+ """Walk up from `file` to the first folder with an import-linter config file."""
28
+ for folder in file.parents:
29
+ for candidate in folder.iterdir():
30
+ if config_format(candidate) and "importlinter" in candidate.read_text(errors="replace"):
31
+ return folder
32
+ return None
33
+
34
+
35
+ def run_import_linter(project_dir: Path) -> str:
36
+ """Run `lint-imports` in `project_dir` and return its report (the output is the same for pass and fail)."""
37
+ # Start the `lint-imports` command through Python so that it uses the same environment as the
38
+ # server, on every operating system. `--no-cache` makes sure that a saved file is analysed again.
39
+ code = "from importlinter.cli import lint_imports_command; lint_imports_command()"
40
+ result = subprocess.run(
41
+ [sys.executable, "-c", code, "--no-cache"],
42
+ cwd=project_dir,
43
+ capture_output=True,
44
+ text=True,
45
+ check=False, # exit code 1 only means "there are broken contracts"
46
+ )
47
+ return result.stdout
48
+
49
+
50
+ # A broken import is reported like this: `- layered_app.low.storage -> layered_app.high.report (l.4)`.
51
+ # When one module imports the same thing twice there are several line numbers: `(l.4, l.9)`.
52
+ VIOLATION_LINE = re.compile(r"(?P<importer>[\w.]+) -> (?P<imported>[\w.]+) \((?P<lines>l\.\d+(?:, l\.\d+)*)\)")
53
+
54
+
55
+ def parse_report(report: str, project_dir: Path) -> list[Violation]:
56
+ """Find the broken imports in the text printed by `lint-imports`."""
57
+ violations = []
58
+ for match in VIOLATION_LINE.finditer(report):
59
+ file = module_to_file(match["importer"], project_dir)
60
+ if file is None:
61
+ continue
62
+ for line_number in re.findall(r"\d+", match["lines"]):
63
+ violations.append(Violation(file, int(line_number), match["importer"], match["imported"]))
64
+ return violations
65
+
66
+
67
+ def module_to_file(module: str, project_dir: Path) -> Path | None:
68
+ """Find the source file of a module like `layered_app.low.storage`, in the project or in its `src` folder."""
69
+ relative = Path(*module.split("."))
70
+ for source_root in (project_dir, project_dir / "src"):
71
+ for candidate in (source_root / relative.with_suffix(".py"), source_root / relative / "__init__.py"):
72
+ if candidate.is_file():
73
+ return candidate
74
+ return None