9
\$\begingroup\$

Explanation

The script is designed to be run from the command-line, and takes any number of positional arguments as paths of "input" files (it also takes some optional arguments to customize its behavior, such as changing the character sequence that marks the start of a single-line comment). If one of the paths is - (or no positional arguments are given at all), that file will be read from STDIN. The files can end in any extension and should contain source code. It'll then go through these files and strip all single-line comments in them.

As for language, any language whose syntax rules for string literals and single-line comments are similiar to C should work (I've only tested it on Python source files so far).

Lines that only contain a single-line comment would be removed entirely, while lines that contain code and a single-line comment at the end would be modified to remove the comment at the end (and the whitespace before the start of the comment).

Dependencies

The regex module from PyPI, everything else is from the stdlib.

Why I Wrote it

Occasionally, after finishing work on a source file, I would want to remove all comments in it for various reasons. Maybe I decided the comments I wrote weren't very helpful and want to start over, or maybe the script never has to be read or modified again anyway. Going through the file manually to remove all single-line comments is very tedious, so I decided to write this script to automate the process and save some time.

Why I would like a review

I usually use object-oriented-programming for structuring and writing my full-sized projects, and in this script I decided to use functional programming instead, which I'm not as familiar with. So I would like to know how my code looks.

Also, I'm mostly a self-taught programmer (I'm still a student by the way, and haven't started university yet), so I have no idea how my code quality is. Hopefully it's not horrible.

I followed PEP8 as closely as I could (as I do for all my projects, single-file scripts or full-sized projects), and also kept the column width to less than or equal to 80 characters as PEP8 recommends. The code is also fully type-annotated and passes Pyright's type-check.

Source File

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
This is a simple command-line utility that takes an arbitrary number of
paths to source code files as positional arguments, and will strip all
single-line comments in those files. The character sequence that signifies a
comment can be configured via an optional argument, so it can be used for most
programming languages.
"""
import typing as tp
import platform
import pathlib
import fileinput
import contextlib
import argparse
import regex
import os
import sys
if tp.TYPE_CHECKING:
    import types
BACKUP_EXT = ".backup"
stmt_parser: regex.Pattern[str]
args: argparse.Namespace
ExcType = tp.TypeVar("ExcType", bound=Exception)

# Matches a single valid C-like single or double quoted string literal,
#     such as "Dick's sister said: \"But what about Timmy?\"" or "backslash: \\"
# This is useful on its own.
# The group <begin_quote> stores the type of quotes that the string uses if it
#     matches (either ' or ").
str_lit = (
    r"^(?P<str_lit>(?P<begin_quote>(?P<single_quote>')|\")"
    r"(?:(?(single_quote)\"|')|\\[\"']|\\[^\r\n ]|[^\"'\\\r\n])*?"
    r"(?P=begin_quote))$"
)

# Matches any sequence of characters that are allowed outside a string literal.
# Not very useful on its own as it matches indentation as well.
# Replacement field is for comment char sequence (like '#').
non_str_lit = r"(?P<non_str_lit>(?:(?!{})[^\"'\r\n])*?)"

# Matches a line of valid C-like code into the groups <indentation>, <stmt>,
# <whitespace>, <comment>, and <line_ending>. Lines with unclosed string
# literals won't match.
# Groups <comment> and <line_ending> can be missing if the code doesn't have it.
# Other groups can be empty.
stmt = (
    r"^(?P<indentation>(?P<sp_or_tab>[ \t])*+)"
    r"(?P<stmt>{non_str_lit}(?:{str_lit}(?&non_str_lit))*?)"
    r"(?P<whitespace>(?&sp_or_tab)*+)"
    r"(?P<comment>(?:{comment_seq_escaped})[^\r\n]*+)?"
    r"(?P<line_ending>\r\n|\r|\n)?$"
)

# Matches a line that is empty except for the line ending. This is used to skip
# empty source lines to save time, as 'stmt' matches empty lines.
empty = regex.compile(r"^(\r|\n|\r\n)?$")


def setup_regex_patterns(comment_seq: str) -> None:
    """Takes the character sequence that denotes the start of a single-line
    comment, and sets up regex patterns in-place."""
    global non_str_lit, stmt, stmt_parser
    comment = regex.escape(comment_seq)
    non_str_lit = non_str_lit.format(comment)
    stmt = stmt.format(non_str_lit=non_str_lit, str_lit=str_lit[1:-1],
                       comment_seq_escaped=comment)
    stmt_parser = regex.compile(stmt)


def setup_cmdline_interface() -> argparse.ArgumentParser:
    """Sets up an 'ArgumentParser' instance and returns it."""
    
    def directory(arg: str) -> pathlib.Path:
        """Converts string argument to a 'pathlib.Path' object if and only if
        it is a valid existing directory on the filesystem."""
        # Expand "~" and convert to absolute path.
        try:
            # '.resolve()' can raise OSError or RuntimeError if path is invalid.
            path = pathlib.Path(arg).expanduser().resolve()
        except (OSError, RuntimeError):
            raise ValueError from None
        try:
            # '.is_dir()' can *also* raise an error on insufficient perms.
            is_a_dir = path.is_dir()
        except OSError:
            raise ValueError from None
        if is_a_dir:
            return path
        else:
            raise ValueError
    
    parser = argparse.ArgumentParser(
        description="A simple command-line utility for stripping out "
                    "all single-line comments from one or more source code "
                    "files. Input will be read from STDIN for one file if "
                    "that file's name is \"-\", or if no files are given. For "
                    "files read from STDIN, the stripped output would be sent "
                    "to STDOUT. For the rest, the output would be written to "
                    "new files whose names are the original filenames plus a "
                    "suffix (writing to the original files can be enabled)\n\n"
                    "All files are read and written with UTF-8 encoding.")
    parser.add_argument(
        "files", nargs="*", type=os.path.expanduser,
        help="One or more source files whose comments will be stripped.")
    parser.add_argument(
        "-d", "--directory", type=directory, default=".",
        help="The directory where stripped files are outputted. Defaults to "
             "the current working directory.")
    parser.add_argument(
        "-c", "--comment", default="#",
        help="The character sequence that the source file(s) uses to signify "
             "single-line comments. Defaults to \"#\", which is what Python "
             "uses.")
    parser.add_argument(
        "-s", "--suffix", default="_stripped",
        help="The suffix to add to the original filename(s) for output files. "
             "Defaults to \"_stripped\".")
    group = parser.add_mutually_exclusive_group()
    group.add_argument(
        "-o", "--overwrite", action="store_true",
        help="If the output filename of a file clashes with an existing file, "
             "output would be skipped for that file to prevent data loss. "
             "Specify this flag to override this behavior.")
    group.add_argument(
        "-i", "--in-place", action="store_true",
        help="This would strip the files in-place, instead of writing the "
             "stripped versions to separate files. The contents of the original"
             " files would be temporarily backed up, and would be restored "
             "if an IO error is encountered before the stripping is completed.")
    return parser


class HandleStdoutClose:
    def __init__(self) -> None:
        """Context manager that allows the script to exit without errors if its
        STDOUT output is cut off by an external program (such as a pager). The
        code should not attempt to output anything after exiting this context.
        """
        self.win32 = (platform.system() == "Windows")

    def _check_exception(self, exc: Exception) -> bool:
        """Determines if the exception was caused by a broken pipe,
        in a platform-dependent way."""
        if self.win32:
            # This is the exception Python raises on SIGPIPE on Windows.
            # OSError: [Errno 22] Invalid argument
            return isinstance(exc, OSError) and exc.errno == 22
        else:
            # This is the exception Python raises on SIGPIPE on Linux.
            return isinstance(exc, BrokenPipeError)

    def _try_flush(self) -> bool:
        """Flushes the standard output and returns whether SIGPIPE was
        encountered."""
        try:
            sys.stdout.flush()
        except (OSError, BrokenPipeError) as err:
            return self._check_exception(err)
        else:
            return False

    def __enter__(self) -> None:
        return

    @tp.overload
    def __exit__(self, exc_type: None, exc_value: None,
                 traceback: None) -> bool:
        ...

    @tp.overload
    def __exit__(self, exc_type: tp.Type[ExcType], exc_value: ExcType,
                 traceback: "types.TracebackType") -> bool:
        ...
    
    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type is None:
            return False
        elif not self._check_exception(exc_value) and not self._try_flush():
            # Only 'BrokenPipeError' should be suppressed by context manager.
            return False
        devnull = os.open(os.devnull, os.O_WRONLY)
        os.dup2(devnull, sys.stdout.fileno())
        return True  # Suppress the exception.


@tp.overload
def process(line: tp.Union[str, bytes], out: tp.BinaryIO,
            is_stdin: tp.Literal[False]) -> None:
    ...


@tp.overload
def process(line: tp.Union[str, bytes], out: tp.TextIO,
            is_stdin: tp.Literal[True]) -> None:
    ...


def process(line, out, is_stdin):
    """Process a single line and writes modified version to output file."""
    def sub_func(match: regex.Match[str]) -> str:
        if match.group("stmt"):
            # If statement is non-empty, strip comment and keep line-ending.
            return "".join(match.group("indentation", "stmt", "line_ending"))
        else:
            # Line is comment-only, remove entire line.
            return ""
    
    # When reading from STDIN, line *should* be bytes because we opened
    # 'fileinput.input' with mode 'rb', but line *can* be 'r' if script is being
    # run in a pseudo-terminal like IDLE.
    if isinstance(line, bytes):
        line = tp.cast(str, line.decode("utf-8"))
    else:
        line = tp.cast(str, line)
    if empty.fullmatch(line):  # line is empty except for line ending.
        # Empty lines do not have to be changed.
        filtered_line = line
    else:
        # If <line_ending> is 'None', it would be replaced with an empty string.
        filtered_line = stmt_parser.sub(
            sub_func, line, count=1
        )
    # Should output as 'str' if reading from STDIN.
    if not is_stdin:
        filtered_line = filtered_line.encode("utf-8")
    out.write(filtered_line)


def switch_file(prev_file: tp.Optional[str], filename: str,
                close_only: bool = False
                ) -> tp.Optional[tp.Union[tp.TextIO, tp.BinaryIO]]:
    """Initializes the output file for each input. Should be called everytime a
    new file has started being read, including STDIN."""
    if not close_only and (fileinput.isstdin() or args.in_place):
        # If file is being read from STDIN or in-place filtering, return STDOUT.
        return sys.stdout
    if prev_file is not None:
        # The previous input file has been successfully processed,
        #     delete the backup file.
        with contextlib.suppress(OSError):
            # May raise an exception on Windows if another process opened the
            #     backup file for some reason.
            os.remove(prev_file + BACKUP_EXT)
        if close_only:
            return
    # Output filepath generation procedure:
    # args.directory → Path("path/to/some/directory")
    # filename → "path/to/some/file.txt"
    # new_filename = "path/to/some/file" + "_stripped" + ".txt"
    # out_path = Path(Path("path/to/some/directory"),
    #                 "path/to/some/file_stripped.txt")
    base, ext = os.path.splitext(filename)
    new_filename = "".join((base, args.suffix, ext))
    out_path = pathlib.Path(args.directory, new_filename)
    if args.overwrite:
        return out_path.open(mode="wb")
    else:
        # If user doesn't want silent over-write, then be more careful.
        try:
            file_obj = out_path.open(mode="xb")
        except (OSError, FileExistsError):
            # File already exists, abort instead of overwriting.
            return
        else:
            return file_obj


def reader(files: fileinput.FileInput) -> None:
    """Main logic for reading through files and creating the filtered version.
    Returns a bool indicating whether to skip to the next input file."""
    def read_line(line: tp.Union[str, bytes]) -> bool:
        nonlocal out_file, current_file
        if tp.TYPE_CHECKING:
            out_file = tp.cast(tp.Union[tp.TextIO, tp.BinaryIO], out_file)
        if fileinput.isfirstline():
            if out_file is not None and current_file != "<stdin>":
                # If previous file was read from STDIN, don't close it.
                out_file.close()
            # We just read the first line of a new file. Prepare out file.
            out_file = switch_file(
                current_file,
                current_file := ("<stdin>" if fileinput.isstdin()
                                 else fileinput.filename()))
            if out_file is None:
                return True
        if fileinput.isstdin():
            process(line, tp.cast(tp.TextIO, out_file), True)
        else:
            process(line, tp.cast(tp.BinaryIO, out_file), False)
        return False

    def restore_backup() -> None:
        nonlocal current_file
        if tp.TYPE_CHECKING:
            current_file = tp.cast(str, current_file)
        with contextlib.suppress(OSError):
            # Attempt to restore original file.
            os.replace(current_file + BACKUP_EXT, current_file)
    
    out_file: tp.Optional[tp.Union[tp.TextIO, tp.BinaryIO]] = None
    current_file: tp.Optional[str] = None
    # Input would be str if read from stdin.
    line: tp.Union[str, bytes]
    # 'file_input.__next__' can possibly error at the start of every iteration.
    # 'read_line' can also possibly error every iteration.
    while True:
        try:
            # Read a new line from the files.
            for line in files:
                try:
                    abort = read_line(line)
                except (OSError, UnicodeDecodeError):
                    restore_backup()
                else:
                    if abort:
                        fileinput.nextfile()
            break
        except OSError:
            # Problematic file, skip to next one.
            fileinput.nextfile()
    if current_file is not None:
        # delete backup of last file processed
        switch_file(current_file, "", close_only=True)


def iter_files() -> None:
    # Add comment character sequence to regex.
    setup_regex_patterns(args.comment)
    # 'args.files' is of type 'tp.List[str]', can possibly be empty.
    with fileinput.input(
            files=args.files, inplace=args.in_place,
            backup=BACKUP_EXT if args.in_place else "",
            mode="rb") as f:
        reader(f)


def main() -> None:
    global args
    args = setup_cmdline_interface().parse_args()
    with HandleStdoutClose():
        iter_files()


if __name__ == "__main__":
    main()
\$\endgroup\$
8
  • 2
    \$\begingroup\$ This is certainly reviewable, but the upper-level motivation of obliterating all source comments is highly dubious. Generally speaking, I would only ever trust a manual process for that. \$\endgroup\$
    – Reinderien
    Commented Aug 29 at 12:05
  • \$\begingroup\$ @Reinderien that may seem strange indeed but I have had that need sometimes, for example if I want to to publish code while avoiding attribution or even doxxing. Also when packaging software for embedded devices. On microcontrollers the available storage is sometimes so tiny that stripping inline doc and comments is a sensible thing to do. \$\endgroup\$
    – Kate
    Commented Aug 29 at 13:10
  • 4
    \$\begingroup\$ @Kate Not really, no: compiled code does not increase in size (embedded or otherwise) in the presence of comments. Perhaps you mean that you're storing source on an embedded system, but generally that shouldn't be done at all. \$\endgroup\$
    – Reinderien
    Commented Aug 29 at 14:16
  • 1
    \$\begingroup\$ I suggest adding tests. Separating the logic from the command line interface makes it easier to do this. \$\endgroup\$
    – ggorlen
    Commented Aug 29 at 14:37
  • 1
    \$\begingroup\$ @HaiVu As I have stated in the question (under the heading Dependencies), this script requires The regex module from PyPI, not the re module from the standard library (which doesn't support subroutine calls in regular expressions). So you have to run pip3 install regex in a terminal (or pip if you're on Windows instead of Linux), instead of changing the script to use re instead of regex. \$\endgroup\$ Commented Aug 31 at 3:08

5 Answers 5

5
\$\begingroup\$

black

followed PEP8 as closely ...

I wouldn't worry too much about it, given that you have already chosen nice_identifier_names. Just run "$ black *.py" (and "$ isort *.py") and be done with it, no need to give it a second thought. Being PEP-compliant does make things easier on the reader, and it worked out just fine in this case. Except for the part about import regex being confusingly mixed in with "batteries included" modules, contrary to advice, which goes a long way toward explaining commenter @HaiVu's PyPI confusion.

The shebang is nice, but the "coding: utf-8" line is just weird, given that it has been the standard encoding since interpreter 2.7 became python 3.

conditional import for mypy

if tp.TYPE_CHECKING:
    import types

It's not obvious why we shouldn't just unconditionally import this. Sure it takes another millisecond, but it's not that bad. The added complexity doesn't seem warranted.

doctest

This is a wonderfully informative comment; thank you.

# Matches a single valid C-like single or double quoted string literal,
#     such as "Dick's sister said: \"But what about Timmy?\"" or "backslash: \\"

But it would be much nicer to package up the str_lit assignment as a defined function, one which could have a """docstring""" attached to it. And then we could write >>> examples which are both human readable and machine readable. I love to read comment documentation, and sometimes I believe it. I place greater stock in examples which have been automatically executed and verified. Similarly for the other regexes.

There are several capturing groups which have helpful names associated with them. The regex is complex enough that it may be worth adopting the multiline style of defining a regex, so there's room for tacking on # comments in the middle, perhaps with example input text.

Maybe regex isn't the best tool for the task, and we'd be happier with an RD parser for this grammar.

Specifically for *.py python source, you could exploit the ast, which already knows all the parsing rules.

Preconditioning input lines so we strip SPACE, CRLF, and LF at end-of-line seems reasonable, so the regexes needn't even worry about that.

class

I usually use object-oriented-programming ...

def setup_regex_patterns( ... ) -> None: ...
    global non_str_lit, stmt, stmt_parser

Prefer to assign self.stmt and similar, rather than using module-level globals.

Side effecting stmt seems adventurous and more difficult to reason about than necessary, especially since the comment merely mentions "sets up" without details.

... , str_lit=str_lit[1:-1], ...

It's not obvious to me why that's correct, given that some of the input files of interest will contain """triple quoted""" text. Again, relying on a parser may make reasoning about correctness a little easier.

nested function

def setup_cmdline_interface() -> argparse.ArgumentParser: ...
    
    def directory( ... ) -> ...:

Breaking out a directory helper is lovely, and it has a well defined interface. But consider putting def _directory() at top level of this module instead. Then it will be accessible to an automated unit test, which can exercise that interface. Similarly for read_line() and the ill-named sub_func().

I do like the docstring, and also the DbC approach to returning a good result when feasible, else raise.

I'm not sure what the raise ValueError from None is all about. It's enough to raise ValueError. Better, consider raise ValueError(arg) so a maintenance engineer can more easily see what provoked the error. Consider eliding the try, so the error just naturally bubbles up the call stack. Not sure why you don't chain the raised error to the underlying error, as that includes helpful details such as line number where things fell apart. Maybe mapping everything to ValueError is convenient for callers? But it's not clear that it's a win overall.

magic number

            # OSError: [Errno 22] Invalid argument
            return isinstance(exc, OSError) and exc.errno == 22

This is accurate.

But it would be nicer to "import errno" and rely on its symbolic definition of 22.

wrong comment

    # 'fileinput.input' with mode 'rb', but line *can* be 'r' if script is being

Looks like you intended str rather than 'r'. Or perhaps you meant to comment on the open() mode.

Also, I don't see why we need the tp.cast( ... ) calls. Just assign str(line) or line.decode("utf-8") and be done with it; mypy will figure it out. I'm sad the function signature doesn't describe the input parameter types -- use "$ mypy --strict *.py" for help with that. Generally, we should have settled on encoded vs decoded prior to even calling the process() function, so it would be less complex and would be doing just one thing.

modern interpreter

def switch_file(prev_file: tp.Optional[str], ... ,
                ...
                ) -> tp.Optional[tp.Union[tp.TextIO, tp.BinaryIO]]:

Prefer the | notation for Unions:

def switch_file(prev_file: str | None, ... ,
                ...
                ) -> tp.TextIO | tp.BinaryIO | None:

If there's some unusual requirement to support ancient interpreters, then document it in the source text.

objects are nouns; functions are verbs

def reader( ... ) -> None:

Prefer a name like read() for this. Or perhaps read_(), if you were worried about shadowing an existing read symbol. (By default there isn't one.) Or a more verbose read_files().

tricky code

            out_file = switch_file(
                current_file,
                current_file := ("<stdin>" if fileinput.isstdin()
                                 else fileinput.filename()))

Avoid abusing the := walrus operator in this way. It has its uses, but this isn't one of them. There's a reason pythonistas choose not to code in C.

The "is it text?" vs "is it binary?" uncertainty continues to be an issue in this function. Better to add a layer which resolves it once and for all, for all the downstream functions.

reliability

Silently suppressing OSError seems adventurous. As an end user it would be difficult for me to put much faith in the correct execution of this script, as reader locks held by other processes could, seemingly at random, silently alter its behavior.

\$\endgroup\$
4
\$\begingroup\$

tp is not a helpful (or commonly-seen) alias for typing; I would just spell that out in full.

For str_lit, you really should expand that to a multi-line r'''(?x)...''' regular expression in verbose style with trailing comments. The same goes for your other regular expressions; they'll be very difficult to verify and maintain in their current form.

This:

        except (OSError, RuntimeError):
            raise ValueError from None

actively harms the traceback. It replaces a sensible exception type with an unrelated exception type, and explicitly destroys the inner trace and message. Better to just delete this.

You say that the code you're parsing is C-like. Regular expressions would not be the first thing I reach for when parsing this. There are existing libraries that offer you ASTs that have been highly validated by the open-source community, including (probably) clang/llvm, though I have not tried this myself.

\$\endgroup\$
1
  • \$\begingroup\$ The issue you raise about the exception traceback feels like a moot point in this context since argparse will swallow the traceback anyways and only display the error message. I would be more concerned about 1) keeping the message to explain why accessing the directory is not possible and 2) using a proper argparse.ArgumentTypeError instead of relying on the "convenient" conversion from ValueError… explicit is better than implicit. \$\endgroup\$ Commented Sep 4 at 8:23
3
\$\begingroup\$

Encoding

This is not required in Python3:

# -*- coding: utf-8 -*-

Verbosity

It would be desirable to add an optional -v flag to follow execution and program logic. Use the logging module to selectively output messages. Consider sending error messages to stderr, so they can be intercepted but don't pollute the console.

Return code

Since this is a CLI tool I find it desirable to return an appropriate exit code. In case of issue or exception, the script should return a non-zero value. As an example, sed has the following standard exit codes: Exit status

Logic

When I see a function that is named iter_files I immediately expect that i would return an iterator of some sort. This is not the case. Inside that function you call a function named reader. That function does not behave like an iterator either. Again, I am thinking about related function names like csvreader in Python but your functions defy expectations (at least mine).

In your main routine you have this:

with HandleStdoutClose():
    iter_files()

Whereas I would expect something more like:

def main() -> None:
    with HandleStdoutClose():
        for file in iter_files():
            # do something

and another iterator to consume your files line by line. Basically a nested loop with a try block, so you can handle exceptions as appropriate.

It seems to me that some functions are maybe doing too much. I would probably write a separate function to create the backup file if desired, and another function for patching the file proper. So your functions would become more focused, smaller and thus easier to maintain.

Moreover, inside your reader function there are two embedded functions: read_line, restore_backup. To me it's pretty clear that the latter at least has nothing to do here.

Context manager

You obviously understand the concept since you've written your own. But you should have used the same approach for opening files/streams. Then you wouldn't bother closing them if they are != stdin.

File access

I saw this in the code:

with contextlib.suppress(OSError):
    # May raise an exception on Windows if another process opened the
    #     backup file for some reason.
    os.remove(prev_file + BACKUP_EXT)

I have no idea if this code has been tested but it got me thinking: you might want to obtain an exclusive lock on the file being processed. Possibly you'll need fcntl or a specialized cross-platform library such as filelock.

In a scenario like this, I would probably use the tempfile lib in Python to create a temp file, write to it, and overwrite the original file at the very end.

Misc

I have run the script against itself, and it seems to do what's intended, that is strip # by default. But the biggest limitation is that it only handles single line comments. Docstrings should be handled separately. So as a Python programmer I see limited value as-is.

I have tried the script against a C file with a mix of /* */ and // comment lines. This seems to work at first glance, if the prefix is set on the command line. But only one pattern is processed at a time.

Some usage samples would be welcome in a readme file perhaps, so as to lift any ambiguity and better underline the limitations of the tool.

Regular expressions tend to be slow. It seems to me that it's more reasonable to rely on an AST lib or a tokenizer of some sort. Given the lack of verbosity and the absence of exit code I find this script to be somewhat unpredictable.

\$\endgroup\$
3
\$\begingroup\$

Too much annotations

The author put too much focus on type annotations. As the result, the code is highly cluttered. I am not advocating to remove them all: We should keep those which make the code more readable. Of course, what to keep is highly objective.

Remove commented out code

I understand that during the course of development, we keep lots of commented code. I usually put a TODO at the top of the script to remind me:

# TODO: Remove commented out code before `git submit`

Function process()

The name process is too generic and it takes too many parameters. My recommendation is

  • Rename it to something more descriptive
  • Take in only 1 single parameter: the line
  • The line should be string, not bytes. It is the caller's responsibility to convert bytes to string and vice versa. This function should just work on a line and not concern with writing the output out.

Function switch_file()

If we write simple function which operates on a single file at a time, I believe we can drop this function. Please see my comment on function reader()

Too much comment

In my opinion this is a symptom of complicated code. Simple code don't need comment

class HandleStdoutClose

  • We should not need this class
  • It is more dangerous to hide the errors than to show them

Should not remove the magic (shebang) line

The shebang line

#!/usr/bin/env python3

is not a regular comment. We should preserve it

Function reader

This function is very complicated:

  • It include 2 nested functions read_line() and restore_backup()
  • It also calls process() and switch_file()

I would like to see moving the two nested functions out to global scope. Also, the functions should communicate via parameters passing, not global or nonlocal mechanism.

In my opinion, the most obvious issue is this function try to to process multiple files. I would rather code a function which process a single file, then call that function in a loop. For example:

def strip_comment(
        file_name: pathlib.Path,
        inplace: bool,
        backup_extension: str,
):
    """
    Remove comments from the a file and write the result into another file.
    """
    ...

Then in function main(), we would call it for each file:

for file_name in args.files:
    strip_comment(file_name, args.in_place, BACKUP_EXT)

This way, we might be able to eliminate function read_line() because the bulk of read_line() is to transition from one file to another.

Function iter_files()

  • It should take the args as a function argument
  • However, ultimately, I would delete this function and move its content into function main
    • One less function
    • Code in main is already minimal, we are not afraid making it overly crowded

Other Issues

  • We should not mix os.path and pathlib. Use one or the other

  • Bug: lines like the following could cause trouble:

      print("what about # within a string?")
    
  • black and isort (ruff for me) are your friends. Use them.

\$\endgroup\$
1
  • \$\begingroup\$ Good point about the shebang, not all comments are made equal. \$\endgroup\$
    – Kate
    Commented Aug 31 at 21:36
3
\$\begingroup\$

It's not possible to determine if a line is comment or not without looking at the surrounding lines.

# Some text here #

Is this a comment?

header ="""
    ##################
    # Some text here #
    ##################
"""

print(header)

Or part of a string?

You need a parser to parse code.

"""

header ="""
    ##################
    # Some text here #
    ##################
"""

print(header)


"""

print("https://xkcd.com/1171/")
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.