Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Test
on:
pull_request:
branches: [ main ]
push:
branches: [ main ]

jobs:
lint:
name: Check with linter
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ruff
- name: Run Ruff
run: ruff check --output-format=github .

test:
name: Run smoke tests
runs-on: ubuntu-latest
strategy:
matrix:
python-version:
- "3.12"
- "3.13"
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: ""
- name: Install the project
run: uv sync --all-extras --dev
- name: Run tests
run: uv run pytest
34 changes: 20 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@

**Work in progress**

A wrapper around [Jedi](https://jedi.readthedocs.io/en/latest/index.html)'s `Project` that helps to analyse and refactor
imports in your Python code. Starkiller aims to be as static as possible, i.e. to analyse source code without actually
executing it.
A package and [python-lsp-server](https://github.com/python-lsp/python-lsp-server) plugin that helps to analyze and
refactor imports in your Python code.
Starkiller aims to be static, i.e. to analyse source code without actually executing it, and fast, thanks to built-in
`ast` module.

The initial goal was to create a simple code formatter to get rid of star imports, hence the choice of the package name.
The initial goal was to create a simple linter to get rid of star imports, hence the choice of the package name.

## Python LSP Server plugin

This package contains a plugin for [python-lsp-server](https://github.com/python-lsp/python-lsp-server) that provides
code actions to refactor import statements:
The `pylsp` plugin provides the following code actions to refactor import statements:

- `Replace * with imported names` - suggested for `from <module> import *` statements.
- At least one more upcoming.
- `Replace * with explicit names` - suggested for `from ... import *` statements.
- `Replace * import with module import` - suggested for `from ... import *` statements.
- [wip] `Replace from import with module import` - suggested for `from ... import ...` statements.
- [wip] `Replace module import with from import` - suggested for `import ...` statements.

To enable the plugin install Starkiller in the same virtual environment as `python-lsp-server` with `[pylsp]` optional
dependency. E.g. with `pipx`:
Expand All @@ -32,7 +34,11 @@ require("lspconfig").pylsp.setup {
settings = {
pylsp = {
plugins = {
starkiller = {enabled = true},
starkiller = { enabled = true },
aliases = {
numpy = "np",
[ "matplotlib.pyplot" ] = "plt",
}
}
}
}
Expand All @@ -41,8 +47,8 @@ require("lspconfig").pylsp.setup {

## Alternatives and inspiration

[removestar](https://www.asmeurer.com/removestar/) provides a [Pyflakes](https://github.com/PyCQA/pyflakes) based tool.

[SurpriseDog's scripts](https://github.com/SurpriseDog/Star-Wrangler) are a great source of inspiration.

`pylsp` has a built-in `rope_autoimport` plugin utilizing [Rope](https://github.com/python-rope/rope)'s `autoimport` module.
- [removestar](https://www.asmeurer.com/removestar/) is a [Pyflakes](https://github.com/PyCQA/pyflakes) based tool with
similar objectives.
- [SurpriseDog's scripts](https://github.com/SurpriseDog/Star-Wrangler) are a great source of inspiration.
- `pylsp` itself has a built-in `rope_autoimport` plugin utilizing [Rope](https://github.com/python-rope/rope)'s
`autoimport` module.
2 changes: 2 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[mypy]
python_executable=./.venv/bin/python
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ dependencies = [

[project.optional-dependencies]
pylsp = [
"lsprotocol>=2023.0.1",
"python-lsp-server>=1.12.2",
]

[dependency-groups]
dev = [
"pytest-stub>=1.1.0",
"pytest>=8.3.5",
"pytest-virtualenv>=1.8.1",
]

[project.entry-points.pylsp]
Expand Down
120 changes: 69 additions & 51 deletions starkiller/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,6 @@
BUILTINS = set(dir(builtins))


def check_line_for_star_import(line: str) -> str | None:
"""Checks if given line of python code contains star import.

Args:
line: Line of code to check

Returns:
Module name or None
"""
body = ast.parse(line).body

if len(body) == 0 or not isinstance(body[0], ast.ImportFrom):
return None

statement = body[0]
match statement.names:
case [ast.alias(name="*")]:
return statement.module
return None


@dataclass(frozen=True)
class ImportedName:
"""Imported name structure."""
Expand All @@ -55,33 +34,6 @@ class _LocalScope:
args: list[str] | None = None


def parse_module(
code: str,
find_definitions: set[str] | None = None,
*,
check_internal_scopes: bool = False,
) -> ModuleNames:
"""Parse Python source and find all definitions, undefined symbols usages and imported names.

Args:
code: Source code to be parsed
find_definitions: Optional set of definitions to look for
check_internal_scopes: If False, won't parse function and classes definitions

Returns:
ModuleNames object
"""
visitor = _ScopeVisitor(find_definitions=find_definitions)
visitor.visit(ast.parse(code))
if check_internal_scopes:
visitor.visit_internal_scopes()
return ModuleNames(
undefined=visitor.undefined,
defined=visitor.defined,
import_map=visitor.import_map,
)


class _ScopeVisitor(ast.NodeVisitor):
def __init__(self, find_definitions: set[str] | None = None) -> None:
super().__init__()
Expand Down Expand Up @@ -141,8 +93,9 @@ def import_map(self) -> dict[str, set[ImportedName]]:

@contextmanager
def definition_context(self) -> Generator[None]:
# We use context to control new names treatment: should we record them as definitions or presume they could be
# undefined.
# This is not thread safe! Consider using thead local data to store definition context state.
# Context manager is used in this class to control new names treatment: either to record them as definitions or
# as possible usages of undefined names.
self._in_definition_context = True
yield
self._in_definition_context = False
Expand Down Expand Up @@ -179,7 +132,7 @@ def visit_Name(self, node: ast.Name) -> None:
def visit_Import(self, node: ast.Import) -> None:
for name in node.names:
self.record_import_from_module(
module_name=name.name or ".",
module_name=name.name,
name=name.name,
alias=name.asname,
)
Expand Down Expand Up @@ -268,3 +221,68 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:

def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
self._visit_callable(node)


def parse_module(
code: str,
find_definitions: set[str] | None = None,
*,
check_internal_scopes: bool = False,
) -> ModuleNames:
"""Parse Python source and find all definitions, undefined symbols usages and imported names.

Args:
code: Source code to be parsed.
find_definitions: Optional set of definitions to look for.
check_internal_scopes: If False, won't parse function and classes definitions.

Returns:
ModuleNames object.
"""
visitor = _ScopeVisitor(find_definitions=find_definitions)
visitor.visit(ast.parse(code))
if check_internal_scopes:
visitor.visit_internal_scopes()
return ModuleNames(
undefined=visitor.undefined,
defined=visitor.defined,
import_map=visitor.import_map,
)


def find_from_import(line: str) -> tuple[str, list[ImportedName]] | tuple[None, None]:
"""Checks if given line of python code contains from import statement.

Args:
line: Line of code to check.

Returns:
Module name and ImportedName list or `(None, None)`.
"""
body = ast.parse(line).body
if len(body) == 0 or not isinstance(body[0], ast.ImportFrom):
return None, None

node = body[0]
module_name = "." * node.level
if node.module:
module_name += node.module
imported_names = [ImportedName(name=name.name, alias=name.asname) for name in node.names]
return module_name, imported_names


def find_import(line: str) -> list[ImportedName] | None:
"""Checks if given line of python code contains import statement.

Args:
line: Line of code to check.

Returns:
ImportedName or None.
"""
body = ast.parse(line).body
if len(body) == 0 or not isinstance(body[0], ast.Import):
return None

node = body[0]
return [ImportedName(name=name.name, alias=name.asname) for name in node.names]
Loading