diff --git a/cogapp/__init__.py b/cogapp/__init__.py index bfcb793..676233a 100644 --- a/cogapp/__init__.py +++ b/cogapp/__init__.py @@ -5,3 +5,7 @@ """ from .cogapp import Cog as Cog, CogUsageError as CogUsageError, main as main + + +if __name__ == "__main__": + main() diff --git a/cogapp/cogapp.py b/cogapp/cogapp.py index b463f87..66568d4 100644 --- a/cogapp/cogapp.py +++ b/cogapp/cogapp.py @@ -1,7 +1,5 @@ """Cog content generation tool.""" -import copy -import getopt import glob import io import linecache @@ -12,98 +10,19 @@ import traceback import types -from .whiteutils import common_prefix, reindent_block, white_prefix +from .errors import ( + CogCheckFailed, + CogError, + CogGeneratedError, + CogUsageError, + CogUserException, +) +from .options import CogOptions from .utils import NumberedFileReader, Redirectable, change_dir, md5 +from .whiteutils import common_prefix, reindent_block, white_prefix __version__ = "3.4.1" -usage = """\ -cog - generate content with inlined Python code. - -cog [OPTIONS] [INFILE | @FILELIST | &FILELIST] ... - -INFILE is the name of an input file, '-' will read from stdin. -FILELIST is the name of a text file containing file names or -other @FILELISTs. - -For @FILELIST, paths in the file list are relative to the working -directory where cog was called. For &FILELIST, paths in the file -list are relative to the file list location. - -OPTIONS: - -c Checksum the output to protect it against accidental change. - -d Delete the generator code from the output file. - -D name=val Define a global string available to your generator code. - -e Warn if a file has no cog code in it. - -I PATH Add PATH to the list of directories for data files and modules. - -n ENCODING Use ENCODING when reading and writing files. - -o OUTNAME Write the output to OUTNAME. - -p PROLOGUE Prepend the generator source with PROLOGUE. Useful to insert an - import line. Example: -p "import math" - -P Use print() instead of cog.outl() for code output. - -r Replace the input file with the output. - -s STRING Suffix all generated output lines with STRING. - -U Write the output with Unix newlines (only LF line-endings). - -w CMD Use CMD if the output file needs to be made writable. - A %s in the CMD will be filled with the filename. - -x Excise all the generated output without running the generators. - -z The end-output marker can be omitted, and is assumed at eof. - -v Print the version of cog and exit. - --check Check that the files would not change if run again. - --markers='START END END-OUTPUT' - The patterns surrounding cog inline instructions. Should - include three values separated by spaces, the start, end, - and end-output markers. Defaults to '[[[cog ]]] [[[end]]]'. - --verbosity=VERBOSITY - Control the amount of output. 2 (the default) lists all files, - 1 lists only changed files, 0 lists no files. - -h Print this help. -""" - - -class CogError(Exception): - """Any exception raised by Cog.""" - - def __init__(self, msg, file="", line=0): - if file: - super().__init__(f"{file}({line}): {msg}") - else: - super().__init__(msg) - - -class CogUsageError(CogError): - """An error in usage of command-line arguments in cog.""" - - pass - - -class CogInternalError(CogError): - """An error in the coding of Cog. Should never happen.""" - - pass - - -class CogGeneratedError(CogError): - """An error raised by a user's cog generator.""" - - pass - - -class CogUserException(CogError): - """An exception caught when running a user's cog generator. - - The argument is the traceback message to print. - - """ - - pass - - -class CogCheckFailed(CogError): - """A --check failed.""" - - pass - class CogGenerator(Redirectable): """A generator pulled from a source file.""" @@ -213,129 +132,6 @@ def error(self, msg="Error raised by cog generator."): raise CogGeneratedError(msg) -class CogOptions: - """Options for a run of cog.""" - - def __init__(self): - # Defaults for argument values. - self.args = [] - self.include_path = [] - self.defines = {} - self.show_version = False - self.make_writable_cmd = None - self.replace = False - self.no_generate = False - self.output_name = None - self.warn_empty = False - self.hash_output = False - self.delete_code = False - self.eof_can_be_end = False - self.suffix = None - self.newlines = False - self.begin_spec = "[[[cog" - self.end_spec = "]]]" - self.end_output = "[[[end]]]" - self.encoding = "utf-8" - self.verbosity = 2 - self.prologue = "" - self.print_output = False - self.check = False - - def __eq__(self, other): - """Comparison operator for tests to use.""" - return self.__dict__ == other.__dict__ - - def clone(self): - """Make a clone of these options, for further refinement.""" - return copy.deepcopy(self) - - def add_to_include_path(self, dirs): - """Add directories to the include path.""" - dirs = dirs.split(os.pathsep) - self.include_path.extend(dirs) - - def parse_args(self, argv): - # Parse the command line arguments. - try: - opts, self.args = getopt.getopt( - argv, - "cdD:eI:n:o:rs:p:PUvw:xz", - [ - "check", - "markers=", - "verbosity=", - ], - ) - except getopt.error as msg: - raise CogUsageError(msg) - - # Handle the command line arguments. - for o, a in opts: - if o == "-c": - self.hash_output = True - elif o == "-d": - self.delete_code = True - elif o == "-D": - if a.count("=") < 1: - raise CogUsageError("-D takes a name=value argument") - name, value = a.split("=", 1) - self.defines[name] = value - elif o == "-e": - self.warn_empty = True - elif o == "-I": - self.add_to_include_path(os.path.abspath(a)) - elif o == "-n": - self.encoding = a - elif o == "-o": - self.output_name = a - elif o == "-r": - self.replace = True - elif o == "-s": - self.suffix = a - elif o == "-p": - self.prologue = a - elif o == "-P": - self.print_output = True - elif o == "-U": - self.newlines = True - elif o == "-v": - self.show_version = True - elif o == "-w": - self.make_writable_cmd = a - elif o == "-x": - self.no_generate = True - elif o == "-z": - self.eof_can_be_end = True - elif o == "--check": - self.check = True - elif o == "--markers": - self._parse_markers(a) - elif o == "--verbosity": - self.verbosity = int(a) - else: - # Since getopt.getopt is given a list of possible flags, - # this is an internal error. - raise CogInternalError(f"Don't understand argument {o}") - - def _parse_markers(self, val): - try: - self.begin_spec, self.end_spec, self.end_output = val.split(" ") - except ValueError: - raise CogUsageError( - f"--markers requires 3 values separated by spaces, could not parse {val!r}" - ) - - def validate(self): - """Does nothing if everything is OK, raises CogError's if it's not.""" - if self.replace and self.delete_code: - raise CogUsageError( - "Can't use -d with -r (or you would delete all your source!)" - ) - - if self.replace and self.output_name: - raise CogUsageError("Can't use -o with -r (they are opposites)") - - class Cog(Redirectable): """The Cog engine.""" @@ -348,23 +144,23 @@ def __init__(self): self.check_failed = False def _fix_end_output_patterns(self): - end_output = re.escape(self.options.end_output) + end_output = re.escape(self.options.markers.end_output) self.re_end_output = re.compile( end_output + r"(?P *\(checksum: (?P[a-f0-9]+)\))" ) - self.end_format = self.options.end_output + " (checksum: %s)" + self.end_format = self.options.markers.end_output + " (checksum: %s)" def show_warning(self, msg): self.prout(f"Warning: {msg}") def is_begin_spec_line(self, s): - return self.options.begin_spec in s + return self.options.markers.begin_spec in s def is_end_spec_line(self, s): - return self.options.end_spec in s and not self.is_end_output_line(s) + return self.options.markers.end_spec in s and not self.is_end_output_line(s) def is_end_output_line(self, s): - return self.options.end_output in s + return self.options.markers.end_output in s def create_cog_module(self): """Make a cog "module" object. @@ -380,7 +176,7 @@ def open_output_file(self, fname): opts = {} mode = "w" opts["encoding"] = self.options.encoding - if self.options.newlines: + if self.options.unix_newlines: opts["newline"] = "\n" fdir = os.path.dirname(fname) if os.path.dirname(fdir) and not os.path.exists(fdir): @@ -439,13 +235,13 @@ def process_file(self, file_in, file_out, fname=None, globals=None): while line and not self.is_begin_spec_line(line): if self.is_end_spec_line(line): raise CogError( - f"Unexpected {self.options.end_spec!r}", + f"Unexpected {self.options.markers.end_spec!r}", file=file_name_in, line=file_in.linenumber(), ) if self.is_end_output_line(line): raise CogError( - f"Unexpected {self.options.end_output!r}", + f"Unexpected {self.options.markers.end_output!r}", file=file_name_in, line=file_in.linenumber(), ) @@ -466,8 +262,8 @@ def process_file(self, file_in, file_out, fname=None, globals=None): # If the spec begin is also a spec end, then process the single # line of code inside. if self.is_end_spec_line(line): - beg = line.find(self.options.begin_spec) - end = line.find(self.options.end_spec) + beg = line.find(self.options.markers.begin_spec) + end = line.find(self.options.markers.end_spec) if beg > end: raise CogError( "Cog code markers inverted", @@ -475,7 +271,9 @@ def process_file(self, file_in, file_out, fname=None, globals=None): line=first_line_num, ) else: - code = line[beg + len(self.options.begin_spec) : end].strip() + code = line[ + beg + len(self.options.markers.begin_spec) : end + ].strip() gen.parse_line(code) else: # Deal with an ordinary code block. @@ -485,13 +283,13 @@ def process_file(self, file_in, file_out, fname=None, globals=None): while line and not self.is_end_spec_line(line): if self.is_begin_spec_line(line): raise CogError( - f"Unexpected {self.options.begin_spec!r}", + f"Unexpected {self.options.markers.begin_spec!r}", file=file_name_in, line=file_in.linenumber(), ) if self.is_end_output_line(line): raise CogError( - f"Unexpected {self.options.end_output!r}", + f"Unexpected {self.options.markers.end_output!r}", file=file_name_in, line=file_in.linenumber(), ) @@ -519,13 +317,13 @@ def process_file(self, file_in, file_out, fname=None, globals=None): while line and not self.is_end_output_line(line): if self.is_begin_spec_line(line): raise CogError( - f"Unexpected {self.options.begin_spec!r}", + f"Unexpected {self.options.markers.begin_spec!r}", file=file_name_in, line=file_in.linenumber(), ) if self.is_end_spec_line(line): raise CogError( - f"Unexpected {self.options.end_spec!r}", + f"Unexpected {self.options.markers.end_spec!r}", file=file_name_in, line=file_in.linenumber(), ) @@ -537,7 +335,7 @@ def process_file(self, file_in, file_out, fname=None, globals=None): if not line and not self.options.eof_can_be_end: # We reached end of file before we found the end output line. raise CogError( - f"Missing {self.options.end_output!r} before end of file.", + f"Missing {self.options.markers.end_output!r} before end of file.", file=file_name_in, line=file_in.linenumber(), ) @@ -560,7 +358,7 @@ def process_file(self, file_in, file_out, fname=None, globals=None): # Write the ending output line hash_match = self.re_end_output.search(line) - if self.options.hash_output: + if self.options.checksum: if hash_match: old_hash = hash_match["hash"] if old_hash != cur_hash: @@ -573,7 +371,7 @@ def process_file(self, file_in, file_out, fname=None, globals=None): endpieces = line.split(hash_match.group(0), 1) else: # There was no old hash, but we want a new hash. - endpieces = line.split(self.options.end_output, 1) + endpieces = line.split(self.options.markers.end_output, 1) line = (self.end_format % new_hash).join(endpieces) else: # We don't want hashes output, so if there was one, get rid of @@ -731,15 +529,14 @@ def process_arguments(self, args): self.options = self.options.clone() self.options.parse_args(args[1:]) - self.options.validate() if args[0][0] == "@": if self.options.output_name: - raise CogUsageError("Can't use -o with @file") + raise CogUsageError("Can't use -o/--output with @file") self.process_file_list(args[0][1:]) elif args[0][0] == "&": if self.options.output_name: - raise CogUsageError("Can't use -o with &file") + raise CogUsageError("Can't use -o/--output with &file") file_list = args[0][1:] with change_dir(os.path.dirname(file_list)): self.process_file_list(os.path.basename(file_list)) @@ -756,13 +553,7 @@ def callable_main(self, argv): """ argv = argv[1:] - # Provide help if asked for anywhere in the command line. - if "-?" in argv or "-h" in argv: - self.prerr(usage, end="") - return - self.options.parse_args(argv) - self.options.validate() self._fix_end_output_patterns() if self.options.show_version: @@ -801,6 +592,8 @@ def main(self, argv): except CogError as err: self.prerr(err) return 1 + except SystemExit as exit: + return exit.code def find_cog_source(frame_summary, prologue): diff --git a/cogapp/errors.py b/cogapp/errors.py new file mode 100644 index 0000000..cc9216e --- /dev/null +++ b/cogapp/errors.py @@ -0,0 +1,40 @@ +from argparse import ArgumentTypeError +from typing import Any + + +class CogError(Exception): + """Any exception raised by Cog.""" + + def __init__(self, msg: Any, file: str = "", line: int = 0): + if file: + super().__init__(f"{file}({line}): {msg}") + else: + super().__init__(msg) + + +class CogUsageError(CogError, ArgumentTypeError): + """An error in usage of command-line arguments in cog.""" + + pass + + +class CogGeneratedError(CogError): + """An error raised by a user's cog generator.""" + + pass + + +class CogUserException(CogError): + """An exception caught when running a user's cog generator. + + The argument is the traceback message to print. + + """ + + pass + + +class CogCheckFailed(CogError): + """A --check failed.""" + + pass diff --git a/cogapp/options.py b/cogapp/options.py new file mode 100644 index 0000000..bcd6bf5 --- /dev/null +++ b/cogapp/options.py @@ -0,0 +1,287 @@ +import argparse +import copy +import os +import shutil +import sys +from dataclasses import dataclass, field +from textwrap import dedent, wrap +from typing import ClassVar, Dict, List, NamedTuple, NoReturn, Optional + +from .errors import CogUsageError + + +if sys.version_info >= (3, 8): + extend_action = "extend" +else: + + class extend_action(argparse._AppendAction): + def __call__(self, parser, namespace, values, option_string=None): + items = getattr(namespace, self.dest, None) + items = argparse._copy_items(items) + items.extend(values) + setattr(namespace, self.dest, items) + + +description = """\ +cog - generate content with inlined Python code. + +usage: cog [OPTIONS] [INFILE | @FILELIST | &FILELIST] ... + +INFILE is the name of an input file, '-' will read from stdin. +FILELIST is the name of a text file containing file names or +other @FILELISTs. + +For @FILELIST, paths in the file list are relative to the working +directory where cog was called. For &FILELIST, paths in the file +list are relative to the file list location. +""" + + +HELP_WIDTH = min( + shutil.get_terminal_size().columns - 2, # same default as argparse + 100, # arbitrary choice 🤷🏻‍♂️ +) + + +def rewrap(text: str) -> str: + paras = text.split("\n\n") + paras_wrapped = ["\n".join(wrap(para, HELP_WIDTH)) for para in paras] + return "\n\n".join(paras_wrapped) + + +class CogArgumentParser(argparse.ArgumentParser): + def error(self, message: str) -> NoReturn: + "https://stackoverflow.com/a/67891066/718180" + raise CogUsageError(message) + + +def _parse_define(arg): + if arg.count("=") < 1: + raise CogUsageError("takes a name=value argument") + return arg.split("=", 1) + + +class _UpdateDictAction(argparse.Action): + def __call__(self, _parser, ns, arg, _option_string=None): + getattr(ns, self.dest).update([arg]) + + +class Markers(NamedTuple): + begin_spec: str + end_spec: str + end_output: str + + @classmethod + def from_arg(cls, arg: str): + parts = arg.split(" ") + if len(parts) != 3: + raise CogUsageError( + f"requires 3 values separated by spaces, could not parse {arg!r}" + ) + return cls(*parts) + + +@dataclass +class CogOptions: + """Options for a run of cog.""" + + _parser: ClassVar = CogArgumentParser( + prog="cog", + usage=argparse.SUPPRESS, + description=rewrap(description), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + _parser.add_argument("-?", action="help", help=argparse.SUPPRESS) + + checksum: bool = False + _parser.add_argument( + "-c", + dest="checksum", + action="store_true", + help="Checksum the output to protect it against accidental change.", + ) + + delete_code: bool = False + _parser.add_argument( + "-d", + dest="delete_code", + action="store_true", + help="Delete the generator code from the output file.", + ) + + defines: Dict[str, str] = field(default_factory=dict) + _parser.add_argument( + "-D", + dest="defines", + type=_parse_define, + metavar="name=val", + action=_UpdateDictAction, + help="Define a global string available to your generator code.", + ) + + warn_empty: bool = False + _parser.add_argument( + "-e", + dest="warn_empty", + action="store_true", + help="Warn if a file has no cog code in it.", + ) + + include_path: List[str] = field(default_factory=list) + _parser.add_argument( + "-I", + dest="include_path", + metavar="PATH", + type=lambda paths: map(os.path.abspath, paths.split(os.path.pathsep)), + action=extend_action, + help="Add PATH to the list of directories for data files and modules.", + ) + + encoding: str = "utf-8" + _parser.add_argument( + "-n", + dest="encoding", + metavar="ENCODING", + help="Use ENCODING when reading and writing files.", + ) + + output_name: Optional[str] = None + _parser.add_argument( + "-o", + "--output", + dest="output_name", + metavar="OUTNAME", + help="Write the output to OUTNAME.", + ) + + prologue: str = "" + _parser.add_argument( + "-p", + dest="prologue", + help=dedent(""" + Prepend the generator source with PROLOGUE. Useful to insert an import + line. Example: -p "import math" + """), + ) + + print_output: bool = False + _parser.add_argument( + "-P", + dest="print_output", + action="store_true", + help="Use print() instead of cog.outl() for code output.", + ) + + replace: bool = False + _parser.add_argument( + "-r", + dest="replace", + action="store_true", + help="Replace the input file with the output.", + ) + + suffix: Optional[str] = None + _parser.add_argument( + "-s", + dest="suffix", + metavar="STRING", + help="Suffix all generated output lines with STRING.", + ) + + unix_newlines: bool = False + _parser.add_argument( + "-U", + dest="unix_newlines", + action="store_true", + help="Write the output with Unix newlines (only LF line-endings).", + ) + + make_writable_cmd: Optional[str] = None + _parser.add_argument( + "-w", + dest="make_writable_cmd", + metavar="CMD", + help=dedent(""" + Use CMD if the output file needs to be made writable. A %%s in the CMD + will be filled with the filename. + """), + ) + + no_generate: bool = False + _parser.add_argument( + "-x", + dest="no_generate", + action="store_true", + help="Excise all the generated output without running the generators.", + ) + + eof_can_be_end: bool = False + _parser.add_argument( + "-z", + dest="eof_can_be_end", + action="store_true", + help="The end-output marker can be omitted, and is assumed at eof.", + ) + + show_version: bool = False + _parser.add_argument( + "-v", + dest="show_version", + action="store_true", + help="Print the version of cog and exit.", + ) + + check: bool = False + _parser.add_argument( + "--check", + action="store_true", + help="Check that the files would not change if run again.", + ) + + markers: Markers = Markers("[[[cog", "]]]", "[[[end]]]") + _parser.add_argument( + "--markers", + metavar="'START END END-OUTPUT'", + type=Markers.from_arg, + help=dedent(""" + The patterns surrounding cog inline instructions. Should include three + values separated by spaces, the start, end, and end-output markers. + Defaults to '[[[cog ]]] [[[end]]]'. + """), + ) + + verbosity: int = 2 + _parser.add_argument( + "--verbosity", + type=int, + help=dedent(""" + Control the amount of output. 2 (the default) lists all files, 1 lists + only changed files, 0 lists no files. + """), + ) + + args: List[str] = field(default_factory=list) + _parser.add_argument( + "args", + metavar="[INFILE | @FILELIST | &FILELIST]", + nargs=argparse.ZERO_OR_MORE, + ) + + def clone(self): + """Make a clone of these options, for further refinement.""" + return copy.deepcopy(self) + + def parse_args(self, argv: List[str]): + try: + self._parser.parse_args(argv, namespace=self) + except argparse.ArgumentError as err: + raise CogUsageError(err.message) + + if self.replace and self.delete_code: + raise CogUsageError( + "Can't use -d with -r (or you would delete all your source!)" + ) + + if self.replace and self.output_name: + raise CogUsageError("Can't use -o with -r (they are opposites)") diff --git a/cogapp/test_cogapp.py b/cogapp/test_cogapp.py index ca3162f..d303291 100644 --- a/cogapp/test_cogapp.py +++ b/cogapp/test_cogapp.py @@ -12,10 +12,12 @@ import threading from unittest import TestCase -from .cogapp import Cog, CogOptions, CogGenerator -from .cogapp import CogError, CogUsageError, CogGeneratedError, CogUserException -from .cogapp import usage, __version__, main +import pytest + +from .cogapp import __version__, Cog, CogGenerator, main +from .errors import CogError, CogGeneratedError, CogUsageError, CogUserException from .makefiles import make_files +from .options import CogOptions from .whiteutils import reindent_block @@ -510,19 +512,12 @@ def test_combining_flags(self): p.parse_args(["-erz"]) self.assertEqual(o, p) - def test_markers(self): - o = CogOptions() - o._parse_markers("a b c") - self.assertEqual("a", o.begin_spec) - self.assertEqual("b", o.end_spec) - self.assertEqual("c", o.end_output) - def test_markers_switch(self): o = CogOptions() o.parse_args(["--markers", "a b c"]) - self.assertEqual("a", o.begin_spec) - self.assertEqual("b", o.end_spec) - self.assertEqual("c", o.end_output) + self.assertEqual("a", o.markers.begin_spec) + self.assertEqual("b", o.markers.end_spec) + self.assertEqual("c", o.markers.end_output) class FileStructureTests(TestCase): @@ -767,12 +762,15 @@ def test_no_common_prefix_for_markers(self): class TestCaseWithTempDir(TestCase): + capsys: pytest.CaptureFixture + + @pytest.fixture(autouse=True) + def _capsys(self, capsys: pytest.CaptureFixture): + self.capsys = capsys + def new_cog(self): - """Initialize the cog members for another run.""" - # Create a cog engine, and catch its output. self.cog = Cog() - self.output = io.StringIO() - self.cog.set_output(stdout=self.output, stderr=self.output) + self.capsys.readouterr() # reset stdout/stderr def setUp(self): # Create a temporary directory. @@ -807,40 +805,45 @@ class ArgumentHandlingTests(TestCaseWithTempDir): def test_argument_failure(self): # Return value 2 means usage problem. self.assertEqual(self.cog.main(["argv0", "-j"]), 2) - output = self.output.getvalue() - self.assertIn("option -j not recognized", output) + _output, errput = self.capsys.readouterr() + self.assertIn("unrecognized arguments: -j", errput) with self.assertRaisesRegex(CogUsageError, r"^No files to process$"): self.cog.callable_main(["argv0"]) - with self.assertRaisesRegex(CogUsageError, r"^option -j not recognized$"): + with self.assertRaisesRegex(CogUsageError, r"^unrecognized arguments: -j$"): self.cog.callable_main(["argv0", "-j"]) def test_no_dash_o_and_at_file(self): make_files({"cogfiles.txt": "# Please run cog"}) - with self.assertRaisesRegex(CogUsageError, r"^Can't use -o with @file$"): + with self.assertRaisesRegex( + CogUsageError, r"^Can't use -o/--output with @file$" + ): self.cog.callable_main(["argv0", "-o", "foo", "@cogfiles.txt"]) def test_no_dash_o_and_amp_file(self): make_files({"cogfiles.txt": "# Please run cog"}) - with self.assertRaisesRegex(CogUsageError, r"^Can't use -o with &file$"): + with self.assertRaisesRegex( + CogUsageError, r"^Can't use -o/--output with &file$" + ): self.cog.callable_main(["argv0", "-o", "foo", "&cogfiles.txt"]) def test_dash_v(self): self.assertEqual(self.cog.main(["argv0", "-v"]), 0) - output = self.output.getvalue() + output, errput = self.capsys.readouterr() self.assertEqual("Cog version %s\n" % __version__, output) def produces_help(self, args): self.new_cog() argv = ["argv0"] + args.split() self.assertEqual(self.cog.main(argv), 0) - self.assertEqual(usage, self.output.getvalue()) + output, errput = self.capsys.readouterr() + self.assertIn("usage: ", output) def test_dash_h(self): # -h or -? anywhere on the command line should just print help. self.produces_help("-h") self.produces_help("-?") self.produces_help("fooey.txt -h") - self.produces_help("-o -r @fooey.txt -? @booey.txt") + self.produces_help("-o foo -r @fooey.txt -? @booey.txt") def test_dash_o_and_dash_r(self): d = { @@ -888,20 +891,24 @@ def test_dash_z(self): self.assertFilesSame("test.cog", "test.out") def test_bad_dash_d(self): - with self.assertRaisesRegex(CogUsageError, r"^-D takes a name=value argument$"): + with self.assertRaisesRegex( + CogUsageError, r"^argument -D/--define: takes a name=value argument$" + ): self.cog.callable_main(["argv0", "-Dfooey", "cog.txt"]) - with self.assertRaisesRegex(CogUsageError, r"^-D takes a name=value argument$"): + with self.assertRaisesRegex( + CogUsageError, r"^argument -D/--define: takes a name=value argument$" + ): self.cog.callable_main(["argv0", "-D", "fooey", "cog.txt"]) def test_bad_markers(self): with self.assertRaisesRegex( CogUsageError, - r"^--markers requires 3 values separated by spaces, could not parse 'X'$", + r"^argument --markers: requires 3 values separated by spaces, could not parse 'X'$", ): self.cog.callable_main(["argv0", "--markers=X"]) with self.assertRaisesRegex( CogUsageError, - r"^--markers requires 3 values separated by spaces, could not parse 'A B C D'$", + r"^argument --markers: requires 3 values separated by spaces, could not parse 'A B C D'$", ): self.cog.callable_main(["argv0", "--markers=A B C D"]) @@ -924,7 +931,7 @@ def test_main_function(self): ret = main() self.assertEqual(ret, 2) stderr = sys.stderr.getvalue() - self.assertEqual(stderr, "option -Z not recognized\n(for help use -h)\n") + self.assertEqual(stderr, "unrecognized arguments: -Z\n(for help use -h)\n") files = { "test.cog": """\ @@ -1015,7 +1022,7 @@ def test_simple(self): make_files(d) self.cog.callable_main(["argv0", "-r", "test.cog"]) self.assertFilesSame("test.cog", "test.out") - output = self.output.getvalue() + output, errput = self.capsys.readouterr() self.assertIn("(changed)", output) def test_print_output(self): @@ -1046,7 +1053,7 @@ def test_print_output(self): make_files(d) self.cog.callable_main(["argv0", "-rP", "test.cog"]) self.assertFilesSame("test.cog", "test.out") - output = self.output.getvalue() + output, errput = self.capsys.readouterr() self.assertIn("(changed)", output) def test_wildcards(self): @@ -1106,7 +1113,7 @@ def test_wildcards(self): self.assertFilesSame("test.cog", "test.out") self.assertFilesSame("test2.cog", "test.out") self.assertFilesSame("not_this_one.cog", "not_this_one.out") - output = self.output.getvalue() + output, errput = self.capsys.readouterr() self.assertIn("(changed)", output) def test_output_file(self): @@ -1179,7 +1186,7 @@ def test_at_file(self): self.cog.callable_main(["argv0", "-r", "@cogfiles.txt"]) self.assertFilesSame("one.cog", "one.out") self.assertFilesSame("two.cog", "two.out") - output = self.output.getvalue() + output, errput = self.capsys.readouterr() self.assertIn("(changed)", output) def test_nested_at_file(self): @@ -1225,7 +1232,7 @@ def test_nested_at_file(self): self.cog.callable_main(["argv0", "-r", "@cogfiles.txt"]) self.assertFilesSame("one.cog", "one.out") self.assertFilesSame("two.cog", "two.out") - output = self.output.getvalue() + output, errput = self.capsys.readouterr() self.assertIn("(changed)", output) def test_at_file_with_args(self): @@ -1392,7 +1399,7 @@ def run_with_verbosity(self, verbosity): self.cog.callable_main( ["argv0", "-r", "--verbosity=" + verbosity, "@cogfiles.txt"] ) - output = self.output.getvalue() + output, errput = self.capsys.readouterr() return output def test_verbosity0(self): @@ -1479,7 +1486,7 @@ def test_simple(self): make_files(d) self.cog.callable_main(["argv0", "-r", "test.cog"]) self.assertFilesSame("test.cog", "test.out") - output = self.output.getvalue() + output, errput = self.capsys.readouterr() self.assertIn("(changed)", output) def test_file_encoding_option(self): @@ -1504,7 +1511,7 @@ def test_file_encoding_option(self): make_files(d) self.cog.callable_main(["argv0", "-n", "cp1251", "-r", "test.cog"]) self.assertFilesSame("test.cog", "test.out") - output = self.output.getvalue() + output, errput = self.capsys.readouterr() self.assertIn("(changed)", output) @@ -1707,15 +1714,15 @@ def test_warn_if_no_cog_code(self): make_files(d) self.cog.callable_main(["argv0", "-e", "with.cog"]) - output = self.output.getvalue() + output, errput = self.capsys.readouterr() self.assertNotIn("Warning", output) self.new_cog() self.cog.callable_main(["argv0", "-e", "without.cog"]) - output = self.output.getvalue() + output, errput = self.capsys.readouterr() self.assertIn("Warning: no cog code found in without.cog", output) self.new_cog() self.cog.callable_main(["argv0", "without.cog"]) - output = self.output.getvalue() + output, errput = self.capsys.readouterr() self.assertNotIn("Warning", output) def test_file_name_props(self): @@ -1797,7 +1804,7 @@ def test_globals_dont_cross_files(self): self.cog.callable_main(["argv0", "-r", "@cogfiles.txt"]) self.assertFilesSame("one.cog", "one.out") self.assertFilesSame("two.cog", "two.out") - output = self.output.getvalue() + output, errput = self.capsys.readouterr() self.assertIn("(changed)", output) def test_remove_generated_output(self): @@ -1849,7 +1856,7 @@ def test_msg_call(self): """ infile = reindent_block(infile) self.assertEqual(self.cog.process_string(infile), infile) - output = self.output.getvalue() + output, errput = self.capsys.readouterr() self.assertEqual(output, "Message: Hello there!\n") def test_error_message_has_no_traceback(self): @@ -1870,12 +1877,11 @@ def test_error_message_has_no_traceback(self): } make_files(d) - stderr = io.StringIO() - self.cog.set_output(stderr=stderr) self.cog.main(["argv0", "-c", "-r", "cog1.txt"]) - self.assertEqual(self.output.getvalue(), "Cogging cog1.txt\n") + output, errput = self.capsys.readouterr() + self.assertEqual(output, "Cogging cog1.txt\n") self.assertEqual( - stderr.getvalue(), + errput, "cog1.txt(9): Output has been edited! Delete old checksum to unprotect.\n", ) @@ -1934,7 +1940,7 @@ def test_output_to_stdout(self): stderr = io.StringIO() self.cog.set_output(stderr=stderr) self.cog.callable_main(["argv0", "test.cog"]) - output = self.output.getvalue() + output, errput = self.capsys.readouterr() outerr = stderr.getvalue() self.assertEqual( output, "--[[[cog cog.outl('Hey there!') ]]]\nHey there!\n--[[[end]]]\n" @@ -1953,7 +1959,7 @@ def restore_stdin(old_stdin): stderr = io.StringIO() self.cog.set_output(stderr=stderr) self.cog.callable_main(["argv0", "-"]) - output = self.output.getvalue() + output, errput = self.capsys.readouterr() outerr = stderr.getvalue() self.assertEqual(output, "--[[[cog cog.outl('Wow') ]]]\nWow\n--[[[end]]]\n") self.assertEqual(outerr, "") @@ -2083,7 +2089,6 @@ def thread_main(num): class CheckTests(TestCaseWithTempDir): def run_check(self, args, status=0): actual_status = self.cog.main(["argv0", "--check"] + args) - print(self.output.getvalue()) self.assertEqual(status, actual_status) def assert_made_files_unchanged(self, d): @@ -2101,7 +2106,7 @@ def test_check_no_cog(self): } make_files(d) self.run_check(["hello.txt"], status=0) - self.assertEqual(self.output.getvalue(), "Checking hello.txt\n") + self.assertEqual(self.capsys.readouterr(), ("Checking hello.txt\n", "")) self.assert_made_files_unchanged(d) def test_check_good(self): @@ -2116,7 +2121,7 @@ def test_check_good(self): } make_files(d) self.run_check(["unchanged.cog"], status=0) - self.assertEqual(self.output.getvalue(), "Checking unchanged.cog\n") + self.assertEqual(self.capsys.readouterr(), ("Checking unchanged.cog\n", "")) self.assert_made_files_unchanged(d) def test_check_bad(self): @@ -2131,9 +2136,9 @@ def test_check_bad(self): } make_files(d) self.run_check(["changed.cog"], status=5) - self.assertEqual( - self.output.getvalue(), "Checking changed.cog (changed)\nCheck failed\n" - ) + output, errput = self.capsys.readouterr() + self.assertEqual(output, "Checking changed.cog (changed)\n") + self.assertEqual(errput, "Check failed\n") self.assert_made_files_unchanged(d) def test_check_mixed(self): @@ -2154,19 +2159,20 @@ def test_check_mixed(self): """, } make_files(d) - for verbosity, output in [ - ("0", "Check failed\n"), - ("1", "Checking changed.cog (changed)\nCheck failed\n"), + for verbosity, output, errput in [ + ("0", "", "Check failed\n"), + ("1", "Checking changed.cog (changed)\n", "Check failed\n"), ( "2", - "Checking unchanged.cog\nChecking changed.cog (changed)\nCheck failed\n", + "Checking unchanged.cog\nChecking changed.cog (changed)\n", + "Check failed\n", ), ]: self.new_cog() self.run_check( ["--verbosity=%s" % verbosity, "unchanged.cog", "changed.cog"], status=5 ) - self.assertEqual(self.output.getvalue(), output) + self.assertEqual(self.capsys.readouterr(), (output, errput)) self.assert_made_files_unchanged(d) def test_check_with_good_checksum(self): @@ -2186,7 +2192,7 @@ def test_check_with_good_checksum(self): make_files(d) # Have to use -c with --check if there are checksums in the file. self.run_check(["-c", "good.txt"], status=0) - self.assertEqual(self.output.getvalue(), "Checking good.txt\n") + self.assertEqual(self.capsys.readouterr(), ("Checking good.txt\n", "")) self.assert_made_files_unchanged(d) def test_check_with_bad_checksum(self): @@ -2207,8 +2213,11 @@ def test_check_with_bad_checksum(self): # Have to use -c with --check if there are checksums in the file. self.run_check(["-c", "bad.txt"], status=1) self.assertEqual( - self.output.getvalue(), - "Checking bad.txt\nbad.txt(9): Output has been edited! Delete old checksum to unprotect.\n", + self.capsys.readouterr(), + ( + "Checking bad.txt\n", + "bad.txt(9): Output has been edited! Delete old checksum to unprotect.\n", + ), ) self.assert_made_files_unchanged(d) @@ -2619,8 +2628,9 @@ def test_error_call_has_no_traceback(self): make_files(d) self.cog.main(["argv0", "-r", "error.cog"]) - output = self.output.getvalue() - self.assertEqual(output, "Cogging error.cog\nError: Something Bad!\n") + self.assertEqual( + self.capsys.readouterr(), ("Cogging error.cog\n", "Error: Something Bad!\n") + ) def test_real_error_has_traceback(self): # Test that a genuine error does show a traceback. @@ -2635,12 +2645,10 @@ def test_real_error_has_traceback(self): make_files(d) self.cog.main(["argv0", "-r", "error.cog"]) - output = self.output.getvalue() - msg = "Actual output:\n" + output - self.assertTrue( - output.startswith("Cogging error.cog\nTraceback (most recent"), msg - ) - self.assertIn("RuntimeError: Hey!", output) + output, errput = self.capsys.readouterr() + self.assertEqual(output, "Cogging error.cog\n") + self.assertRegex(errput, r"^Traceback \(most recent") + self.assertIn("RuntimeError: Hey!", errput) # Things not yet tested: diff --git a/docs/running.rst b/docs/running.rst index 2008dc3..a215693 100644 --- a/docs/running.rst +++ b/docs/running.rst @@ -10,15 +10,13 @@ Cog is a command-line utility which takes arguments in standard form. import io import textwrap - from cogapp import Cog + import sys + from subprocess import check_output, STDOUT print("\n.. code-block:: text\n") - outf = io.StringIO() - print("$ cog -h", file=outf) - cog = Cog() - cog.set_output(stdout=outf, stderr=outf) - cog.main(["cog", "-h"]) - print(textwrap.indent(outf.getvalue(), " ")) + cmd_text = "$ cog -h" + helptext = check_output([sys.executable, "-m", "cogapp", "-h"], stderr=STDOUT, text=True) + print(textwrap.indent(f"{cmd_text}\n{helptext}", " ")) .. }}} .. code-block:: text @@ -26,46 +24,57 @@ Cog is a command-line utility which takes arguments in standard form. $ cog -h cog - generate content with inlined Python code. - cog [OPTIONS] [INFILE | @FILELIST | &FILELIST] ... - - INFILE is the name of an input file, '-' will read from stdin. - FILELIST is the name of a text file containing file names or - other @FILELISTs. - - For @FILELIST, paths in the file list are relative to the working - directory where cog was called. For &FILELIST, paths in the file - list are relative to the file list location. - - OPTIONS: - -c Checksum the output to protect it against accidental change. - -d Delete the generator code from the output file. - -D name=val Define a global string available to your generator code. - -e Warn if a file has no cog code in it. - -I PATH Add PATH to the list of directories for data files and modules. - -n ENCODING Use ENCODING when reading and writing files. - -o OUTNAME Write the output to OUTNAME. - -p PROLOGUE Prepend the generator source with PROLOGUE. Useful to insert an - import line. Example: -p "import math" - -P Use print() instead of cog.outl() for code output. - -r Replace the input file with the output. - -s STRING Suffix all generated output lines with STRING. - -U Write the output with Unix newlines (only LF line-endings). - -w CMD Use CMD if the output file needs to be made writable. - A %s in the CMD will be filled with the filename. - -x Excise all the generated output without running the generators. - -z The end-output marker can be omitted, and is assumed at eof. - -v Print the version of cog and exit. - --check Check that the files would not change if run again. - --markers='START END END-OUTPUT' - The patterns surrounding cog inline instructions. Should - include three values separated by spaces, the start, end, - and end-output markers. Defaults to '[[[cog ]]] [[[end]]]'. - --verbosity=VERBOSITY - Control the amount of output. 2 (the default) lists all files, - 1 lists only changed files, 0 lists no files. - -h Print this help. - -.. {{{end}}} (checksum: 159e7d7aebb9dcc98f250d47879703dd) + usage: cog [OPTIONS] [INFILE | @FILELIST | &FILELIST] ... + + INFILE is the name of an input file, '-' will read from stdin. FILELIST is the + name of a text file containing file names or other @FILELISTs. + + For @FILELIST, paths in the file list are relative to the working directory + where cog was called. For &FILELIST, paths in the file list are relative to + the file list location. + + positional arguments: + [INFILE | @FILELIST | &FILELIST] + + options: + -h, --help show this help message and exit + -c Checksum the output to protect it against accidental + change. + -d Delete the generator code from the output file. + -D name=val Define a global string available to your generator + code. + -e Warn if a file has no cog code in it. + -I PATH Add PATH to the list of directories for data files and + modules. + -n ENCODING Use ENCODING when reading and writing files. + -o OUTNAME, --output OUTNAME + Write the output to OUTNAME. + -p PROLOGUE Prepend the generator source with PROLOGUE. Useful to + insert an import line. Example: -p "import math" + -P Use print() instead of cog.outl() for code output. + -r Replace the input file with the output. + -s STRING Suffix all generated output lines with STRING. + -U Write the output with Unix newlines (only LF line- + endings). + -w CMD Use CMD if the output file needs to be made writable. + A %s in the CMD will be filled with the filename. + -x Excise all the generated output without running the + generators. + -z The end-output marker can be omitted, and is assumed + at eof. + -v Print the version of cog and exit. + --check Check that the files would not change if run again. + --markers 'START END END-OUTPUT' + The patterns surrounding cog inline instructions. + Should include three values separated by spaces, the + start, end, and end-output markers. Defaults to + '[[[cog ]]] [[[end]]]'. + --verbosity VERBOSITY + Control the amount of output. 2 (the default) lists + all files, 1 lists only changed files, 0 lists no + files. + +.. {{{end}}} (checksum: db115c6046601f93868d3d18d231e4a8) In addition to running cog as a command on the command line, you can also invoke it as a module with the Python interpreter: