Skip to content

Commit e62b4a8

Browse files
evhubclaude
andcommitted
Add final variables enforcement in --pure mode
All variables are now implicitly final in --pure mode, disallowing reassignment. Use escape syntax (\name = ...) to bypass when needed. Also refactors add_to_parsing_context to support multiple contexts. Resolves #885 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9914407 commit e62b4a8

File tree

5 files changed

+136
-58
lines changed

5 files changed

+136
-58
lines changed

DOCS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -359,8 +359,8 @@ Note that many of the above style issues will still show a warning if `--strict`
359359
If the `--pure` flag is enabled, Coconut will enforce functional programming norms on the code being compiled. These checks are compatible with `--strict` mode—when both are enabled, violations become errors instead of warnings. Like `--strict`, these checks can usually be disabled on a line-by-line basis by adding `# NOQA` or `# noqa` comments.
360360

361361
The following are disallowed in `--pure` mode:
362-
- `global` statements
363-
- `nonlocal` statements
362+
- variable reassignment (all variables are implicitly [`final`](#final))
363+
- `global` and `nonlocal` statements
364364

365365
#### Backports
366366

@@ -1662,7 +1662,7 @@ GREETING = "Hello"
16621662
def calculate_area(radius):
16631663
return PI * radius ** 2
16641664

1665-
# No compile-time enforcement - reassignment would be allowed
1665+
PI = 3.14 # No compile-time enforcement - reassignment is allowed
16661666
```
16671667

16681668

coconut/compiler/compiler.py

Lines changed: 100 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,14 @@ def genhash(self, code, package_level=-1):
589589
temp_var_counts = None
590590
operators = None
591591

592+
def init_parsing_context(self):
593+
"""Initialize parsing context."""
594+
self.parsing_context = defaultdict(list)
595+
# initialize module-level scopes
596+
self.parsing_context["final_vars"].append({})
597+
self.parsing_context["pure_vars"].append({})
598+
return self.parsing_context
599+
592600
def reset(self, keep_state=False, filename=None):
593601
"""Reset references.
594602
@@ -607,8 +615,6 @@ def reset(self, keep_state=False, filename=None):
607615
self.temp_var_counts = defaultdict(int)
608616
# but always overwrite temp_vars_by_key since they store locs that will be invalidated
609617
self.temp_vars_by_key = {}
610-
self.parsing_context = defaultdict(list)
611-
self.parsing_context["final_vars"].append({}) # initialize module-level final scope
612618
self.name_info = defaultdict(lambda: {"imported": set(), "referenced": set(), "assigned": set()})
613619
self.star_import = False
614620
self.kept_lines = []
@@ -625,6 +631,7 @@ def reset(self, keep_state=False, filename=None):
625631
self.shown_warnings = set()
626632
if not keep_state:
627633
self.computation_graph_caches = defaultdict(staledict)
634+
self.init_parsing_context()
628635

629636
@contextmanager
630637
def inner_environment(self, ln=None):
@@ -638,12 +645,12 @@ def inner_environment(self, ln=None):
638645
wrapped_type_ignore, self.wrapped_type_ignore = self.wrapped_type_ignore, None
639646
skips, self.skips = self.skips, []
640647
docstring, self.docstring = self.docstring, ""
641-
parsing_context, self.parsing_context = self.parsing_context, defaultdict(list)
642-
self.parsing_context["final_vars"].append({}) # initialize module-level final scope
643648
kept_lines, self.kept_lines = self.kept_lines, []
644649
num_lines, self.num_lines = self.num_lines, 0
645650
remaining_original, self.remaining_original = self.remaining_original, None
646651
shown_warnings, self.shown_warnings = self.shown_warnings, set()
652+
parsing_context = self.parsing_context
653+
self.init_parsing_context()
647654
try:
648655
with ComputationNode.using_overrides():
649656
yield
@@ -655,11 +662,11 @@ def inner_environment(self, ln=None):
655662
self.wrapped_type_ignore = wrapped_type_ignore
656663
self.skips = skips
657664
self.docstring = docstring
658-
self.parsing_context = parsing_context
659665
self.kept_lines = kept_lines
660666
self.num_lines = num_lines
661667
self.remaining_original = remaining_original
662668
self.shown_warnings = shown_warnings
669+
self.parsing_context = parsing_context
663670

664671
@contextmanager
665672
def disable_checks(self):
@@ -1084,10 +1091,10 @@ def strict_err_or_warn(self, msg, original, loc, noqa_able=False, **kwargs):
10841091
else:
10851092
self.syntax_warning(msg, original, loc, **kwargs)
10861093

1087-
def pure_error(self, msg, original, loc, noqa_able=True, **kwargs):
1094+
def pure_error(self, msg, original, loc, **kwargs):
10881095
"""If in pure mode, raise an error or warn depending on strict mode."""
10891096
if self.pure:
1090-
return self.strict_err_or_warn(msg, original, loc, noqa_able=noqa_able, pure_err=True, **kwargs)
1097+
return self.strict_err_or_warn(msg, original, loc, pure_err=True, **kwargs)
10911098

10921099
@contextmanager
10931100
def complain_on_err(self):
@@ -5047,16 +5054,20 @@ def current_parsing_context(self, name, default=None):
50475054
return default
50485055

50495056
@contextmanager
5050-
def add_to_parsing_context(self, name, obj, callbacks_key=None):
5051-
"""Put the given object on the parsing context stack for the given name."""
5052-
self.parsing_context[name].append(obj)
5057+
def add_to_parsing_context(self, name_obj_dict, callbacks_keys_dict=None):
5058+
"""Put all the given objects on their respective parsing context stacks."""
5059+
for name, obj in name_obj_dict.items():
5060+
self.parsing_context[name].append(obj)
50535061
try:
50545062
yield
50555063
finally:
5056-
popped_ctx = self.parsing_context[name].pop()
5057-
if callbacks_key is not None:
5058-
for callback in popped_ctx[callbacks_key]:
5059-
callback()
5064+
for name in name_obj_dict:
5065+
popped_ctx = self.parsing_context[name].pop()
5066+
if callbacks_keys_dict is not None:
5067+
callbacks_key = callbacks_keys_dict.get(name)
5068+
if callbacks_key is not None:
5069+
for callback in popped_ctx[callbacks_key]:
5070+
callback()
50605071

50615072
def funcname_typeparams_handle(self, tokens):
50625073
"""Handle function names with type parameters."""
@@ -5183,10 +5194,12 @@ def get_generic_for_typevars(self):
51835194
def type_alias_stmt_manage(self, original=None, loc=None, item=None):
51845195
"""Manage the typevars parsing context."""
51855196
prev_typevar_info = self.current_parsing_context("typevars")
5186-
with self.add_to_parsing_context("typevars", {
5187-
"all_typevars": {} if prev_typevar_info is None else prev_typevar_info["all_typevars"].copy(),
5188-
"new_typevars": [],
5189-
"typevar_locs": {},
5197+
with self.add_to_parsing_context({
5198+
"typevars": {
5199+
"all_typevars": {} if prev_typevar_info is None else prev_typevar_info["all_typevars"].copy(),
5200+
"new_typevars": [],
5201+
"typevar_locs": {},
5202+
},
51905203
}):
51915204
yield
51925205

@@ -5220,8 +5233,10 @@ def where_item_handle(self, tokens):
52205233
@contextmanager
52215234
def where_stmt_manage(self, original, loc, item):
52225235
"""Manage where statements."""
5223-
with self.add_to_parsing_context("where", {
5224-
"assigns": None,
5236+
with self.add_to_parsing_context({
5237+
"where": {
5238+
"assigns": None,
5239+
},
52255240
}):
52265241
yield
52275242

@@ -5277,7 +5292,10 @@ def class_manage(self, original, loc, item):
52775292
try:
52785293
# handles support for class type variables
52795294
with self.type_alias_stmt_manage():
5280-
with self.add_to_parsing_context("final_vars", {}):
5295+
with self.add_to_parsing_context({
5296+
"final_vars": {},
5297+
"pure_vars": {},
5298+
}):
52815299
yield
52825300
finally:
52835301
cls_stack.pop()
@@ -5291,7 +5309,10 @@ def func_manage(self, original, loc, item):
52915309
try:
52925310
# handles support for function type variables
52935311
with self.type_alias_stmt_manage():
5294-
with self.add_to_parsing_context("final_vars", {}):
5312+
with self.add_to_parsing_context({
5313+
"final_vars": {},
5314+
"pure_vars": {},
5315+
}):
52955316
yield
52965317
finally:
52975318
if cls_context is not None:
@@ -5307,14 +5328,13 @@ def in_method(self):
53075328
def has_expr_setname_manage(self, original, loc, item):
53085329
"""Handle parses that can assign expr_setname."""
53095330
with self.add_to_parsing_context(
5310-
"expr_setnames",
5311-
{
5331+
{"expr_setnames": {
53125332
"parent": self.current_parsing_context("expr_setnames"),
53135333
"new_names": set(),
53145334
"callbacks": [],
53155335
"loc": loc,
5316-
},
5317-
callbacks_key="callbacks",
5336+
}},
5337+
callbacks_keys_dict={"expr_setnames": "callbacks"},
53185338
):
53195339
yield
53205340

@@ -5391,6 +5411,55 @@ def name_handle(self, original, loc, tokens, assign=False, classname=False, expr
53915411
is_new = loc not in self.name_info[name]["referenced"]
53925412
self.name_info[name]["referenced"].add(loc)
53935413

5414+
# final variable checking (setting final_vars happens at the end)
5415+
final_vars = self.current_parsing_context("final_vars")
5416+
self.internal_assert(final_vars is not None, original, loc, "no final_vars context")
5417+
if (
5418+
assign
5419+
and not escaped
5420+
# at least on py3, expr_setnames shadow rather than overwrite, so we allow them
5421+
and not expr_setname
5422+
and name in final_vars
5423+
and final_vars[name] != loc # allow reassign in same loc (speculative parsing duplicate)
5424+
):
5425+
return local_raise_or_wrap_error(
5426+
CoconutSyntaxError,
5427+
"disallowed reassignment of final variable '{name}'".format(name=name),
5428+
original,
5429+
loc,
5430+
extra="use explicit '\\{name}' syntax to bypass final checking".format(name=name),
5431+
)
5432+
5433+
safe_to_show_warnings = (
5434+
# in strict mode, errors are wrapped and we should always do that
5435+
self.strict
5436+
# in non-strict mode, only check when using computation graph
5437+
# and not for greedy handlers (to avoid spurious warnings)
5438+
# and only if it's a new assignment (to avoid duplicate warnings)
5439+
or (is_new and not is_greedy and USE_COMPUTATION_GRAPH)
5440+
)
5441+
5442+
# pure variable checking (setting pure_vars happens at the end)
5443+
if self.pure:
5444+
pure_vars = self.current_parsing_context("pure_vars")
5445+
self.internal_assert(pure_vars is not None, original, loc, "no pure_vars context")
5446+
if (
5447+
assign
5448+
and not escaped
5449+
and not expr_setname
5450+
and safe_to_show_warnings
5451+
and name in pure_vars
5452+
and pure_vars[name] != loc # allow reassign in same loc (speculative parsing duplicate)
5453+
):
5454+
err = self.pure_error(
5455+
"disallowed reassignment of variable '{name}' (all variable reassignment is prohibited in --pure mode; try reworking to be more functional or bypass with explicit '\\{name}' syntax if necessary)".format(name=name),
5456+
original,
5457+
loc,
5458+
raise_err_func=local_raise_or_wrap_error,
5459+
)
5460+
if err is not None:
5461+
return err
5462+
53945463
is_class_attr = (
53955464
self.current_parsing_context("class")
53965465
and not self.in_method
@@ -5418,14 +5487,7 @@ def name_handle(self, original, loc, tokens, assign=False, classname=False, expr
54185487
# case we can't be sure this is actually shadowing a builtin;
54195488
# BUT if we're on strict mode, then it's an actual error, rather
54205489
# than a warning, which means we can just wrap it
5421-
and (
5422-
# in strict mode, errors are wrapped and we should always do that
5423-
self.strict
5424-
# in non-strict mode, only check when using computation graph
5425-
# and not for greedy handlers (to avoid spurious warnings)
5426-
# and only if it's a new assignment (to avoid duplicate warnings)
5427-
or (is_new and not is_greedy and USE_COMPUTATION_GRAPH)
5428-
)
5490+
and safe_to_show_warnings
54295491
):
54305492
if name in all_builtins:
54315493
err = self.strict_err_or_warn(
@@ -5449,26 +5511,11 @@ def name_handle(self, original, loc, tokens, assign=False, classname=False, expr
54495511
if err is not None:
54505512
return err
54515513

5452-
# final variable checking
5453-
final_vars = self.current_parsing_context("final_vars")
5454-
self.internal_assert(final_vars is not None, original, loc, "no final_vars context")
5455-
if (
5456-
assign
5457-
and not escaped
5458-
and not expr_setname
5459-
and name in final_vars
5460-
and final_vars[name] != loc # allow reassign in same loc (speculative parsing duplicate)
5461-
):
5462-
return local_raise_or_wrap_error(
5463-
CoconutSyntaxError,
5464-
"cannot reassign final variable '{name}'".format(name=name),
5465-
original,
5466-
loc,
5467-
extra="use explicit '\\{name}' syntax to bypass final checking".format(name=name),
5468-
)
54695514
# only mark as final after all checks pass
54705515
if is_final:
54715516
final_vars[name] = loc
5517+
if self.pure:
5518+
pure_vars[name] = loc
54725519

54735520
if name == "exec":
54745521
if self.target.startswith("3"):
@@ -5575,13 +5622,13 @@ def check_py(self, version, name, original, loc, tokens):
55755622

55765623
def global_check(self, original, loc, tokens):
55775624
"""Check for global statement in --pure mode."""
5578-
self.pure_error("global statements are disabled in --pure mode", original, loc)
5625+
self.pure_error("global statements are disabled in --pure mode", original, loc, noqa_able=True)
55795626
global_stmt, = tokens
55805627
return global_stmt
55815628

55825629
def nonlocal_check(self, original, loc, tokens):
55835630
"""Check for Python 3 nonlocal statement."""
5584-
self.pure_error("nonlocal statements are disabled in --pure mode", original, loc)
5631+
self.pure_error("nonlocal statements are disabled in --pure mode", original, loc, noqa_able=True)
55855632
return self.check_py("3", "nonlocal statement", original, loc, tokens)
55865633

55875634
def star_assign_item_check(self, original, loc, tokens):

coconut/compiler/util.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ def evaluate_tokens(tokens, **kwargs):
255255
if isinstance(tokens, (str, bool)) or tokens is None:
256256
return tokens
257257

258-
elif isinstance(tokens, ComputationNode):
258+
elif isinstance(tokens, (ComputationNode, SideEffectNode)):
259259
result = tokens.evaluate()
260260
if is_final and isinstance(result, ExceptionNode):
261261
result.evaluate()
@@ -446,6 +446,20 @@ def evaluate(self):
446446
return unpack(self.tokens)
447447

448448

449+
class SideEffectNode(object):
450+
"""A node in the computation graph that performs a side effect when evaluated."""
451+
__slots__ = ("result", "side_effect")
452+
453+
def __init__(self, result, side_effect):
454+
self.result = result
455+
self.side_effect = side_effect
456+
457+
def evaluate(self):
458+
"""Call the stored function to get the result."""
459+
self.side_effect()
460+
return self.result
461+
462+
449463
class ExceptionNode(object):
450464
"""A node in the computation graph that stores an exception that will be raised upon final evaluation."""
451465
__slots__ = ("exception_maker",)

coconut/root.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
VERSION = "3.2.0"
2727
VERSION_NAME = None
2828
# False for release, int >= 1 for develop
29-
DEVELOP = 3
29+
DEVELOP = 4
3030
ALPHA = False # for pre releases rather than post releases
3131

3232
assert DEVELOP is False or DEVELOP >= 1, "DEVELOP must be False or an int >= 1"

coconut/tests/src/extras.coco

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,23 @@ def f():
603603
nonlocal x # NOQA
604604
""".strip())
605605

606+
# test final variables in pure mode
607+
setup(line_numbers=False, strict=True, pure=True, target="sys")
608+
assert_raises(-> parse("""
609+
x = 1
610+
x = 2
611+
""".strip()), CoconutStyleError, err_has="pure")
612+
assert_raises(-> parse("""
613+
def f():
614+
y = 1
615+
y = 2
616+
""".strip()), CoconutStyleError, err_has="pure")
617+
# escape syntax should allow reassignment
618+
parse(r"""
619+
x = 1
620+
\x = 2
621+
""".strip())
622+
606623
setup(line_numbers=False, target="2.7")
607624
assert parse("from io import BytesIO", mode="lenient") == "from io import BytesIO"
608625
assert_raises(-> parse("def f(*, x=None) = x"), CoconutTargetError, err_has="\n ^")

0 commit comments

Comments
 (0)