Issues & Bugs: danielonet/pyrite/issues · Changelog: CHANGELOG.md
Pyrite
Read Python as if it were Java.
Developers today, Java developers included, increasingly write code with AI. A Java
developer dropped into a Python codebase no longer has to close the language gap
before contributing, because the AI can write the Python. The real risk is the
opposite: shipping code you cannot read. That is vibe coding, and it leaves the
developer unable to review, reason about, or take responsibility for what the
application does.
Pyrite closes that gap on the reading side, so a Java developer on a Python project
can practise Supervised Development instead of vibe coding: read the AI's output
at a developer level, understand the design, review changes, and steer the
application deliberately.
The name is the disclaimer. Pyrite is fool's gold: it looks like Java but is not, and
it is never meant to compile. It is a faithful reading view of the Python underneath.
Pyrite mirrors every .py file in your workspace into a .java-view/
folder laid out as a standard Java project: production code in
src/main/java, test code (test_*.py, *_test.py, conftest.py, anything under
test/tests) in src/test/java. Every package starts with the project's name
(the workspace folder, or pyrite.basePackage, optionally behind pyrite.basePackagePrefix) followed by the Python folder path,
and each file is named after its Java class, as Java requires. Each module is
translated into a Java-flavored
reading view. The view keeps Python's names, order, comments
and docstrings, but uses Java syntax: braces, semicolons, explicit types,
for (var x : xs), throw new ..., streams instead of comprehensions, and
so on. It is meant to be read, not compiled.
my-project/ my-project/.java-view/src/
├── main.py ├── main/java/my_project/
├── inventory/ │ ├── Main.java package my_project;
│ ├── models.py ==> │ └── inventory/
│ └── services/ │ ├── Models.java package my_project.inventory;
│ └── order_service.py │ └── services/
└── tests/ │ └── OrderService.java package my_project.inventory.services;
└── test_order_service.py └── test/java/my_project/tests/
└── TestOrderService.java package my_project.tests;
Imports of the project's own modules carry the same prefix and point at the generated class
(from .models import Order -> import my_project.inventory.Models.Order;).
Features
- Generate Java view for the workspace – one command translates the whole tree.
- Live sync – saving a Python file re-translates just that file (
pyrite.watch).
- Status bar report – the Pyrite logo sits in the status bar; hovering it
(or clicking it) shows how many classes, methods, files and fields the Java
view holds, a health bar, and the engine in use. Failures, Python syntax
errors and warnings are links: clicking one lists every message in the Output
panel. Buttons regenerate or delete the view, or open the Pyrite settings.
- Two-way navigation –
Ctrl+Alt+J jumps from a Python line to the matching
Java line and back, using a per-file line map.
- Go to Definition – F12 / Ctrl+Click / right-click on a class, method or
field name inside the Java view jumps to where it's declared, even in
another mirrored file.
- Two work modes –
rules (default: deterministic, offline, instant) and
hybrid (the rules plus a small LLM running on your own machine for the few
functions the rules cannot translate). Switch with the pyrite.engine setting.
See Work modes.
- CLI for CI or quick checks:
npx pyrite <project-root>.
See architecture/ for design notes, including the
hybrid engine and the earlier
cloud LLM engine that the local one replaced.
The rules engine
The rules engine is Pyrite's core and the default mode. It is a deterministic,
line-oriented translator built on a real Python parser
(tree-sitter): the same Python always gives the same
Java view, it runs offline with no API key, and it translates a whole project in
seconds. It works on the structure it recognises (classes, functions, control flow,
common builtins, type hints), which covers the large majority of ordinary Python.
What it maps:
| Python |
Java view |
module order_service.py |
public final class OrderService { ... } in OrderService.java |
class Foo(Base): |
public static class Foo extends Base { |
class OrderService: inside order_service.py |
the module's class itself, public class OrderService { ... }, with the module's constants and functions as its static members (Java forbids a nested class named like its enclosing class) |
@dataclass fields, self.x = ... in __init__ |
field declarations with inferred types |
def f(self, a: int, b="x") -> bool: |
public boolean f(int a, String b /* = "x" */) { |
__init__, __str__, __eq__, __len__ ... |
constructor, toString(), equals(), size() ... |
if __name__ == "__main__": |
public static void main(String[] args) { |
for i in range(n) / for k, v in d.items() |
for (int i = 0; i < n; i++) / entrySet() loop |
try/except X as e/finally, raise X(...) |
try/catch (X e)/finally, throw new X(...) |
with open(p) as f: |
try (var f = open(p)) { |
[f(x) for x in xs if c] |
xs.stream().filter(x -> c).map(x -> f(x)).toList() |
sum, sorted(key=..), any/all, next, round(x, 2), map/filter, isinstance(x, (A, B)), open(p, "w"), ... |
.mapToDouble(..).sum(), .sorted(Comparator.comparing(..)), .anyMatch(..), it.next(), Math.round(x * 100.0) / 100.0, .map(..)/.filter(..), x instanceof A \|\| .., new PrintWriter(p), ... |
value = a or default |
Objects.requireNonNullElse(a, default) (in a condition a or b stays a \|\| b) |
@property def total(self) and order.total |
total() and order.total(); a trivial getter/setter pair in Lombok style becomes @Getter/@Setter on a field named after the property, read as order.getTotal() |
x = repo.order(1) where order has a -> Order hint, for line in self.lines with lines: List[OrderLine] |
Order x = ..., for (OrderLine line : this.lines) - types follow hints on functions, methods (also from other files and base classes), fields and parameters |
f"Hi {name}, {total:.2f}" |
"Hi " + name + ", " + String.format("%.2f", total) |
x if c else y, and/or/not, None/True/False |
c ? x : y, &&/\|\|/!, null/true/false |
if items and not done: (truthiness) |
if (Py.truthy(items) && !done) - a value that is not a boolean goes through the runtime library |
xs[1:-1], f()[-1], (a, b), zip(a, b) |
xs.subList(1, xs.size() - 1), f().getLast(), Tuple.of(a, b), Tuple.zip(a, b) |
os.path.join(a, b), sys.argv, re.match(p, s), math.floor(x) |
Os.path.join(a, b), Sys.argv, Re.match(p, s), (long) Math.floor(x) |
a name assigned in an if/try branch and used after it |
declared before the block (String label = null;), since a Java local lives only in its block |
def f(text): return text.strip().split(",") (no hint) |
f(String text): an unhinted parameter used only as a string, list or dict gets that type |
s.split(), s.partition("="), xs.pop(), s[0] on a string |
Py.split(s), Py.partition(s, "="), Py.pop(xs), String.valueOf(s.charAt(0)) |
Optional[T], list[int], dict[str, Any] |
T /* nullable */, List<Integer>, Map<String, Object> |
ValueError, KeyError, ... |
IllegalArgumentException, NoSuchElementException, ... |
docstrings / # comments |
Javadoc / // comments |
inventory/__init__.py with only a docstring / __all__ |
nothing: Java packages are plain folders |
inventory/__init__.py with real content (constants, re-exports) |
public final class InventoryInit in inventory/InventoryInit.java, Javadoc names the original __init__.py; the Init suffix hints at __init__.py and keeps it apart from a sibling inventory/inventory.py (Inventory.java) |
Anything without a clean equivalent is kept and annotated with a /* ... */
comment rather than dropped, so the view is always complete.
A file with a Python syntax error is still translated, but its view starts with a
// WARNING block naming the error, and a file the engine cannot handle at all gets a
TRANSLATION FAILED placeholder rather than an outdated view. Syntax is checked with a
real Python parser (tree-sitter, WebAssembly, offline).
The small LLM
Some Python does not translate well line by line, for example a comprehension with two
for clauses, or an assignment expression (if (n := len(xs)) > 3). For those, Pyrite
can ask a small language model running locally to rewrite just that one function.
The model gets the Python function (with line numbers) and the rules engine's draft,
and returns a Java-flavored version that keeps every statement, name and comment.
It runs through Ollama, so nothing leaves your machine and no
API key is involved.
Pyrite does not include an LLM server. For now you install and run one yourself
(Ollama is the supported one) and point Pyrite at it; see
Running a local LLM with Ollama. If none is
running, hybrid mode simply behaves like rules and tells you why.
Work modes: rules and hybrid
Set the mode with pyrite.engine:
| Mode |
What runs |
Needs |
rules (default) |
Rules engine only |
nothing |
hybrid |
Rules engine for everything, plus the local LLM for the functions the rules cannot handle and for the imports the JDK index cannot resolve |
a running Ollama and a downloaded model |
How hybrid works, per file:
- The rules engine translates the whole file first, exactly as in
rules mode.
- Pyrite splits the file into functions and methods and looks for ones the rules
failed on: a warning was raised inside it, a statement stayed
untranslated, or it
uses a construct the rules are known to get wrong (nested comprehension, :=).
- Only those functions are sent to the model, one request per function, each with
its Python source and the rules' draft. Files and functions the rules handled never
touch the model, so most files make no request at all (
pyrite.ollama.maxFunctionsPerFile
caps the rest).
- The answer replaces just that function in the Java view. The line map and Go to
Definition are kept, and a
// Rewritten by <model> (<reason>); check against the Python. line marks it, so you always know which parts came from the model.
- If the model is unreachable, too slow, or returns something unusable, the rules
output for that function stays and a warning is recorded (click the warning count in
the status bar report to see it). Answers are cached in
.java-view/.pyrite/ollama-cache.json, so only functions that changed are sent again.
Model output is not deterministic or guaranteed correct: treat a rewritten function as
a reading aid and check it against the Python, which is why it is marked.
Real Java imports
Python's imports (import logging, from typing import Dict) name modules, not classes
javac could find. Pyrite replaces them with explicit imports of the real classes the view
uses, in both modes:
// Python-shaped // resolved (default)
import java.util.*; import java.util.List;
import java.util.stream.*; import java.util.Map;
import logging; import java.util.logging.Logger;
import typing.Dict;
import app.models.Order; import lombok.Getter;
import app.utils.formatting.money;
import app.Models.Order;
import static app.utils.Formatting.money;
// python: import logging
// python: from typing import Dict
- Every type the Java code uses is looked up in an index of the real JDK (every public
class of the
java.se modules, generated from JDK 21): List becomes java.util.List,
Logger becomes java.util.logging.Logger. java.lang needs no import.
- Imports of your own modules point at the classes Pyrite generates: a class is nested in its
module's class (
app.Models.Order), and a function or constant becomes a static import.
A class used without a Python import (from a type hint, say) is found too.
- The Lombok annotations Pyrite writes (
@Getter, @Data, ...) import lombok.*.
- In
hybrid mode, only the names left over go to the local model, in one request per file (cached like the
function rewrites). Its answer is checked: a JDK class must exist in the JDK, and an import
must be for a name the code really uses; anything else is dropped with a warning. A
library class it suggests is kept and marked // suggested by <model>; not in the JDK, needs this library on the classpath.
Python imports with no Java counterpart stay as // python: ... comments (so Ctrl+Alt+J
still jumps to them), and names nobody could resolve are listed in a // No Java import found for: ... comment. Steps 1-3 need no model, so they run in rules mode and when Ollama is not
running. Turn it off with pyrite.resolveImports: false (CLI: --no-java-imports) to keep
the Python-shaped lines, which read like the Python but are not Java.
The rest of the view is still a reading view (logging.getLogger(__name__) is not Java), so
the imports are what is real today, not the whole file.
Runtime library
Some Python has no one-line Java equivalent: truthiness, slices with negative or
out-of-range bounds, tuples, re with its Python syntax and results. The views call a small
Java library for those instead of guessing, and Pyrite writes its sources into the view
project (src/main/java/pyrite/runtime/, imported like any other class):
| Class |
For |
Py |
Py.truthy(x), Py.slice(xs, lo, hi[, step]), Py.at(xs, -2), Py.len, Py.repr, Python's str methods (split, strip(chars), partition, splitlines, isdigit, ...) and pop / remove; NotImplemented, Ellipsis |
Tuple |
Tuple.of(a, b), Tuple.zip(...), Tuple.enumerate(...): an immutable List that prints and compares like Python |
Os, Os.path, Sys, Re, Time, Warnings |
the commonly used part of os, os.path, sys, re (Python pattern syntax, match anchored at the start, findall returning groups, sub with \1 or a function), time, warnings (with the warning categories as classes) |
@Property, @PropertySetter |
mark methods that were a Python @property / setter |
Only the module members the library implements are rewritten (os.path.join ->
Os.path.join); anything else keeps its Python spelling (os.stat(p)), so what is missing
stays visible. math maps to java.lang.Math.
Running a local LLM with Ollama
The default rules engine needs nothing. The optional hybrid engine adds a small
model running on your own machine, used only for the functions the rules cannot
translate (nested comprehensions, assignment expressions :=, and anything the rules
flag with a warning or an untranslated marker). Generators, async/await, lambdas
and the rest stay rule-based. If Ollama is off or fails, Pyrite keeps the rules output
for that function and adds a warning, so it never blocks you.
1. Install Ollama (ollama.com/download)
# Linux
curl -fsSL https://ollama.com/install.sh | sh
# macOS
brew install ollama # or download the app
# Windows: download the installer from ollama.com/download
On Linux the installer sets up a background service on http://localhost:11434. On macOS
and Windows the app starts the server; otherwise run ollama serve.
2. Download a model. Pick a small code model; a bigger one is slower:
ollama pull qwen2.5-coder:7b # Pyrite's default; good at code, ~4.7 GB
ollama pull qwen2.5-coder:3b # smaller and faster, for weaker machines
ollama list # shows the exact names you can use
3. Check it works
curl http://localhost:11434/api/tags # should list your models
ollama run qwen2.5-coder:7b "say hi"
4. Turn it on in VS Code (settings.json):
"pyrite.engine": "hybrid",
"pyrite.ollama.model": "qwen2.5-coder:7b"
pyrite.ollama.model must match a name from ollama list exactly, tag included. Then run
Pyrite: Generate Java View for Workspace. A function rewritten by the model has a
// Rewritten by <model> (<reason>); check against the Python. line above it, and the
answers are cached in .java-view/.pyrite/ollama-cache.json, so only changed functions
are sent again. From the command line:
node out/cli.js my-project --engine hybrid --ollama-model qwen2.5-coder:7b
Troubleshooting
| Symptom |
Cause and fix |
404 on /api/chat, or a warning that the model "is not installed" |
The model in pyrite.ollama.model is not pulled. Run ollama pull <name> or set the setting to a name from ollama list. |
| Warning "Could not reach Ollama" |
The server is not running, or pyrite.ollama.url is wrong. Start it with ollama serve. |
| Warning "did not answer within ...s" |
Too slow. On a CPU-only machine the first request also loads the model and can take minutes. Use a smaller model, raise pyrite.ollama.timeoutSeconds, or lower pyrite.ollama.maxFunctionsPerFile. |
| Where are Ollama's logs? |
Linux service: journalctl -u ollama -f. Also ollama ps shows what is loaded and whether it runs on CPU or GPU. |
Every warning is listed in the Output panel: click the warning count in the status bar report.
Testing
npm test runs the unit tests, a snapshot of the sample project
(src/test/snapshots/, refresh with UPDATE_SNAPSHOTS=1 npm test after an intended
change) and a corpus run over the first 300 files of the local Python standard
library: the engine must not throw, must emit balanced braces, must stay fast, and
every class and function the real parser sees must come out as a symbol. Point it
elsewhere with PYRITE_CORPUS=dir1:dir2, widen it with PYRITE_CORPUS_ALL=1. CI runs
the full standard library on every push to main.
Getting started (development)
On a fresh Debian/Ubuntu machine, scripts/setup-env.sh installs Node.js and npm through
apt (adding the NodeSource repository when apt's Node is older than 20) and runs
npm install. scripts/build-and-install.sh builds a .vsix, installs Node 20 through
nvm if it is missing, and installs the extension into your local VS Code.
git clone https://github.com/danielonet/pyrite.git && cd pyrite
npm install
npm run compile
npm test # unit tests (node --test)
npm run cli -- sample-python-project # writes sample-python-project/.java-view/
Open this folder in VS Code and press F5 to launch an Extension Development
Host; open sample-python-project there and run Pyrite: Generate Java View for Workspace.
To build an installable package: npx @vscode/vsce package (produces a .vsix).
To publish a release to the Visual Studio Marketplace: npm run publish:marketplace
(runs scripts/publish-marketplace.sh - builds, tests,
packages and publishes). It reads the publisher id and Marketplace personal access token
from a plain text file outside this repo (~/.config/pyrite/marketplace.env by default,
never committed); the script prints a template and instructions the first time it's run.
Use --dry-run to build and package without publishing.
Settings
| Setting |
Default |
Meaning |
pyrite.engine |
rules |
work mode: rules, or hybrid (rules + a local LLM for hard functions, see Work modes) |
pyrite.ollama.url |
http://localhost:11434 |
Ollama server used by the hybrid engine |
pyrite.ollama.model |
qwen2.5-coder:7b |
model name, exactly as ollama list shows it |
pyrite.ollama.timeoutSeconds |
300 |
give up on one request after this long and keep the rules output |
pyrite.ollama.maxFunctionsPerFile |
10 |
at most this many functions per file go to the model |
pyrite.resolveImports |
true |
replace Python's imports with real Java ones, in both modes (see Real Java imports) |
pyrite.outputFolder |
.java-view |
where the mirror is written (relative to the workspace root), as src/main/java and src/test/java |
pyrite.basePackage |
(empty) |
package every view starts with, e.g. com.acme.shop; empty uses the workspace folder's name (my-project -> my_project) |
pyrite.basePackagePrefix |
(empty) |
package put in front of the project's name: com.acme + folder my-project -> com.acme.my_project |
pyrite.exclude |
venv, node_modules, ... |
glob patterns to skip |
pyrite.watch |
true |
re-translate on save |
pyrite.javadoc |
docstringOnly |
Javadoc generation: always (every class/method, extrapolated when there's no docstring), docstringOnly (only where a docstring exists), or none (no Javadoc; docstrings kept as plain comments) |
pyrite.javadocTestCode |
false |
Document test code too, using the same pyrite.javadoc rules as production code. When unchecked, test files (test_*.py, *_test.py, conftest.py, or anything under a test/tests folder) never get Javadoc |
pyrite.lombok |
true |
Use Lombok-style annotations instead of spelling out boilerplate: a plain self.x = x __init__ becomes @AllArgsConstructor, a trivial __str__/__repr__ becomes @ToString, a trivial __eq__/__hash__ becomes @EqualsAndHashCode, a @property/@x.setter pair that just wraps a field becomes @Getter/@Setter on that field, and a dataclass-like class becomes @Data. Methods that don't match these simple shapes are left spelled out |
pyrite.invalidJava |
stub |
Every view is parsed as Java. With stub, each generated statement that is not valid Java is replaced by if (true) throw new UnsupportedOperationException("TODO(pyrite): <python line>");, with the original text kept as a comment, so one bad statement cannot break the rest of the file (search the view for TODO(pyrite)). keep leaves the text alone and only counts those statements in the view's warnings |
pyrite.lineWidth |
120 |
Maximum line length of the generated Java. Longer lines wrap like IntelliJ IDEA and palantir-java-format by default: a signature or call that does not fit gets one parameter per line with an 8-space continuation indent, conditions break before &&/\|\|, concatenation before +, call chains before each .call(). Long string literals are never split. 0 turns wrapping off |
The generated folder contains its own .gitignore so it is never committed.
Roadmap
- Edit the Java view and sync back to Python is not implemented yet. The intended
design (function-level, approval-gated, LLM-translated with the original Python as
context) and its open questions are in architecture/phase-2.md.
- A built-in LLM server is not planned for now: the model runs in your own Ollama.
Limitations
- The rules engine is line-oriented. Deeply nested comprehensions, exotic
decorators, metaclasses and dynamic attribute tricks are kept verbatim with a comment
(nested comprehensions and
:= can be rewritten by the hybrid engine's local model).
- Types are inferred from hints, defaults and simple literals only; unknown types show as
Object/var.
| |