Python on Rails
Four days ago, Railcar could transpile a Rails app to Crystal. Now it can also transpile to Python.
The same demo blog — articles, comments, nested resources, validations, Turbo Streams — generates a working Python web application. 21 tests pass. The app serves styled pages with Tailwind CSS, handles real-time updates via ActionCable WebSockets, and runs on aiohttp.
What Changed
The original Crystal pipeline parses Rails source with Prism, transforms the AST through composable filters, and emits Crystal. The Python pipeline shares the same parse and filter stages but diverges at emission:
Rails source
|
v
Prism parser -> Crystal AST
|
v
Shared filters (9 transformers)
|
+--> Crystal filters --> Crystal source
|
+--> Python filters --> Cr2Py emitter --> PyAST --> Python source
The Python path adds a second compilation stage: Crystal's program.semantic() runs type inference on the translated AST, providing the emitter with type information for property-vs-method detection. The result passes through a Python-specific AST (PyAST) with structural indentation, then through post-emission filters for implicit returns, async/await, and Python dunder methods.
Idiomatic Python, Not Syntax Swapping
The goal isn't to transliterate Ruby into Python. It's to produce the Python that a Python developer would write by hand. The emitter maps Ruby and Crystal idioms to their Python equivalents:
Type hints from Crystal's type system. Crystal infers types for every expression. The emitter carries these through to Python annotations — return types on functions, parameter types on helpers:
async def index(request) -> web.Response:
articles = Article.all()
return web.Response(text=layout(render_index(articles=articles)), content_type="text/html")
def comments(self) -> CollectionProxy:
return CollectionProxy(self, "article_id", "Comment")
List comprehensions from blocks. Ruby's .map and .reject with blocks become Python list comprehensions, not loops:
# Crystal: cols.reject { |k| k == "id" }
[k for k in cols if not (k == "id")]
# Crystal: rows.map { |row| from_row(row) }
[from_row(row) for row in rows]
f-strings from string interpolation. Crystal's "hello #{name}" becomes f"hello {name}", including triple-quoted f-strings for multiline view templates.
async/await. Controller actions are async def with await on HTTP calls — not just syntactic sugar, but genuine async I/O on aiohttp.
Direct attribute access. article.title, not article.title(). The runtime uses COLUMNS + setattr so model columns are real Python attributes.
Dunder methods. initialize → __init__, [] → __getitem__, []= → __setitem__. The dunder filter auto-generates __bool__ and __len__ from Crystal's any? and size methods.
isinstance, is None, @classmethod, @property. .is_a?(Type) → isinstance(obj, Type). .nil? → is None. Class methods get @classmethod with cls. The errors accessor is @property.
pytest from Minitest. test "creates article" becomes def test_creates_article. assert_equal becomes assert ==. Integration tests use pytest-asyncio with aiohttp's test client.
Design Decisions
Hand-written runtime. The Python runtime (ApplicationRecord, ValidationErrors, CollectionProxy) is hand-written Python, not transpiled from Crystal. The Crystal runtime source exists only as scaffolding for program.semantic() — it provides types so the compiler can resolve method calls, but it's never emitted. Each target language gets an idiomatic runtime.
Shared filter chain. Nine Crystal AST filters are shared between targets: InstanceVarToLocal, ParamsExpect, RespondToHTML, StrongParams, RailsHelpers, LinkToPathHelper, ButtonToPathHelper, RenderToPartial, BroadcastsTo. These handle Rails-specific normalization and are target-neutral. Adding a third target (TypeScript?) would reuse them unchanged.
Shared metadata extraction. Both pipelines consume the same AppModel — schemas, models, controllers, routes, fixtures — extracted once from the Rails source. The ControllerExtractor, Rails DSL detection, and Inflector are shared across all targets.
The Numbers
| Crystal | Python | |
|---|---|---|
| Specs/tests | 321 | 21 |
| Model tests | yes | 9 passing |
| Controller tests | yes | 12 passing |
| Async | Crystal fibers | aiohttp async/await |
| ORM | Macro-based, typed | COLUMNS + setattr |
| Views | ECR templates | Python string functions |
| Turbo Streams | ActionCable | ActionCable (WebSocket) |
| CSS | Tailwind CLI | Tailwind CLI |
| Test framework | Crystal Spec | pytest + pytest-asyncio |
The Honest Assessment
All 21 tests pass on the demo blog. Here's what that doesn't tell you.
One app. The blog exercises common Rails patterns — CRUD, nested resources, validations, form helpers — but nothing exotic. No mailer, no background jobs, no polymorphic associations, no complex form builders. The second app will be the real test.
Hand-written where transpilation failed. The runtime, route helpers, form helpers, view helpers (link_to, button_to, truncate), and seed data are hand-written Python, not transpiled from Crystal. In each case, the transpiled version had bugs — Crystal's text[0, n] slice syntax isn't valid Python, model.id came out as model.id(), button_to didn't handle form_class or data dicts. Each hand-written piece marks a place where the transpiler couldn't produce correct output on its own.
Property detection is heuristic. Crystal's type inference works for typed code, but views, tests, and helpers have untyped variables. The emitter falls back to model_columns (inferring Article from a variable named article) and a hardcoded KNOWN_PROPERTIES list. A variable named item that happens to hold an Article wouldn't be detected correctly.
Two runtimes, one truth. base.cr exists only so program.semantic() can type-check models. base_runtime.py is what actually runs. They have to stay roughly in sync but serve completely different purposes. Every model attribute added to one needs to be reflected in the other. This is a maintenance cost that scales with complexity.
Try It
Prerequisites: Crystal (>= 1.10.0), Ruby with the prism gem, SQLite3 headers, uv. For styled output: gem install tailwindcss-rails turbo-rails.
git clone https://github.com/rubys/railcar
cd railcar
make
make test
npx github:ruby2js/juntos --demo blog
Generate the Python blog:
./build/railcar --python blog python-blog
cd python-blog
uv run --extra test python -m pytest tests/ -v
uv run python app.py
Generate the Crystal blog:
./build/railcar blog crystal-blog
cd crystal-blog
shards install
crystal build src/app.cr -o blog
crystal spec
./blog
Source code. Architecture guide. MIT licensed.