Skip to content

Commit 9914407

Browse files
evhubclaude
andcommitted
Add --pure mode and refactor NOQA support (#885)
- Add --pure compilation mode flag for enforcing functional programming norms - Currently disallows global and nonlocal statements in --pure mode - Refactor strict_err_or_warn to support noqa_able parameter - Remove strict_qa_error in favor of unified strict_err_or_warn - Add NOQA suppression support to check_strict - Add has_noqa_comment utility method - Add tests for --pure mode and NOQA suppression - Add documentation for --pure mode in DOCS.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 41d0ecb commit 9914407

File tree

7 files changed

+117
-42
lines changed

7 files changed

+117
-42
lines changed

DOCS.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ _Note: Periods are optional in target specifications, such that the target `27`
327327

328328
#### `strict` Mode
329329

330-
If the `--strict` (`-s` for short) flag is enabled, Coconut will perform additional checks on the code being compiled. It is recommended that you use the `--strict` flag if you are starting a new Coconut project, as it will help you write cleaner code. Almost all of these checks can be disabled on a line-by-line basis by adding `# NOQA` or `# noqa` comments. Specifically, the extra checks done by `--strict` are:
330+
If the `--strict` (`-s` for short) flag is enabled, Coconut will perform additional checks on the code being compiled. It is recommended that you use the `--strict` flag if you are starting a new Coconut project, as it will help you write cleaner code. Most of these checks can be disabled on a line-by-line basis by adding `# NOQA` or `# noqa` comments (the error message will say whether `NOQA` is supported). Specifically, the extra checks done by `--strict` are:
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),
@@ -354,6 +354,14 @@ The style issues which will cause `--strict` to throw an error are:
354354

355355
Note that many of the above style issues will still show a warning if `--strict` is not present.
356356

357+
#### `pure` Mode
358+
359+
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.
360+
361+
The following are disallowed in `--pure` mode:
362+
- `global` statements
363+
- `nonlocal` statements
364+
357365
#### Backports
358366

359367
In addition to the newer Python features that Coconut can backport automatically itself to older Python versions, Coconut will also automatically compile code to make use of a variety of external backports as well. These backports are automatically installed with Coconut if needed and Coconut will automatically use them instead of the standard library if the standard library is not available. These backports are:

coconut/api.pyi

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,14 @@ def version(which: Optional[Text] = None) -> Text:
7373

7474
def setup(
7575
target: Optional[str] = None,
76+
*,
7677
strict: bool = False,
7778
minify: bool = False,
7879
line_numbers: bool = True,
7980
keep_lines: bool = False,
8081
no_tco: bool = False,
8182
no_wrap: bool = False,
82-
*,
83+
pure: bool = False,
8384
state: Optional[Command] = ...,
8485
) -> None:
8586
"""Set up the given state object."""

coconut/command/cli.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,12 @@
164164
help="enforce code cleanliness standards",
165165
)
166166

167+
arguments.add_argument(
168+
"--pure",
169+
action="store_true",
170+
help="enforce functional programming norms",
171+
)
172+
167173
arguments.add_argument(
168174
"--no-tco", "--notco",
169175
action="store_true",

coconut/command/command.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ def execute_args(self, args, interact=True, original_args=None):
331331
keep_lines=args.keep_lines,
332332
no_tco=args.no_tco,
333333
no_wrap=args.no_wrap_types,
334+
pure=args.pure,
334335
)
335336
if not self.using_jobs:
336337
self.comp.warm_up(

coconut/compiler/compiler.py

Lines changed: 60 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,7 @@ def __init__(self, *args, **kwargs):
500500
self.reset()
501501

502502
# changes here should be reflected in __reduce__, get_cli_args, and in the stub for coconut.api.setup
503-
def setup(self, target=None, strict=False, minify=False, line_numbers=True, keep_lines=False, no_tco=False, no_wrap=False):
503+
def setup(self, target=None, strict=False, minify=False, line_numbers=True, keep_lines=False, no_tco=False, no_wrap=False, pure=False):
504504
"""Initializes parsing parameters."""
505505
if target is None:
506506
target = ""
@@ -529,10 +529,11 @@ def setup(self, target=None, strict=False, minify=False, line_numbers=True, keep
529529
self.keep_lines = keep_lines
530530
self.no_tco = no_tco
531531
self.no_wrap = no_wrap
532+
self.pure = pure
532533

533534
def __reduce__(self):
534535
"""Get pickling information."""
535-
return (self.__class__, (self.target, self.strict, self.minify, self.line_numbers, self.keep_lines, self.no_tco, self.no_wrap))
536+
return (self.__class__, (self.target, self.strict, self.minify, self.line_numbers, self.keep_lines, self.no_tco, self.no_wrap, self.pure))
536537

537538
def get_cli_args(self):
538539
"""Get the Coconut CLI args that can be used to set up an equivalent compiler."""
@@ -549,6 +550,8 @@ def get_cli_args(self):
549550
args.append("--no-tco")
550551
if self.no_wrap:
551552
args.append("--no-wrap-types")
553+
if self.pure:
554+
args.append("--pure")
552555
return args
553556

554557
def copy(self, snapshot=False):
@@ -910,6 +913,7 @@ def bind(cls):
910913

911914
# these handlers just do strict/target checking
912915
cls.u_string <<= attach(cls.u_string_ref, cls.method("u_string_check"))
916+
cls.global_stmt <<= attach(cls.global_stmt_ref, cls.method("global_check"))
913917
cls.nonlocal_stmt <<= attach(cls.nonlocal_stmt_ref, cls.method("nonlocal_check"))
914918
cls.keyword_lambdef <<= attach(cls.keyword_lambdef_ref, cls.method("lambdef_check"))
915919
cls.star_sep_arg <<= attach(cls.star_sep_arg_ref, cls.method("star_sep_check"))
@@ -1048,37 +1052,42 @@ def syntax_warning(self, message, original, loc, **kwargs):
10481052
logger.warn_err(self.make_err(CoconutSyntaxWarning, message, original, loc, **kwargs))
10491053
self.shown_warnings.add(key)
10501054

1051-
def strict_err_or_warn(self, *args, **kwargs):
1052-
"""Raises an error if in strict mode, otherwise raises a warning. Usage:
1053-
self.strict_err_or_warn(message, original, loc)
1055+
def has_noqa_comment(self, original, loc):
1056+
"""Check if the given location has a NOQA comment."""
1057+
ln = self.adjust(lineno(loc, original))
1058+
comment = self.reformat(" ".join(self.comments[ln]), ignore_errors=True)
1059+
return self.noqa_regex.search(comment)
1060+
1061+
def strict_err_or_warn(self, msg, original, loc, noqa_able=False, **kwargs):
1062+
"""Raises an error if in strict mode, otherwise raises a warning.
1063+
1064+
Set noqa_able=True when the error is non-greedy and comments have been
1065+
parsed, allowing NOQA suppression. Use noqa_able=False (default) when
1066+
errors are greedy and comments haven't been recorded yet.
10541067
"""
10551068
raise_err_func = kwargs.pop("raise_err_func", None)
1069+
pure_err = kwargs.pop("pure_err", False)
10561070
internal_assert("extra" not in kwargs, "cannot pass extra=... to strict_err_or_warn")
1071+
if noqa_able:
1072+
if self.has_noqa_comment(original, loc):
1073+
return None
1074+
msg += " (add '# NOQA' to suppress)"
10571075
if self.strict:
1058-
kwargs["extra"] = "remove --strict to downgrade to a warning"
1076+
kwargs["extra"] = (
1077+
("remove --pure to dismiss; " if pure_err else "")
1078+
+ "remove --strict to downgrade to a warning"
1079+
)
10591080
if raise_err_func is None:
1060-
raise self.make_err(CoconutStyleError, *args, **kwargs)
1081+
raise self.make_err(CoconutStyleError, msg, original, loc, **kwargs)
10611082
else:
1062-
return raise_err_func(CoconutStyleError, *args, **kwargs)
1083+
return raise_err_func(CoconutStyleError, msg, original, loc, **kwargs)
10631084
else:
1064-
self.syntax_warning(*args, **kwargs)
1065-
1066-
def strict_qa_error(self, msg, original, loc, **kwargs):
1067-
"""Strict error or warn an error that should be disabled by a NOQA comment.
1085+
self.syntax_warning(msg, original, loc, **kwargs)
10681086

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-
"""
1073-
ln = self.adjust(lineno(loc, original))
1074-
comment = self.reformat(" ".join(self.comments[ln]), ignore_errors=True)
1075-
if not self.noqa_regex.search(comment):
1076-
return self.strict_err_or_warn(
1077-
msg + " (add '# NOQA' to suppress)",
1078-
original,
1079-
loc,
1080-
**kwargs # no comma
1081-
)
1087+
def pure_error(self, msg, original, loc, noqa_able=True, **kwargs):
1088+
"""If in pure mode, raise an error or warn depending on strict mode."""
1089+
if self.pure:
1090+
return self.strict_err_or_warn(msg, original, loc, noqa_able=noqa_able, pure_err=True, **kwargs)
10821091

10831092
@contextmanager
10841093
def complain_on_err(self):
@@ -1448,11 +1457,11 @@ def run_final_checks(self, original, keep_state=False):
14481457
# always use endpoint=False otherwise the endpoint will be the end of the file since we've finished parsing
14491458
if info["imported"] and not info["referenced"]:
14501459
for loc in info["imported"]:
1451-
self.strict_qa_error("found unused import " + repr(self.reformat(name, ignore_errors=True)), original, loc, endpoint=False)
1460+
self.strict_err_or_warn("found unused import " + repr(self.reformat(name, ignore_errors=True)), original, loc, noqa_able=True, endpoint=False)
14521461
if not self.star_import: # only check for undefined names when there are no * imports
14531462
if name not in all_builtins and info["referenced"] and not (info["assigned"] or info["imported"]):
14541463
for loc in info["referenced"]:
1455-
self.strict_qa_error("found undefined name " + repr(self.reformat(name, ignore_errors=True)), original, loc, endpoint=False)
1464+
self.strict_err_or_warn("found undefined name " + repr(self.reformat(name, ignore_errors=True)), original, loc, noqa_able=True, endpoint=False)
14561465

14571466
def pickle_cache(self, original, cache_path, include_incremental=True):
14581467
"""Pickle the pyparsing cache for original to cache_path."""
@@ -1762,9 +1771,9 @@ def parse(
17621771

17631772
def prepare(self, inputstring, strip=False, nl_at_eof_check=False, **kwargs):
17641773
"""Prepare a string for processing."""
1765-
if self.strict and nl_at_eof_check and inputstring and not inputstring.endswith("\n"):
1774+
if nl_at_eof_check and inputstring and not inputstring.endswith("\n"):
17661775
end_index = len(inputstring) - 1 if inputstring else 0
1767-
raise self.make_err(CoconutStyleError, "missing new line at end of file", inputstring, end_index)
1776+
self.strict_err("missing new line at end of file", inputstring, end_index)
17681777
kept_lines = tuple(literal_lines(inputstring))
17691778
self.num_lines = len(kept_lines)
17701779
if self.keep_lines:
@@ -3159,7 +3168,7 @@ def split_function_call(self, original, loc, tokens):
31593168
elif arg[1] == "=":
31603169
kwd_args.append(arg[0] + "=" + arg[0])
31613170
elif arg[0] == "...":
3162-
self.strict_qa_error("'...={name}' shorthand is deprecated, use '{name}=' shorthand instead".format(name=arg[1]), original, loc)
3171+
self.strict_err_or_warn("'...={name}' shorthand is deprecated, use '{name}=' shorthand instead".format(name=arg[1]), original, loc, noqa_able=True)
31633172
kwd_args.append(arg[1] + "=" + arg[1])
31643173
else:
31653174
kwd_args.append(argstr)
@@ -3380,7 +3389,7 @@ def item_handle(self, original, loc, tokens):
33803389
elif trailer[0] == "[]":
33813390
out = "_coconut_partial(_coconut.operator.getitem, " + out + ")"
33823391
elif trailer[0] == ".":
3383-
self.strict_qa_error("'obj.' as a shorthand for 'getattr$(obj)' is deprecated (just use the getattr partial)", original, loc)
3392+
self.strict_err_or_warn("'obj.' as a shorthand for 'getattr$(obj)' is deprecated (just use the getattr partial)", original, loc, noqa_able=True)
33843393
out = "_coconut_partial(_coconut.getattr, " + out + ")"
33853394
elif trailer[0] == "type:[]":
33863395
out = "_coconut.typing.Sequence[" + out + "]"
@@ -3599,7 +3608,7 @@ def classdef_handle(self, original, loc, tokens):
35993608
and not kwd_args
36003609
and not dubstar_args
36013610
):
3602-
self.strict_qa_error("unnecessary inheriting from object (Coconut does this automatically)", original, loc)
3611+
self.strict_err_or_warn("unnecessary inheriting from object (Coconut does this automatically)", original, loc, noqa_able=True)
36033612

36043613
# universalize if not Python 3
36053614
if not self.target.startswith("3"):
@@ -3944,7 +3953,7 @@ def anon_namedtuple_handle(self, original, loc, tokens):
39443953
if item == "=":
39453954
item = name
39463955
elif name == "...":
3947-
self.strict_qa_error("'...={item}' shorthand is deprecated, use '{item}=' shorthand instead".format(item=item), original, loc)
3956+
self.strict_err_or_warn("'...={item}' shorthand is deprecated, use '{item}=' shorthand instead".format(item=item), original, loc, noqa_able=True)
39483957
name = item
39493958
names.append(name)
39503959
items.append(item)
@@ -4077,7 +4086,7 @@ def import_handle(self, original, loc, tokens):
40774086
elif len(tokens) == 2:
40784087
imp_from, imports = tokens
40794088
if imp_from == "__future__":
4080-
self.strict_qa_error("unnecessary from __future__ import (Coconut does these automatically)", original, loc)
4089+
self.strict_err_or_warn("unnecessary from __future__ import (Coconut does these automatically)", original, loc, noqa_able=True)
40814090
return ""
40824091
else:
40834092
raise CoconutInternalException("invalid import tokens", tokens)
@@ -4639,10 +4648,11 @@ def cases_stmt_handle(self, original, loc, tokens):
46394648
raise CoconutInternalException("invalid case tokens", tokens)
46404649

46414650
if block_kwd == "case":
4642-
self.strict_qa_error(
4651+
self.strict_err_or_warn(
46434652
"deprecated case keyword at top level in case ...: match ...: block (use Python 3.10 match ...: case ...: syntax instead)",
46444653
original,
46454654
loc,
4655+
noqa_able=True,
46464656
)
46474657
elif block_kwd == "cases":
46484658
self.syntax_warning(
@@ -4687,7 +4697,7 @@ def f_string_handle(self, original, loc, tokens, is_t=False):
46874697

46884698
# warn if there are no exprs
46894699
if not exprs:
4690-
self.strict_qa_error(("t" if is_t else "f") + "-string with no expressions", original, loc)
4700+
self.strict_err_or_warn(("t" if is_t else "f") + "-string with no expressions", original, loc, noqa_able=True)
46914701

46924702
# handle Python 3.8 f string = specifier
46934703
for i, expr in enumerate(exprs):
@@ -4947,7 +4957,7 @@ def string_atom_handle(self, original, loc, tokens, allow_silent_concat=False):
49474957
return tokens[0]
49484958
else:
49494959
if not allow_silent_concat:
4950-
self.strict_qa_error("found implicit string concatenation (use explicit '+' instead)", original, loc)
4960+
self.strict_err_or_warn("found implicit string concatenation (use explicit '+' instead)", original, loc, noqa_able=True)
49514961
if any(s.endswith(")") for s in tokens): # has .format() calls
49524962
# parens are necessary for string_atom_handle
49534963
return "(" + " + ".join(tokens) + ")"
@@ -5495,10 +5505,14 @@ def name_handle(self, original, loc, tokens, assign=False, classname=False, expr
54955505
# CHECKING HANDLERS:
54965506
# -----------------------------------------------------------------------------------------------------------------------
54975507

5498-
def check_strict(self, name, original, loc, tokens=(None,), only_warn=False, always_warn=False):
5508+
def check_strict(self, name, original, loc, tokens=(None,), only_warn=False, always_warn=False, noqa_able=True):
54995509
"""Check that syntax meets --strict requirements."""
55005510
self.internal_assert(len(tokens) == 1, original, loc, "invalid " + name + " tokens", tokens)
5511+
if noqa_able and self.has_noqa_comment(original, loc):
5512+
return tokens[0]
55015513
message = "found " + name
5514+
if noqa_able:
5515+
message += " (add '# NOQA' to suppress)"
55025516
if self.strict:
55035517
kwargs = {}
55045518
if only_warn:
@@ -5515,7 +5529,7 @@ def check_strict(self, name, original, loc, tokens=(None,), only_warn=False, alw
55155529

55165530
def lambdef_check(self, original, loc, tokens):
55175531
"""Check for Python-style lambdas."""
5518-
return self.check_strict("Python-style lambda", original, loc, tokens)
5532+
return self.check_strict("Python-style lambda", original, loc, tokens, noqa_able=False)
55195533

55205534
def endline_semicolon_check(self, original, loc, tokens):
55215535
"""Check for semicolons at the end of lines."""
@@ -5559,8 +5573,15 @@ def check_py(self, version, name, original, loc, tokens):
55595573
else:
55605574
return tokens[0]
55615575

5576+
def global_check(self, original, loc, tokens):
5577+
"""Check for global statement in --pure mode."""
5578+
self.pure_error("global statements are disabled in --pure mode", original, loc)
5579+
global_stmt, = tokens
5580+
return global_stmt
5581+
55625582
def nonlocal_check(self, original, loc, tokens):
55635583
"""Check for Python 3 nonlocal statement."""
5584+
self.pure_error("nonlocal statements are disabled in --pure mode", original, loc)
55645585
return self.check_py("3", "nonlocal statement", original, loc, tokens)
55655586

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

coconut/compiler/grammar.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2003,7 +2003,8 @@ class Grammar(object):
20032003
kwd_augassign
20042004
| simple_kwd_assign
20052005
)
2006-
global_stmt = addspace(keyword("global") + kwd_assign)
2006+
global_stmt = Forward()
2007+
global_stmt_ref = addspace(keyword("global") + kwd_assign)
20072008
nonlocal_stmt = Forward()
20082009
nonlocal_stmt_ref = addspace(keyword("nonlocal") + kwd_assign)
20092010

coconut/tests/src/extras.coco

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,43 @@ class A:
566566
setup(line_numbers=False, strict=True, target="sys")
567567
assert_raises(-> parse("await f x"), CoconutParseError)
568568

569+
setup(line_numbers=False, strict=True, pure=True, target="sys")
570+
assert_raises(-> parse("""
571+
def f():
572+
global x
573+
""".strip()), CoconutStyleError, err_has="pure")
574+
assert_raises(-> parse("""
575+
def f():
576+
x = 1
577+
def g():
578+
nonlocal x
579+
""".strip()), CoconutStyleError, err_has="pure")
580+
581+
# test NOQA suppression
582+
setup(line_numbers=False, strict=True, target="sys")
583+
# these should NOT raise with NOQA
584+
parse('u"test" # NOQA')
585+
parse('x = 1; # NOQA')
586+
parse('"a" "b" # NOQA')
587+
parse('f"no expr" # NOQA')
588+
parse("""
589+
class Foo(object): # NOQA
590+
pass
591+
""".strip())
592+
parse("from __future__ import print_function # NOQA")
593+
# pure mode NOQA
594+
setup(line_numbers=False, strict=True, pure=True, target="sys")
595+
parse("""
596+
def f():
597+
global x # NOQA
598+
""".strip())
599+
parse("""
600+
def f():
601+
x = 1
602+
def g():
603+
nonlocal x # NOQA
604+
""".strip())
605+
569606
setup(line_numbers=False, target="2.7")
570607
assert parse("from io import BytesIO", mode="lenient") == "from io import BytesIO"
571608
assert_raises(-> parse("def f(*, x=None) = x"), CoconutTargetError, err_has="\n ^")

0 commit comments

Comments
 (0)