Skip to content

fix: don't transform main guard inside strings/comments in convert#10237

Draft
vidigoat wants to merge 1 commit into
marimo-team:mainfrom
vidigoat:fix/convert-main-guard-in-string
Draft

fix: don't transform main guard inside strings/comments in convert#10237
vidigoat wants to merge 1 commit into
marimo-team:mainfrom
vidigoat:fix/convert-main-guard-in-string

Conversation

@vidigoat

Copy link
Copy Markdown

This pull request was authored by a coding agent.

📝 Summary

marimo convert corrupts valid Python scripts when the text if __name__ == "__main__": appears inside a string literal or a comment.

_transform_main_blocks located the guard with a naive cell.code.split('if __name__ == "__main__":', 1), so any occurrence of that text — even inside a string or comment — was treated as a real guard. The cell was sliced mid-token and rewritten to def _main_(): …, producing syntactically invalid code that is then emitted as an app._unparsable_cell(...) blob.

This affects marimo convert script.py, marimo edit on a non-marimo .py file, and the server template API — both the plain-script and jupytext/pypercent paths.

🔍 Reproduction (before this PR)

from marimo._convert.converters import MarimoConvert

# valid Python — the guard text is just a string
src = 'GUARD = \'if __name__ == "__main__":\'\nprint(GUARD)\n'
print(MarimoConvert.from_non_marimo_python_script(src, aggressive=False).to_py())

Output on main slices the string literal and emits a mangled app._unparsable_cell(...). The same happens for a comment that mentions the guard.

🔧 Fix

Detect the guard via the AST: find a top-level ast.If whose test is __name__ == "__main__" (either operand order) with no else, and rewrite only that statement using line/column offsets. Cells that don't parse are left untouched (they're emitted as unparsable cells downstream anyway).

As a bonus this also fixes two cases that were silently not transformed before: the single-quoted form (if __name__ == '__main__':) and the reversed comparison (if "__main__" == __name__:).

✅ Tests

  • Added TestTransformMainBlocks (6 cases: guard-in-string, guard-in-comment, single quotes, reversed comparison, and that a real guard still transforms).
  • uv run --python 3.13 --group test pytest tests/_convert tests/_cli/test_cli_convert.py363 passed, 2 skipped (env), 1 xfail (pre-existing, unrelated).
  • ruff check, ruff format --check, and mypy all clean on the changed files.

📋 Checklist

  • I have read the CLA
  • For new features, I have added tests
  • I have run the tests and they pass

marimo convert located the `if __name__ == "__main__":` block with a
naive string search, so the text appearing inside a string literal or
comment was treated as a real guard. The cell was then sliced mid-token
and rewritten, producing invalid code that was emitted as an unparsable
cell.

Detect the guard via the AST instead: find a top-level `if` whose test
is `__name__ == "__main__"` (in either order) with no `else`, and rewrite
only that. Cells that don't parse are left untouched. This also fixes
the single-quoted and reversed-comparison forms, which were silently
not transformed before.
@vercel

vercel Bot commented Jul 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
marimo-docs Ready Ready Preview, Comment Jul 19, 2026 7:30am

Request Review

@github-actions

github-actions Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@vidigoat

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

@Light2Dark
Light2Dark requested a review from dmadisetti July 20, 2026 04:35
Comment on lines +157 to +179
# Absolute offset of each line start (ast linenos are 1-based and
# count physical "\n"-separated lines).
line_starts = [0]
for line in cell.code.split("\n"):
line_starts.append(line_starts[-1] + len(line) + 1)

start = line_starts[guard.lineno - 1] + guard.col_offset
test_end_lineno = guard.test.end_lineno or guard.lineno
test_end_col = guard.test.end_col_offset or 0
test_end = line_starts[test_end_lineno - 1] + test_end_col
# The colon that closes the `if` header is the first ":" after the
# end of the test expression (comments cannot appear before it).
colon = cell.code.index(":", test_end)

before_main = cell.code[:start].strip()

# replace the if __name__ == "__main__": with def _main_():
main_block = "def _main_():" + cell.code[colon + 1 :] + "\n\n_main_()"

if before_main:
cell.code = before_main + "\n\n" + main_block
else:
cell.code = main_block

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think an AST transform may be better than this. I'm wondering if replacing with def _main_ makes sense either.

I reckon if mo.app_meta().mode == "script" almost makes more sense?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @manzt who last touched this

@vidigoat

Copy link
Copy Markdown
Author

Thanks @dmadisetti! Two things:

On the AST point — the PR does use AST to find the guard (that's the actual fix here; the old code string-matched if __name__ == "__main__": and mangled it whenever that text showed up inside a string literal or comment). I deliberately kept the replacement as a string splice rather than ast.unparse, because unparsing the whole cell would drop comments and reformat the user's code — which seems bad for a convert tool that should round-trip their source as faithfully as possible. Happy to go fully-AST if you'd prefer, though.

On def _main_() vs mo.app_meta().mode == "script" — I think you're right that the latter is a better fit: it preserves the original "only run when executed directly" semantic instead of wrapping the body in a function and calling it. I kept def _main_() for now, but I'm glad to rework toward if mo.app_meta().mode == "script": if that's the direction you and @manzt want.

It's still a draft, so happy to settle the approach first before polishing.

@dmadisetti

Copy link
Copy Markdown
Member

unparsing the whole cell would drop comments and reformat the user's code

Agreed here. Cool I think this makes sense

@vidigoat

Copy link
Copy Markdown
Author

Thanks @dmadisetti! Glad the string-splice reasoning makes sense. The one open question is the target: keep def _main_(): ... _main_(), or switch the guard body to run under if mo.app_meta().mode == "script": as you suggested (which arguably preserves the original "only when run as a script" semantic more faithfully). I'm happy to go either way — just let me know which you and @manzt prefer and I'll finalize and take it out of draft.

Bartok9 added a commit to Bartok9/marimo that referenced this pull request Jul 22, 2026
Avoids mangling cells when the guard text appears inside a string or
comment. Also handles single-quoted and reversed comparisons.

Based on work by @vidigoat in marimo-team#10237.

Co-authored-by: Vidit Patankar <vidit.patankar16@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants