Skip to content

Commit eeeb42c

Browse files
ambvclaude
andcommitted
Add perchance, a soft keyword for exception-tolerant expressions (PoC)
This introduces an expression-level fallback for exceptions — the thing `dict.get()` does for one specific case, generalized to any fallible expression: `port = int(os.environ["PORT"]) perchance 8080` evaluates the left operand and, if it raises an exception matching the guard (default: `Exception`, never `BaseException`), yields the fallback instead. An explicit guard is spelled `first = seq[0] perchance None from IndexError`, accepting anything an `except` clause accepts. The fallback and guard are lazily evaluated only when an exception occurs, so it short-circuits like `or` but over exceptions rather than falsiness, and chains left-associatively: `d["a"] perchance d["b"] perchance "default"`. This is PEP 463 (exception-catching expressions) with the syntactic objections fixed: no colon inside the expression, no parentheses required, and it reads as English at exactly the point where the program acknowledges chance. The killer use case is comprehensions, where a fallible step today forces a named helper function or an inner try statement: `[int(line) perchance None from ValueError for line in text.splitlines()]`. There is also a statement form, `perchance FileNotFoundError, PermissionError: os.unlink(tmp)`, which is sugar for `try:`/`except (...): pass` — the pattern the stdlib alone spells out ~1360 times. The exception list is mandatory: there is no bare `perchance:`, so swallowing everything requires writing `perchance Exception:` and owning it in review. Design decisions worth noting: the exception object is not bindable (no `as`) — if you need it, you need a try statement; this is the lambda principle, keeping the expression form too small to hide logic in. The default guard is loaded via a new `LOAD_COMMON_CONSTANT` slot (`CONSTANT_EXCEPTION`) rather than a name lookup, so a local `Exception = ...` cannot change what gets caught. A swallowed exception rides along as `__context__` if the fallback itself raises. Mixing with a conditional expression requires parentheses. `perchance` is a soft keyword like `match`: juxtaposed NAME NAME is a syntax error today, so no existing code changes meaning, and `perchance` remains usable as an identifier — `x = perchance perchance 0` parses and means what you'd hope. Implementation: new `Perchance(expr value, expr fallback, expr? guard)` node in Python.asdl; a left-recursive `perchance_expression` grammar rule next to `if_expression`; the statement form is desugared to `Try` directly in the parser action, so it needs no new statement node, symtable, or codegen support. Code generation mirrors `codegen_try_except` and uses the zero-cost exception tables: the happy path executes no extra instructions, with the handler reachable only through the exception table. Both forms are gated with `CHECK_VERSION(16)`. Known wart for a real version: in `raise f() perchance g() from cause`, the `from` binds to the perchance guard, not the raise cause — parenthesize to disambiguate; dedicated invalid-syntax rules with helpful errors are left for later, as are docs and tests. To sleep, perchance to dream. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4f3be1b commit eeeb42c

16 files changed

Lines changed: 2438 additions & 1799 deletions

Grammar/python.gram

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ compound_stmt[stmt_ty]:
143143
| &'try' try_stmt
144144
| &'while' while_stmt
145145
| match_stmt
146+
| perchance_stmt
146147

147148
# SIMPLE STATEMENTS
148149
# =================
@@ -481,6 +482,21 @@ finally_block[asdl_stmt_seq*]:
481482
| invalid_finally_stmt
482483
| 'finally' &&':' a=block { a }
483484

485+
# Perchance statement
486+
# -------------------
487+
# "perchance E1, E2: block" is sugar for "try: block / except (E1, E2): pass"
488+
# and is desugared to a Try node right here in the parser.
489+
490+
perchance_stmt[stmt_ty]:
491+
| "perchance" e=expressions ':' b=block {
492+
CHECK_VERSION(stmt_ty, 16, "Perchance statements are",
493+
_PyAST_Try(b,
494+
CHECK(asdl_excepthandler_seq*, _PyPegen_singleton_seq(p,
495+
CHECK(excepthandler_ty, _PyAST_ExceptHandler(e, NULL,
496+
CHECK(asdl_stmt_seq*, _PyPegen_singleton_seq(p,
497+
CHECK(stmt_ty, _PyAST_Pass(EXTRA)))), EXTRA)))),
498+
NULL, NULL, EXTRA)) }
499+
484500
# Match statement
485501
# ---------------
486502

@@ -719,12 +735,18 @@ expression[expr_ty] (memo):
719735
| invalid_expression
720736
| invalid_legacy_expression
721737
| if_expression
738+
| perchance_expression
722739
| disjunction
723740
| lambdef
724741

725742
if_expression[expr_ty]:
726743
| a=disjunction 'if' b=disjunction 'else' c=expression { _PyAST_IfExp(b, a, c, EXTRA) }
727744

745+
perchance_expression[expr_ty] (memo):
746+
| a=perchance_expression "perchance" f=disjunction g=['from' e=disjunction { e }] {
747+
CHECK_VERSION(expr_ty, 16, "Perchance expressions are", _PyAST_Perchance(a, f, g, EXTRA)) }
748+
| disjunction
749+
728750
yield_expr[expr_ty]:
729751
| 'yield' 'from' a=expression { _PyAST_YieldFrom(a, EXTRA) }
730752
| 'yield' a=[star_expressions] { _PyAST_Yield(a, EXTRA) }

Include/internal/pycore_ast.h

Lines changed: 17 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Include/internal/pycore_ast_state.h

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Include/internal/pycore_opcode_utils.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ extern "C" {
8282
#define CONSTANT_MINUS_ONE 11
8383
#define CONSTANT_BUILTIN_FROZENSET 12
8484
#define CONSTANT_EMPTY_TUPLE 13
85-
#define NUM_COMMON_CONSTANTS 14
85+
#define CONSTANT_EXCEPTION 14
86+
#define NUM_COMMON_CONSTANTS 15
8687

8788
/* Values used in the oparg for RESUME */
8889
#define RESUME_AT_FUNC_START 0

Lib/_ast_unparse.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,17 @@ def visit_IfExp(self, node):
772772
self.set_precedence(_Precedence.TEST, node.orelse)
773773
self.traverse(node.orelse)
774774

775+
def visit_Perchance(self, node):
776+
with self.require_parens(_Precedence.TEST, node):
777+
self.set_precedence(_Precedence.TEST.next(), node.value, node.fallback)
778+
self.traverse(node.value)
779+
self.write(" perchance ")
780+
self.traverse(node.fallback)
781+
if node.guard:
782+
self.set_precedence(_Precedence.TEST.next(), node.guard)
783+
self.write(" from ")
784+
self.traverse(node.guard)
785+
775786
def visit_Set(self, node):
776787
if node.elts:
777788
with self.delimit("{", "}"):

Lib/keyword.py

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Lib/opcode.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@
4444
builtins.set,
4545
# Append-only — must match CONSTANT_* in
4646
# Include/internal/pycore_opcode_utils.h.
47-
None, "", True, False, -1, builtins.frozenset, ()]
47+
None, "", True, False, -1, builtins.frozenset, (),
48+
builtins.Exception]
4849
_nb_ops = _opcode.get_nb_ops()
4950

5051
hascompare = [opmap["COMPARE_OP"]]

Parser/Python.asdl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ module Python
6363
| UnaryOp(unaryop op, expr operand)
6464
| Lambda(arguments args, expr body)
6565
| IfExp(expr test, expr body, expr orelse)
66+
| Perchance(expr value, expr fallback, expr? guard)
6667
| Dict(expr?* keys, expr* values)
6768
| Set(expr* elts)
6869
| ListComp(expr elt, comprehension* generators)

0 commit comments

Comments
 (0)