Skip to content

Commit e47fbf0

Browse files
evhubclaude
andcommitted
Add strict mode warning for shadowing imported names
Resolves #884 - Add style error when an imported name is redefined by a local assignment - Uses same conditions as builtin shadowing check for consistency - Can be suppressed with `# NOQA` or escaped with `\name` syntax - Properly handles nested classes: class attributes don't trigger the warning, but classes defined directly inside methods do Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 1db61ed commit e47fbf0

File tree

9 files changed

+237
-57
lines changed

9 files changed

+237
-57
lines changed

DOCS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ If the `--strict` (`-s` for short) flag is enabled, Coconut will perform additio
331331

332332
- disabling deprecated features (making them entirely unavailable to code compiled with `--strict`),
333333
- errors instead of warnings on unused imports (unless they have a `# NOQA` or `# noqa` comment),
334+
- errors instead of warnings on redefining previously imported names (unless a backslash is used to escape the imported name),
334335
- errors instead of warnings when overwriting built-ins (unless a backslash is used to escape the built-in name),
335336
- warning on missing `__init__.coco` files when compiling in `--package` mode,
336337
- throwing errors on various style problems (see list below).

coconut/compiler/compiler.py

Lines changed: 109 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@
206206
handle_and_manage,
207207
manage,
208208
sub_all,
209+
same_line,
209210
get_cache_items_for,
210211
clear_packrat_cache,
211212
add_packrat_cache_items,
@@ -604,8 +605,8 @@ def reset(self, keep_state=False, filename=None):
604605
# but always overwrite temp_vars_by_key since they store locs that will be invalidated
605606
self.temp_vars_by_key = {}
606607
self.parsing_context = defaultdict(list)
607-
self.parsing_context["final_vars"].append(set()) # initialize module-level final scope
608-
self.name_info = defaultdict(lambda: {"imported": [], "referenced": [], "assigned": []})
608+
self.parsing_context["final_vars"].append({}) # initialize module-level final scope
609+
self.name_info = defaultdict(lambda: {"imported": set(), "referenced": set(), "assigned": set()})
609610
self.star_import = False
610611
self.kept_lines = []
611612
self.num_lines = 0
@@ -635,7 +636,7 @@ def inner_environment(self, ln=None):
635636
skips, self.skips = self.skips, []
636637
docstring, self.docstring = self.docstring, ""
637638
parsing_context, self.parsing_context = self.parsing_context, defaultdict(list)
638-
self.parsing_context["final_vars"].append(set()) # initialize module-level final scope
639+
self.parsing_context["final_vars"].append({}) # initialize module-level final scope
639640
kept_lines, self.kept_lines = self.kept_lines, []
640641
num_lines, self.num_lines = self.num_lines, 0
641642
remaining_original, self.remaining_original = self.remaining_original, None
@@ -1051,19 +1052,28 @@ def strict_err_or_warn(self, *args, **kwargs):
10511052
"""Raises an error if in strict mode, otherwise raises a warning. Usage:
10521053
self.strict_err_or_warn(message, original, loc)
10531054
"""
1055+
raise_err_func = kwargs.pop("raise_err_func", None)
10541056
internal_assert("extra" not in kwargs, "cannot pass extra=... to strict_err_or_warn")
10551057
if self.strict:
10561058
kwargs["extra"] = "remove --strict to downgrade to a warning"
1057-
raise self.make_err(CoconutStyleError, *args, **kwargs)
1059+
if raise_err_func is None:
1060+
raise self.make_err(CoconutStyleError, *args, **kwargs)
1061+
else:
1062+
return raise_err_func(CoconutStyleError, *args, **kwargs)
10581063
else:
10591064
self.syntax_warning(*args, **kwargs)
10601065

10611066
def strict_qa_error(self, msg, original, loc, **kwargs):
1062-
"""Strict error or warn an error that should be disabled by a NOQA comment."""
1067+
"""Strict error or warn an error that should be disabled by a NOQA comment.
1068+
1069+
Use strict_qa_error when the error is non-greedy and the comment can be handled first;
1070+
use strict_err_or_warn when the error is greedy, which means the comment won't have
1071+
been recorded yet and so we won't be able to check if it contains NOQA.
1072+
"""
10631073
ln = self.adjust(lineno(loc, original))
10641074
comment = self.reformat(" ".join(self.comments[ln]), ignore_errors=True)
10651075
if not self.noqa_regex.search(comment):
1066-
self.strict_err_or_warn(
1076+
return self.strict_err_or_warn(
10671077
msg + " (add '# NOQA' to suppress)",
10681078
original,
10691079
loc,
@@ -1182,11 +1192,12 @@ def wrap_error(self, error_maker):
11821192

11831193
def raise_or_wrap_error(self, *args, **kwargs):
11841194
"""Raise or defer if USE_COMPUTATION_GRAPH else wrap."""
1195+
always_wrap = kwargs.pop("always_wrap", False)
11851196
error_maker = partial(self.make_err, *args, **kwargs)
11861197
if not USE_COMPUTATION_GRAPH:
11871198
return self.wrap_error(error_maker)
11881199
# differently-ordered any ofs can push these errors earlier than they should be, requiring us to defer them
1189-
elif use_adaptive_any_of or reverse_any_of:
1200+
elif always_wrap or use_adaptive_any_of or reverse_any_of:
11901201
return ExceptionNode(error_maker)
11911202
else:
11921203
raise error_maker()
@@ -4081,7 +4092,7 @@ def import_handle(self, original, loc, tokens):
40814092
self.syntax_warning("[from *] import * is a Coconut Easter egg and should not be used in production code", original, loc)
40824093
return special_starred_import_handle(imp_all=bool(imp_from))
40834094
for imp_name in imported_names:
4084-
self.name_info[imp_name]["imported"].append(loc)
4095+
self.name_info[imp_name]["imported"].add(loc)
40854096
return self.universal_import(loc, imports, imp_from=imp_from)
40864097

40874098
def complex_raise_stmt_handle(self, loc, tokens):
@@ -4369,7 +4380,7 @@ def stmt_lambdef_handle(self, original, loc, tokens):
43694380
got_kwds, params, typedef, stmts_toks, followed_by = tokens
43704381

43714382
if followed_by == ",":
4372-
self.strict_qa_error("found statement lambda followed by comma; this isn't recommended as it can be unclear whether the comma is inside or outside the lambda (just wrap the lambda in parentheses)", original, loc)
4383+
self.strict_err_or_warn("found statement lambda followed by comma; this isn't recommended as it can be unclear whether the comma is inside or outside the lambda (just wrap the lambda in parentheses)", original, loc)
43734384
else:
43744385
internal_assert(followed_by == "", "invalid stmt_lambdef followed_by", followed_by)
43754386

@@ -5090,7 +5101,7 @@ def type_param_handle(self, original, loc, tokens):
50905101
if bound_op is not None:
50915102
self.internal_assert(bound_op_type in ("bound", "constraint"), original, loc, "invalid type_param bound_op", bound_op)
50925103
if bound_op == "<=":
5093-
self.strict_qa_error(
5104+
self.strict_err_or_warn(
50945105
"use of " + repr(bound_op) + " as a type parameter " + bound_op_type + " declaration operator is deprecated (Coconut style is to use '<:' for bounds and ':' for constaints)",
50955106
original,
50965107
loc,
@@ -5256,7 +5267,7 @@ def class_manage(self, original, loc, item):
52565267
try:
52575268
# handles support for class type variables
52585269
with self.type_alias_stmt_manage():
5259-
with self.add_to_parsing_context("final_vars", set()):
5270+
with self.add_to_parsing_context("final_vars", {}):
52605271
yield
52615272
finally:
52625273
cls_stack.pop()
@@ -5270,7 +5281,7 @@ def func_manage(self, original, loc, item):
52705281
try:
52715282
# handles support for function type variables
52725283
with self.type_alias_stmt_manage():
5273-
with self.add_to_parsing_context("final_vars", set()):
5284+
with self.add_to_parsing_context("final_vars", {}):
52745285
yield
52755286
finally:
52765287
if cls_context is not None:
@@ -5315,27 +5326,13 @@ def name_handle(self, original, loc, tokens, assign=False, classname=False, expr
53155326
if self.disable_name_check:
53165327
return name
53175328

5318-
# raise_or_wrap_error for all errors here to make sure we don't
5319-
# raise spurious errors if not using the computation graph
5320-
5321-
# final variable checking
5322-
final_vars = self.current_parsing_context("final_vars")
5323-
self.internal_assert(final_vars is not None, original, loc, "no final_vars context")
5324-
if (
5325-
assign
5326-
and not expr_setname
5327-
and not escaped
5328-
and name in final_vars
5329-
):
5330-
return self.raise_or_wrap_error(
5331-
CoconutSyntaxError,
5332-
"cannot reassign final variable '{name}'".format(name=name),
5333-
original,
5334-
loc,
5335-
extra="use explicit '\\{name}' syntax to bypass final checking".format(name=name),
5336-
)
5337-
if is_final:
5338-
final_vars.add(name)
5329+
# these are all handled greedily, so they should always be wrapped
5330+
is_greedy = classname or expr_setname or is_final
5331+
# use this for all errors here to make sure we don't raise spurious errors
5332+
local_raise_or_wrap_error = partial(
5333+
self.raise_or_wrap_error,
5334+
always_wrap=is_greedy,
5335+
)
53395336

53405337
# register non-mid-expression variable assignments inside of where statements for later mangling
53415338
if assign and not expr_setname:
@@ -5365,42 +5362,109 @@ def name_handle(self, original, loc, tokens, assign=False, classname=False, expr
53655362
# a reparse of a setname in a typevar, or not a typevar at all
53665363
if typevar_info["typevar_locs"].get(name, None) != loc:
53675364
if assign:
5368-
return self.raise_or_wrap_error(
5365+
return local_raise_or_wrap_error(
53695366
CoconutSyntaxError,
53705367
"cannot reassign type variable '{name}'".format(name=name),
53715368
original,
53725369
loc,
53735370
extra="use explicit '\\{name}' syntax if intended".format(name=name),
53745371
)
5372+
# note that this is the one case where we return early that isn't an error
53755373
return typevars[name]
53765374

5375+
# track assigned/referenced early so that even if we return early with an
5376+
# error, the name is marked as used (e.g. for unused import checking)
53775377
if assign:
5378-
self.name_info[name]["assigned"].append(loc)
5378+
is_new = loc not in self.name_info[name]["assigned"]
5379+
self.name_info[name]["assigned"].add(loc)
53795380
else:
5380-
self.name_info[name]["referenced"].append(loc)
5381+
is_new = loc not in self.name_info[name]["referenced"]
5382+
self.name_info[name]["referenced"].add(loc)
5383+
5384+
is_class_attr = (
5385+
self.current_parsing_context("class")
5386+
and not self.in_method
5387+
and (
5388+
# for classnames, we need special handling for nested classes
5389+
True if not classname
5390+
# - outer class (len == 1): this is the name of the class, not a class attr
5391+
else False if len(self.parsing_context["class"]) <= 1
5392+
# - class defined directly in a method (immediate parent has in_method):
5393+
# not a class attr, this is a variable in the method's local scope
5394+
else False if self.parsing_context["class"][-2].get("in_method")
5395+
# - nested class as attribute (immediate parent does NOT have in_method):
5396+
# this is a class attr just like any other
5397+
else True
5398+
)
5399+
)
53815400

53825401
if (
53835402
assign
53845403
and not escaped
5404+
# if we're creating a class attribute, then we're not actually shadowing anything
5405+
and not is_class_attr
53855406
# if we're not using the computation graph, then name is handled
53865407
# greedily, which means this might be an invalid parse, in which
5387-
# case we can't be sure this is actually shadowing a builtin
5388-
and USE_COMPUTATION_GRAPH
5389-
# classnames and expr_setnames are handled greedily, so ditto the above
5390-
and not (classname or expr_setname)
5391-
and name in all_builtins
5408+
# case we can't be sure this is actually shadowing a builtin;
5409+
# BUT if we're on strict mode, then it's an actual error, rather
5410+
# than a warning, which means we can just wrap it
5411+
and (
5412+
# in strict mode, errors are wrapped and we should always do that
5413+
self.strict
5414+
# in non-strict mode, only check when using computation graph
5415+
# and not for greedy handlers (to avoid spurious warnings)
5416+
# and only if it's a new assignment (to avoid duplicate warnings)
5417+
or (is_new and not is_greedy and USE_COMPUTATION_GRAPH)
5418+
)
53925419
):
5393-
self.strict_qa_error(
5394-
"assignment shadows builtin '{name}' (use explicit '\\{name}' syntax when purposefully assigning to builtin names)".format(name=name),
5420+
if name in all_builtins:
5421+
err = self.strict_err_or_warn(
5422+
"assignment shadows builtin '{name}' (use explicit '\\{name}' syntax when purposefully assigning to builtin names)".format(name=name),
5423+
original,
5424+
loc,
5425+
raise_err_func=local_raise_or_wrap_error,
5426+
)
5427+
if err is not None:
5428+
return err
5429+
# Only check for shadowing if there are imports on different lines;
5430+
# if all imports are on the same line as the current loc, this is
5431+
# the import statement itself, not an assignment shadowing an import
5432+
if any(not same_line(original, loc, imp_loc) for imp_loc in self.name_info[name]["imported"]):
5433+
err = self.strict_err_or_warn(
5434+
"assignment shadows imported name '{name}' (use explicit '\\{name}' syntax when purposefully redefining imported names)".format(name=name),
5435+
original,
5436+
loc,
5437+
raise_err_func=local_raise_or_wrap_error,
5438+
)
5439+
if err is not None:
5440+
return err
5441+
5442+
# final variable checking
5443+
final_vars = self.current_parsing_context("final_vars")
5444+
self.internal_assert(final_vars is not None, original, loc, "no final_vars context")
5445+
if (
5446+
assign
5447+
and not escaped
5448+
and not expr_setname
5449+
and name in final_vars
5450+
and final_vars[name] != loc # allow reassign in same loc (speculative parsing duplicate)
5451+
):
5452+
return local_raise_or_wrap_error(
5453+
CoconutSyntaxError,
5454+
"cannot reassign final variable '{name}'".format(name=name),
53955455
original,
53965456
loc,
5457+
extra="use explicit '\\{name}' syntax to bypass final checking".format(name=name),
53975458
)
5459+
# only mark as final after all checks pass
5460+
if is_final:
5461+
final_vars[name] = loc
53985462

53995463
if name == "exec":
54005464
if self.target.startswith("3"):
54015465
return name
54025466
elif assign:
5403-
return self.raise_or_wrap_error(
5467+
return local_raise_or_wrap_error(
54045468
CoconutTargetError,
54055469
"found Python-3-only assignment to 'exec' as a variable name",
54065470
original,
@@ -5417,7 +5481,7 @@ def name_handle(self, original, loc, tokens, assign=False, classname=False, expr
54175481
else:
54185482
return name
54195483
elif not escaped and name.startswith(reserved_prefix) and name not in self.operators:
5420-
return self.raise_or_wrap_error(
5484+
return local_raise_or_wrap_error(
54215485
CoconutSyntaxError,
54225486
"variable names cannot start with reserved prefix '{prefix}' (use explicit '\\{name}' syntax if intending to access Coconut internals)".format(prefix=reserved_prefix, name=name),
54235487
original,

coconut/compiler/util.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
# - Targets
1919
# - Parse Elements
2020
# - Utilities
21+
# - Extras
2122

2223
# -----------------------------------------------------------------------------------------------------------------------
2324
# IMPORTS:
@@ -70,6 +71,7 @@
7071
_trim_arity,
7172
_ParseResultsWithOffset,
7273
line as _line,
74+
lineno,
7375
all_parse_elements,
7476
)
7577

@@ -1361,7 +1363,7 @@ def parseImpl(self, original, loc, *args, **kwargs):
13611363
if self.greedy:
13621364
if logger.tracing and not final_evaluate_tokens.enabled:
13631365
logger.log_tag("cached_parse invalidated by", self)
1364-
tokens = evaluate_tokens(tokens)
1366+
tokens = ParseResults(evaluate_tokens(tokens))
13651367
if reparse and parse_loc is None:
13661368
raise CoconutInternalException("illegal double reparse in", self)
13671369
reparse = True
@@ -2216,6 +2218,11 @@ def sub_all(inputstr, regexes, replacements):
22162218
return inputstr
22172219

22182220

2221+
def same_line(original, loc1, loc2):
2222+
"""Check if two locations are on the same line in the original string."""
2223+
return lineno(loc1, original) == lineno(loc2, original)
2224+
2225+
22192226
# -----------------------------------------------------------------------------------------------------------------------
22202227
# EXTRAS:
22212228
# -----------------------------------------------------------------------------------------------------------------------

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 = 2
29+
DEVELOP = 3
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/cocotest/agnostic/primary_1.coco

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import itertools
22
import collections
3-
import collections.abc
3+
# use \collections to avoid shadowing warning since collections is imported above
4+
import \collections.abc
45
import platform
56
from copy import copy
67

@@ -642,7 +643,8 @@ def primary_test_1() -> bool:
642643
assert list(z[10:]) == []
643644
hook = getattr(sys, "breakpointhook", None)
644645
try:
645-
def sys.breakpointhook() = 5
646+
# use \sys to avoid shadowing warning when defining attribute on imported module
647+
def \sys.breakpointhook() = 5
646648
assert breakpoint() == 5
647649
finally:
648650
if hook is None:

coconut/tests/src/cocotest/agnostic/primary_2.coco

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import collections
2-
import collections.abc
2+
# use \collections to avoid shadowing warning since collections is imported above
3+
import \collections.abc
34
import weakref
45
import sys
56

coconut/tests/src/cocotest/agnostic/specific.coco

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def py33_spec_test() -> bool:
7070
def py36_spec_test(tco: bool) -> bool:
7171
"""Tests for any py36+ version."""
7272
from dataclasses import dataclass
73-
from typing import Any, Literal
73+
from typing import Literal
7474

7575
outfile = StringIO()
7676

coconut/tests/src/cocotest/agnostic/util.coco

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1394,8 +1394,6 @@ def fib_alt2(n if n < 2) = n
13941394
addpattern def fib_alt2(n) = fib_alt2(n-1) + fib_alt2(n-2) # type: ignore
13951395

13961396
# MapReduce
1397-
from collections import defaultdict
1398-
13991397
join_pairs1 = reduce$((def (acc, (k, v)) ->
14001398
acc[k] += v;
14011399
acc
@@ -1778,8 +1776,6 @@ match def reduce_with_init(f, xs, init=type(xs[0])()) = reduce(f, xs, init)
17781776

17791777

17801778
# last/end
1781-
import operator
1782-
17831779
def last(n=0 if n >= 0) = -n or None
17841780

17851781
# TODO: #645 to remove the below type: ignore

0 commit comments

Comments
 (0)