Skip to content

Instantly share code, notes, and snippets.

@ertgl
Last active October 17, 2025 21:28
Show Gist options
  • Select an option

  • Save ertgl/b675bdc97e2396f0eb8cf8a226e33e65 to your computer and use it in GitHub Desktop.

Select an option

Save ertgl/b675bdc97e2396f0eb8cf8a226e33e65 to your computer and use it in GitHub Desktop.

Revisions

  1. ertgl revised this gist Oct 17, 2025. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion format_exported_py_symbols.py
    Original file line number Diff line number Diff line change
    @@ -1,6 +1,6 @@
    # MIT License
    #
    # Copyright (c) 2025 Ertuğrul Keremoğlu <[email protected]>
    # Copyright (c) 2024 Ertuğrul Keremoğlu <[email protected]>
    #
    # Permission is hereby granted, free of charge, to any person obtaining a copy
    # of this software and associated documentation files (the "Software"), to deal
  2. ertgl revised this gist Oct 14, 2025. 1 changed file with 2 additions and 2 deletions.
    4 changes: 2 additions & 2 deletions format_exported_py_symbols.py
    Original file line number Diff line number Diff line change
    @@ -1,6 +1,6 @@
    # MIT License
    #
    # Copyright (c) 2024 Ertuğrul Keremoğlu <[email protected]>
    # Copyright (c) 2025 Ertuğrul Keremoğlu <[email protected]>
    #
    # Permission is hereby granted, free of charge, to any person obtaining a copy
    # of this software and associated documentation files (the "Software"), to deal
    @@ -28,10 +28,10 @@
    import re
    import sys

    from collections.abc import Iterable
    from contextlib import suppress
    from pathlib import Path
    from typing import (
    Iterable,
    TextIO,
    cast,
    )
  3. ertgl revised this gist Aug 19, 2025. 1 changed file with 6 additions and 5 deletions.
    11 changes: 6 additions & 5 deletions format_exported_py_symbols.py
    Original file line number Diff line number Diff line change
    @@ -251,17 +251,18 @@ def process_source_files() -> int:

    for source_file_path in WORK_DIR.rglob("*.py"):
    source_file_path_str = str(source_file_path)
    relative_file_path = source_file_path.relative_to(WORK_DIR)
    relative_file_path_str = str(relative_file_path)
    if VENV_DIR_SEGMENT_REGEX.search(source_file_path_str):
    ignored.add(source_file_path)
    ignored.add(relative_file_path_str)
    continue
    relative_file_path = source_file_path.relative_to(WORK_DIR)
    status = process_source_file(source_file_path)
    if status == -1:
    unchanged.add(str(relative_file_path))
    unchanged.add(relative_file_path_str)
    elif status != 0:
    errored.add(str(relative_file_path))
    errored.add(relative_file_path_str)
    else:
    reformatted.add(str(relative_file_path))
    reformatted.add(relative_file_path_str)

    output = ""
    sep = ""
  4. ertgl revised this gist Aug 19, 2025. 1 changed file with 19 additions and 2 deletions.
    21 changes: 19 additions & 2 deletions format_exported_py_symbols.py
    Original file line number Diff line number Diff line change
    @@ -25,6 +25,7 @@
    __all__ = ["main"]

    import operator
    import re
    import sys

    from contextlib import suppress
    @@ -38,7 +39,14 @@

    WORK_DIR = Path.cwd()

    VENV_DIR = WORK_DIR / ".venv"
    VENV_DIR_NAME = ".venv"

    VENV_DIR_SEGMENT_REGEX_PATTERN = rf"[\\/]{re.escape(VENV_DIR_NAME)}[\\/]"

    VENV_DIR_SEGMENT_REGEX = re.compile(
    VENV_DIR_SEGMENT_REGEX_PATTERN,
    re.IGNORECASE | re.UNICODE,
    )


    def read(buffer: TextIO) -> str:
    @@ -239,9 +247,12 @@ def process_source_files() -> int:
    reformatted: set[str] = set()
    unchanged: set[str] = set()
    errored: set[str] = set()
    ignored: set[str] = set()

    for source_file_path in WORK_DIR.rglob("*.py"):
    if str(source_file_path).startswith(str(VENV_DIR)):
    source_file_path_str = str(source_file_path)
    if VENV_DIR_SEGMENT_REGEX.search(source_file_path_str):
    ignored.add(source_file_path)
    continue
    relative_file_path = source_file_path.relative_to(WORK_DIR)
    status = process_source_file(source_file_path)
    @@ -268,8 +279,14 @@ def process_source_files() -> int:
    sep = ", "
    if len(errored) == 1:
    output += f"{sep}1 file errored"
    sep = ", "
    elif len(errored) > 1:
    output += f"{sep}{len(errored)!s} files errored"
    sep = ", "
    if len(ignored) == 1:
    output += f"{sep}1 file ignored"
    elif len(ignored) > 1:
    output += f"{sep}{len(ignored)!s} files ignored"

    if not output:
    output = f"{len(unchanged)!s} files left unchanged"
  5. ertgl revised this gist Aug 19, 2025. 1 changed file with 2 additions and 0 deletions.
    2 changes: 2 additions & 0 deletions format_exported_py_symbols.py
    Original file line number Diff line number Diff line change
    @@ -20,6 +20,8 @@
    # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    # SOFTWARE.

    # Source: https://gist.github.com/ertgl/b675bdc97e2396f0eb8cf8a226e33e65

    __all__ = ["main"]

    import operator
  6. ertgl revised this gist Aug 18, 2025. 1 changed file with 38 additions and 23 deletions.
    61 changes: 38 additions & 23 deletions format_exported_py_symbols.py
    Original file line number Diff line number Diff line change
    @@ -29,20 +29,11 @@
    from pathlib import Path
    from typing import (
    Iterable,
    ParamSpec,
    TextIO,
    TypeVar,
    cast,
    )


    P = ParamSpec("P")

    R = TypeVar("R")

    T = TypeVar("T")


    WORK_DIR = Path.cwd()

    VENV_DIR = WORK_DIR / ".venv"
    @@ -243,25 +234,49 @@ def process_source_file(source_file_path: Path) -> int:


    def process_source_files() -> int:
    reformatted: set[str] = set()
    unchanged: set[str] = set()
    errored: set[str] = set()

    for source_file_path in WORK_DIR.rglob("*.py"):
    if str(source_file_path).startswith(str(VENV_DIR)):
    continue
    relative_file_path = source_file_path.relative_to(WORK_DIR)
    status = process_source_file(source_file_path)
    if status == -1:
    write(
    sys.stderr,
    f"{source_file_path}: No exported py symbols found, skipping\n",
    )
    unchanged.add(str(relative_file_path))
    elif status != 0:
    write(
    sys.stderr,
    f"{source_file_path}: Failed to format exported py symbols\n",
    )
    return status
    write(
    sys.stderr,
    f"{source_file_path}: Formatted exported py symbols\n",
    )
    errored.add(str(relative_file_path))
    else:
    reformatted.add(str(relative_file_path))

    output = ""
    sep = ""
    if len(reformatted) == 1:
    output += "1 file reformatted"
    sep = ", "
    elif len(reformatted) > 1:
    output += f"{len(reformatted)!s} files reformatted"
    sep = ", "
    if len(unchanged) == 1:
    output += f"{sep}1 file left unchanged"
    sep = ", "
    elif len(unchanged) > 1:
    output += f"{sep}{len(unchanged)!s} files left unchanged"
    sep = ", "
    if len(errored) == 1:
    output += f"{sep}1 file errored"
    elif len(errored) > 1:
    output += f"{sep}{len(errored)!s} files errored"

    if not output:
    output = f"{len(unchanged)!s} files left unchanged"

    write(sys.stderr, f"{output}\n")

    if errored:
    return 1

    return 0


    @@ -276,4 +291,4 @@ def main(


    if __name__ == "__main__":
    sys.exit(main(argv=sys.argv.copy()))
    sys.exit(main(argv=sys.argv.copy()))
  7. ertgl revised this gist Mar 1, 2025. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion format_exported_py_symbols.py
    Original file line number Diff line number Diff line change
    @@ -1,6 +1,6 @@
    # MIT License
    #
    # Copyright (c) 2024 Ertuğrul Keremoğlu <ertugkeremoglu(at)gmail(.)com>
    # Copyright (c) 2024 Ertuğrul Keremoğlu <ertugkeremoglu@gmail.com>
    #
    # Permission is hereby granted, free of charge, to any person obtaining a copy
    # of this software and associated documentation files (the "Software"), to deal
  8. ertgl revised this gist Jul 31, 2024. 1 changed file with 5 additions and 5 deletions.
    10 changes: 5 additions & 5 deletions format_exported_py_symbols.py
    Original file line number Diff line number Diff line change
    @@ -156,17 +156,17 @@ def replace_exported_symbols(
    symbols: set[tuple[int, str]],
    ) -> str:
    exported_symbols = sorted(set(map(operator.itemgetter(1), symbols)))
    prefix = ""
    suffix = ""
    indent = ""
    if len(exported_symbols) > 1:
    prefix = "\n"
    suffix = ",\n"
    indent = " "
    inner = ",\n".join(
    [f'{indent}"{symbol}"' for symbol in exported_symbols],
    )
    rendered: str
    if len(exported_symbols) > 1:
    rendered = f"\n{inner},\n"
    else:
    rendered = inner
    rendered = f"{prefix}{inner}{suffix}"
    source_chars = list(source)
    source_chars[start[2] + 1 : end[2]] = list(rendered)
    code = "".join(source_chars)
  9. ertgl revised this gist Jul 31, 2024. 1 changed file with 4 additions and 1 deletion.
    5 changes: 4 additions & 1 deletion format_exported_py_symbols.py
    Original file line number Diff line number Diff line change
    @@ -156,8 +156,11 @@ def replace_exported_symbols(
    symbols: set[tuple[int, str]],
    ) -> str:
    exported_symbols = sorted(set(map(operator.itemgetter(1), symbols)))
    indent = ""
    if len(exported_symbols) > 1:
    indent = " "
    inner = ",\n".join(
    [f' "{symbol}"' for symbol in exported_symbols],
    [f'{indent}"{symbol}"' for symbol in exported_symbols],
    )
    rendered: str
    if len(exported_symbols) > 1:
  10. ertgl revised this gist Jul 31, 2024. 1 changed file with 3 additions and 3 deletions.
    6 changes: 3 additions & 3 deletions format_exported_py_symbols.py
    Original file line number Diff line number Diff line change
    @@ -28,9 +28,9 @@
    from contextlib import suppress
    from pathlib import Path
    from typing import (
    IO,
    Iterable,
    ParamSpec,
    TextIO,
    TypeVar,
    cast,
    )
    @@ -48,7 +48,7 @@
    VENV_DIR = WORK_DIR / ".venv"


    def read(buffer: IO) -> str:
    def read(buffer: TextIO) -> str:
    return buffer.read()


    @@ -171,7 +171,7 @@ def replace_exported_symbols(


    def write(
    buffer: IO,
    buffer: TextIO,
    output: str,
    ) -> None:
    buffer.write(output)
  11. ertgl created this gist Jul 31, 2024.
    276 changes: 276 additions & 0 deletions format_exported_py_symbols.py
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,276 @@
    # MIT License
    #
    # Copyright (c) 2024 Ertuğrul Keremoğlu <ertugkeremoglu(at)gmail(.)com>
    #
    # Permission is hereby granted, free of charge, to any person obtaining a copy
    # of this software and associated documentation files (the "Software"), to deal
    # in the Software without restriction, including without limitation the rights
    # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    # copies of the Software, and to permit persons to whom the Software is
    # furnished to do so, subject to the following conditions:
    #
    # The above copyright notice and this permission notice shall be included in all
    # copies or substantial portions of the Software.
    #
    # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    # SOFTWARE.

    __all__ = ["main"]

    import operator
    import sys

    from contextlib import suppress
    from pathlib import Path
    from typing import (
    IO,
    Iterable,
    ParamSpec,
    TypeVar,
    cast,
    )


    P = ParamSpec("P")

    R = TypeVar("R")

    T = TypeVar("T")


    WORK_DIR = Path.cwd()

    VENV_DIR = WORK_DIR / ".venv"


    def read(buffer: IO) -> str:
    return buffer.read()


    def get_export_expression_span(
    source: str,
    ) -> tuple[tuple[int, int, int], tuple[int, int, int]]:
    line_no = 1
    column_no = 0
    start_line_no = -1
    start_column_no = -1
    start_offset = -1
    end_line_no = -1
    end_column_no = -1
    end_offset = -1
    lookbehind: list[tuple[tuple[int, int, int], str]] = []
    prefix = "__all__=["
    prefix_chars = list(prefix)
    for char_index, char in cast(Iterable[tuple[int, str]], enumerate(source)):
    if char == "\n":
    line_no += 1
    column_no = 0
    else:
    column_no += 1
    if start_offset == -1:
    if not (char.isspace() or char == "\\"):
    lookbehind.append(((line_no, column_no, char_index), char))
    if len(lookbehind) > len(prefix_chars):
    lookbehind.pop(0)
    if len(lookbehind) > 0:
    lookbehind_value = list(map(operator.itemgetter(1), lookbehind))
    found_prefix = lookbehind_value == prefix_chars
    is_prefix_at_start = lookbehind[0][0][1] == 1
    if found_prefix and is_prefix_at_start:
    lookbehind.clear()
    start_line_no = line_no
    start_column_no = column_no
    start_offset = char_index
    if start_offset != -1 and char == "]":
    end_line_no = line_no
    end_column_no = column_no
    end_offset = char_index
    break
    start = start_line_no, start_column_no, start_offset
    end = end_line_no, end_column_no, end_offset
    return start, end


    def collect_existing_exported_symbols(source: str) -> set[tuple[int, str]]:
    existing_exported_symbols: set[tuple[int, str]] = set()
    start, end = get_export_expression_span(source)
    if start[2] == -1 or end[2] == -1:
    return existing_exported_symbols
    lines = source[start[2] + 1 : end[2]].split("\n")
    for line_no, line in enumerate(lines):
    for raw_string in line.split(","):
    symbol_line_no = start[0] + line_no
    symbol_name = raw_string.strip()[1:-1].strip()
    if not symbol_name:
    continue
    symbol = (symbol_line_no, symbol_name)
    existing_exported_symbols.add(symbol)
    return existing_exported_symbols


    def collect_imported_symbols(source: str) -> set[tuple[int, str]]:
    imported_symbols: set[tuple[int, str]] = set()
    lines = list(map(lambda line: line.strip(), source.split("\n")))
    for line_no, line in enumerate(lines):
    if line.startswith("from"):
    parts = line.split(" ", maxsplit=3)
    if len(parts) != 4:
    continue
    if parts[2] != "import":
    continue
    if parts[-1] == "(":
    index_of_tuple_end = line_no + lines[line_no:].index(")")
    section = "\n".join(lines[line_no + 1 : index_of_tuple_end])
    symbol_names = [
    symbol_name.strip()
    for symbol_name in section.split(",")
    if symbol_name.strip()
    ]
    for symbol_name_index, symbol_name in enumerate(symbol_names):
    symbol = (line_no + symbol_name_index + 1, symbol_name)
    imported_symbols.add(symbol)
    elif "," in parts[-1]:
    symbol_names = [
    symbol_name.strip()
    for symbol_name in parts[-1].split(",")
    if symbol_name.strip()
    ]
    for symbol_name_index, symbol_name in enumerate(symbol_names):
    symbol = (line_no + symbol_name_index + 1, symbol_name)
    imported_symbols.add(symbol)
    else:
    symbol = (line_no, parts[-1])
    imported_symbols.add(symbol)
    return imported_symbols


    def replace_exported_symbols(
    source: str,
    start: tuple[int, int, int],
    end: tuple[int, int, int],
    symbols: set[tuple[int, str]],
    ) -> str:
    exported_symbols = sorted(set(map(operator.itemgetter(1), symbols)))
    inner = ",\n".join(
    [f' "{symbol}"' for symbol in exported_symbols],
    )
    rendered: str
    if len(exported_symbols) > 1:
    rendered = f"\n{inner},\n"
    else:
    rendered = inner
    source_chars = list(source)
    source_chars[start[2] + 1 : end[2]] = list(rendered)
    code = "".join(source_chars)
    return code


    def write(
    buffer: IO,
    output: str,
    ) -> None:
    buffer.write(output)
    buffer.flush()


    def process_stdin(argv: list[str]) -> int:
    source = read(sys.stdin)
    start, end = get_export_expression_span(source)
    if -1 in (start[2], end[2]):
    write(sys.stdout, source)
    return -1
    existing_exported_symbols = collect_existing_exported_symbols(source)
    imported_symbols: set[tuple[int, str]]
    if "-export-all" in argv:
    imported_symbols = collect_imported_symbols(source)
    else:
    imported_symbols = set()
    output = replace_exported_symbols(
    source,
    start,
    end,
    existing_exported_symbols.union(
    imported_symbols,
    ),
    )
    try:
    write(sys.stdout, output)
    except Exception as error:
    write(sys.stderr, f"{str(error)}\n")
    with suppress(Exception):
    write(sys.stdout, source)
    return 1
    return 0


    def process_source_file(source_file_path: Path) -> int:
    source = source_file_path.read_text()
    start, end = get_export_expression_span(source)
    if -1 in (start[2], end[2]):
    return -1
    existing_exported_symbols = collect_existing_exported_symbols(source)
    imported_symbols: set[tuple[int, str]]
    if source_file_path.name == "__init__.py":
    imported_symbols = collect_imported_symbols(source)
    else:
    imported_symbols = set()
    output = replace_exported_symbols(
    source,
    start,
    end,
    existing_exported_symbols.union(
    imported_symbols,
    ),
    )
    try:
    with source_file_path.open("w") as source_file:
    write(source_file, output)
    except Exception as error:
    write(sys.stderr, f"{str(error)}\n")
    with suppress(Exception):
    with source_file_path.open("w") as source_file:
    write(source_file, source)
    return 1
    return 0


    def process_source_files() -> int:
    for source_file_path in WORK_DIR.rglob("*.py"):
    if str(source_file_path).startswith(str(VENV_DIR)):
    continue
    status = process_source_file(source_file_path)
    if status == -1:
    write(
    sys.stderr,
    f"{source_file_path}: No exported py symbols found, skipping\n",
    )
    elif status != 0:
    write(
    sys.stderr,
    f"{source_file_path}: Failed to format exported py symbols\n",
    )
    return status
    write(
    sys.stderr,
    f"{source_file_path}: Formatted exported py symbols\n",
    )
    return 0


    def main(
    argv: list[str] | None = None,
    ) -> int:
    if argv is None:
    argv = sys.argv.copy()
    if "-stdin" in argv:
    return process_stdin(argv)
    return process_source_files()


    if __name__ == "__main__":
    sys.exit(main(argv=sys.argv.copy()))