diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml new file mode 100644 index 0000000..2709feb --- /dev/null +++ b/.github/workflows/pylint.yml @@ -0,0 +1,23 @@ +name: Pylint + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.13"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pylint tqdm + - name: Analysing the code with pylint + run: | + pylint ./userland diff --git a/pylintrc b/pylintrc index 2d8a5e5..76a2fa6 100644 --- a/pylintrc +++ b/pylintrc @@ -1,7 +1,5 @@ [MESSAGES CONTROL] disable = - consider-using-with, duplicate-code, missing-docstring, - redefined-outer-name, diff --git a/pyproject.toml b/pyproject.toml index 128d14c..398c3cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,11 @@ build-backend = "setuptools.build_meta" [project] name = "userland" -version = "0.0.2" +version = "0.1.0" requires-python = ">=3.13" +dependencies = [ + "tqdm>=4.67.1" +] authors = [ { name="Expertcoderz", email="expertcoderzx@gmail.com"} ] diff --git a/tests/cut.sh b/tests/cut.sh new file mode 100755 index 0000000..7ce5376 --- /dev/null +++ b/tests/cut.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env sh + +set -eux + +## cut by bytes with range + +result="$(echo 'abcdefghi' | python -m userland cut -b -3,5-6,8-)" + +test "${result}" = 'abcefhi' + +## cut by bytes, zero-terminated + +result="$(printf 'foo\0bar' | python -m userland cut -b 3 -z)" + +test "${result}" = 'or' + +## cut by field + +result="$(echo 'foo:bar' | python -m userland cut -f 2 -d ':')" + +test "${result}" = 'bar' + +## cut by field complement + +result="$(echo 'foo:bar' | python -m userland cut -f 2 -d ':' --complement)" + +test "${result}" = 'foo' + +## cut by field, only delimited + +result="$(printf 'foo\tbar\naaa\n' | python -m userland cut -f 2 -s)" + +test "${result}" = 'bar' + +## cut by field, with output delimiter + +result="$(echo 'foo:bar' | python -m userland cut -f 1,2 -d ':' \ + --output-delimiter='d')" + +test "${result}" = 'foodbar' + +## cut by field, with newline as delimiter + +result="$(printf 'foo\nbar' | python -m userland cut -f 2 -d ' +')" + +test "${result}" = 'bar' + +## cut by field, with newline as delimiter, only delimited + +result="$(printf 'foo\0bar\nx' | python -m userland cut -f 2 -d ' +' -s -z)" + +test "${result}" = 'x' + +exit 0 diff --git a/tests/echo.sh b/tests/echo.sh index f44b6e1..1a50319 100755 --- a/tests/echo.sh +++ b/tests/echo.sh @@ -4,55 +4,55 @@ set -eux ## basic echo -result="$(./echo.py foo bar)" +result="$(python -m userland echo foo bar)" test "${result}" = 'foo bar' ## double hyphen -result="$(./echo.py -- foo)" +result="$(python -m userland echo -- foo)" test "${result}" = '-- foo' ## multiple double hyphens -result="$(./echo.py -- foo --)" +result="$(python -m userland echo -- foo --)" test "${result}" = '-- foo --' ## unknown option -result="$(./echo.py -x foo)" +result="$(python -m userland echo -x foo)" test "${result}" = '-x foo' ## unknown option and double hyphen -result="$(./echo.py -x -- foo)" +result="$(python -m userland echo -x -- foo)" test "${result}" = '-x -- foo' ## escape codes -result="$(./echo.py -e 'foo \x41' '\0102')" +result="$(python -m userland echo -e 'foo \x41' '\0102')" test "${result}" = 'foo A B' ## no arguments -result="$(./echo.py)" +result="$(python -m userland echo)" test "${result}" = "$(printf '\n')" ## empty arguments -result="$(./echo.py '' foo '' bar '' '')" +result="$(python -m userland echo '' foo '' bar '' '')" test "${result}" = ' foo bar ' ## no trailing newline -n_lines="$(./echo.py -n 'foo' | wc -l)" +n_lines="$(python -m userland echo -n 'foo' | wc -l)" test "${n_lines}" = 0 diff --git a/tests/factor.sh b/tests/factor.sh index 02addb8..7946a0a 100755 --- a/tests/factor.sh +++ b/tests/factor.sh @@ -2,13 +2,7 @@ set -eux -start="$(date -u +%s)" - -for n in $(seq 0 1000); do - test "$(./factor.py "${n}")" = "$(factor "${n}")" -done - -end="$(date -u +%s)" -printf '%.1f s elapsed\n' $(( end - start )) +numbers="$(seq 0 10000)" +test "$(python -m userland factor ${numbers})" = "$(factor ${numbers})" exit 0 diff --git a/tests/seq.sh b/tests/seq.sh new file mode 100755 index 0000000..0b906e5 --- /dev/null +++ b/tests/seq.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env sh + +set -eux + +set -- \ + '0' \ + '0 0' \ + '0 0 0' \ + '10' \ + '5 10' \ + '0 2 10' \ + '1.35 0.05 2' \ + '-- -1.0 2' \ + '-- -1 2.5 3' + +for args in "$@"; do + test "$(python -m userland seq ${args})" = "$(seq ${args})" + test "$(python -m userland seq -w ${args})" = "$(seq -w ${args})" +done + +exit 0 diff --git a/tests/truncate.sh b/tests/truncate.sh index 2c66a13..a594270 100755 --- a/tests/truncate.sh +++ b/tests/truncate.sh @@ -14,41 +14,41 @@ get_size() { echo 'foo bar' > "${tempdir}"/a -./truncate.py -s 3 "${tempdir}"/a +python -m userland truncate -s 3 "${tempdir}"/a test "$(cat "${tempdir}"/a)" = 'foo' ## size extension -./truncate.py -s +7 "${tempdir}"/a +python -m userland truncate -s +7 "${tempdir}"/a size="$(get_size "${tempdir}"/a)" test "${size}" = 10 ## ensure minimum size -./truncate.py -s '>5' "${tempdir}"/a +python -m userland truncate -s '>5' "${tempdir}"/a size="$(get_size "${tempdir}"/a)" test "${size}" = 10 ## truncate to maximum size -./truncate.py -s '<8' "${tempdir}"/a +python -m userland truncate -s '<8' "${tempdir}"/a size="$(get_size "${tempdir}"/a)" test "${size}" = 8 ## round size to multiple -./truncate.py -s %5 "${tempdir}"/a +python -m userland truncate -s %5 "${tempdir}"/a size="$(get_size "${tempdir}"/a)" test "${size}" = 10 ## ensure size is multiple -./truncate.py -s /2 "${tempdir}"/a +python -m userland truncate -s /2 "${tempdir}"/a size="$(get_size "${tempdir}"/a)" test "${size}" = 10 @@ -57,21 +57,21 @@ test "${size}" = 10 touch "${tempdir}"/b -./truncate.py -r "${tempdir}"/a "${tempdir}"/b +python -m userland truncate -r "${tempdir}"/a "${tempdir}"/b size="$(get_size "${tempdir}"/b)" test "${size}" = 10 ## truncate with reference file and size adjustment -./truncate.py -r "${tempdir}"/a -s +10 "${tempdir}"/b +python -m userland truncate -r "${tempdir}"/a -s +10 "${tempdir}"/b size="$(get_size "${tempdir}"/b)" test "${size}" = 20 ## truncate with block size -./truncate.py -s 0 -o "${tempdir}"/a +python -m userland truncate -s 0 -o "${tempdir}"/a test ! -s "${tempdir}"/a diff --git a/userland/__init__.py b/userland/__init__.py index f5c864f..5e44216 100644 --- a/userland/__init__.py +++ b/userland/__init__.py @@ -5,14 +5,17 @@ file = Path(__file__).resolve(strict=True) -applets = (applet.stem for applet in Path(file.parent, "utilities").glob("*.py")) +applets = (applet.stem for applet in Path(file.parent, "utilities").glob("[!_]*.py")) def print_usage(prog_name: str) -> None: - try: - version = importlib.metadata.version(__package__) - except importlib.metadata.PackageNotFoundError: - version = "(unknown version)" + version = "(unknown version)" + + if __package__: + try: + version = importlib.metadata.version(__package__) + except importlib.metadata.PackageNotFoundError: + pass print( f"python-userland v{version}", @@ -20,7 +23,7 @@ def print_usage(prog_name: str) -> None: f"{7 * " "}{prog_name} --list", "\nAvailable applets:", ", ".join(sorted(applets)), - sep="\n" + sep="\n", ) diff --git a/userland/core/__init__.py b/userland/core/__init__.py index fa43b6c..9f2f8f9 100644 --- a/userland/core/__init__.py +++ b/userland/core/__init__.py @@ -1,2 +1,4 @@ from .command import * from .io import * +from .paths import * +from .users import * diff --git a/userland/core/command.py b/userland/core/command.py index 7c6b553..3743fd6 100644 --- a/userland/core/command.py +++ b/userland/core/command.py @@ -1,39 +1,56 @@ import sys -from optparse import OptionParser -from typing import Any, Callable - - -def create_parser(usage: tuple[str], **kwargs) -> OptionParser: - if parser_class := kwargs.get("parser_class"): - del kwargs["parser_class"] - - parser = (parser_class or OptionParser)( - usage="Usage: " + f"\n{7 * " "}".join(usage), - **kwargs, - add_help_option=False, - ) - parser.add_option( - "--help", - action="help", - help="show usage information and exit", - ) - - return parser +from optparse import OptionParser, Values +from typing import Callable + +from .users import OptionParserUsersMixin + + +class ExtendedOptionParser(OptionParserUsersMixin, OptionParser): + def __init__(self, usage: str | tuple[str, ...], **kwargs): + super().__init__( + usage="Usage: " + + f"\n{7 * " "}".join(usage if isinstance(usage, tuple) else (usage,)), + add_help_option=False, + **kwargs, + ) + + self.add_option( + "--help", + action="help", + help="show usage information and exit", + ) + + def expect_nargs(self, args: list[str], nargs: int | tuple[int] | tuple[int, int]): + if isinstance(nargs, int): + nargs = (nargs, nargs) + + if len(nargs) == 1: + nargs = (nargs[0], len(args)) + + if nargs[0] <= len(args) <= nargs[1]: + return + + if args: + if len(args) < nargs[0]: + self.error(f"missing operand after '{args[-1]}'") + else: + self.error(f"extra operand '{args[nargs[1]]}'") + else: + self.error("missing operand") def command(parser: OptionParser | None = None): def create_utility( - func: Callable[[dict[str, Any], list[Any]], int], + func: Callable[[Values, list[str]], int], ) -> Callable[[], None]: - if parser: - - def execute_utility(): - sys.exit(func(*parser.parse_args())) - - else: - - def execute_utility(): - sys.exit(func({}, sys.argv[1:])) + def execute_utility(): + try: + sys.exit( + func(*parser.parse_args()) if parser else func(Values(), sys.argv[1:]) + ) + except KeyboardInterrupt: + print() + sys.exit(130) return execute_utility diff --git a/userland/core/io.py b/userland/core/io.py index f4321bf..e891a74 100644 --- a/userland/core/io.py +++ b/userland/core/io.py @@ -1,22 +1,38 @@ +import os import sys -from typing import Generator +from typing import Any, Generator, IO -def readlines_stdin() -> Generator[str]: - while line := sys.stdin.readline(): - yield line - - -def readlines_stdin_raw() -> Generator[bytes]: - while line := sys.stdin.buffer.readline(): - yield line +def perror(*errors: Any) -> None: + print( + f"{os.path.basename(sys.argv[0])}: {"\n".join(map(str, errors))}", + file=sys.stderr, + ) def readwords_stdin() -> Generator[str]: - for line in readlines_stdin(): + for line in sys.stdin: yield from line.split() def readwords_stdin_raw() -> Generator[bytes]: - for line in readlines_stdin_raw(): + for line in sys.stdin.buffer: yield from line.split() + + +def get_lines_by_delimiter[T: ( + str, + bytes, +)](stream: IO[T], delimiter: T) -> Generator[T]: + joiner = type(delimiter)() + line = [] + + while char := stream.read(1): + if char == delimiter: + yield joiner.join(line) + line.clear() + else: + line.append(char) + + if line: + yield joiner.join(line) diff --git a/userland/core/paths.py b/userland/core/paths.py new file mode 100644 index 0000000..f6935c8 --- /dev/null +++ b/userland/core/paths.py @@ -0,0 +1,34 @@ +import sys +from pathlib import Path +from typing import Generator, Iterable, Literal + + +def traverse_files( + filenames: Iterable[str], + recurse_mode: Literal["L", "H", "P"] | None = None, + preserve_root: bool = False, +) -> Generator[Path | None]: + if not recurse_mode: + yield from map(Path, filenames) + return + + def traverse(file: Path) -> Generator[Path]: + for child in file.iterdir(): + if child.is_dir(follow_symlinks=recurse_mode == "L"): + yield from traverse(child) + yield child + + for file in map(Path, filenames): + if preserve_root and file.root == str(file): + print( + f"recursive operation on '{file}' prevented;" + " use --no-preserve-root to override", + file=sys.stderr, + ) + yield None + continue + + if file.is_dir(follow_symlinks=recurse_mode in set("HL")): + yield from traverse(file) + else: + yield file diff --git a/userland/core/users.py b/userland/core/users.py new file mode 100644 index 0000000..550ff36 --- /dev/null +++ b/userland/core/users.py @@ -0,0 +1,70 @@ +import functools +import grp +import pwd + +from optparse import OptionParser + + +class OptionParserUsersMixin(OptionParser): + def parse_owner_spec(self, owner_spec: str) -> tuple[int | None, int | None]: + """ + Accept a string in the form ``[USER][:[GROUP]]`` and return the UID and GID. + Either or both may be None if omitted from the input string. + An appropriate parser error is thrown if obtaining the UID or GID fails. + """ + tokens = owner_spec.split(":") + + uid: int | None = None + gid: int | None = None + + if tokens[0]: + uid = self.parse_user(tokens[0]) + + if len(tokens) > 1 and tokens[1]: + gid = self.parse_group(tokens[1]) + + return uid, gid + + # pylint: disable=inconsistent-return-statements + def parse_user(self, user: str) -> int: + """ + Accept a string representing a username or UID and return the UID. + An appropriate parser error is thrown if obtaining the UID fails. + """ + if user.isdecimal(): + return int(user) + + try: + return pwd.getpwnam(user).pw_uid + except KeyError: + self.error(f"invalid user: {user}") + + # pylint: disable=inconsistent-return-statements + def parse_group(self, group: str) -> int: + """ + Accept a string representing a group name or GID and return the GID. + An appropriate parser error is thrown if obtaining the GID fails. + """ + if group.isdecimal(): + return int(group) + + try: + return grp.getgrnam(group).gr_gid + except KeyError: + self.error(f"invalid group: {group}") + + +@functools.lru_cache(1000) +def user_display_name_from_id(uid: int) -> str: + try: + return pwd.getpwuid(uid).pw_name + except KeyError: + return str(uid) + + +@functools.lru_cache(1000) +def group_display_name_from_id(gid: int) -> str: + try: + return grp.getgrgid(gid).gr_name + except KeyError: + return str(gid) diff --git a/userland/utilities/__init__.py b/userland/utilities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/userland/utilities/basename.py b/userland/utilities/basename.py index 7b591b5..00731d8 100644 --- a/userland/utilities/basename.py +++ b/userland/utilities/basename.py @@ -3,7 +3,7 @@ from .. import core -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog NAME [SUFFIX]", "%prog OPTION... NAME..."), description="Print the last component of each path NAME.", ) @@ -26,15 +26,13 @@ @core.command(parser) -def python_userland_basename(opts, args): - if not args: - parser.error("missing operand") +def python_userland_basename(opts, args: list[str]) -> int: + parser.expect_nargs(args, (1,)) if opts.suffix: opts.multiple = True elif not opts.multiple and len(args) > 1: - if len(args) > 2: - parser.error(f"extra operand '{args[2]}'") + parser.expect_nargs(args, 2) opts.suffix = args.pop() else: diff --git a/userland/utilities/cat.py b/userland/utilities/cat.py index f97825a..00dfa48 100644 --- a/userland/utilities/cat.py +++ b/userland/utilities/cat.py @@ -1,15 +1,15 @@ import itertools import sys from io import BufferedReader -from typing import BinaryIO, Generator +from typing import Generator, Iterable from .. import core -def squeeze_blank_lines(io: BinaryIO) -> Generator[bytes]: +def squeeze_blank_lines(stream: Iterable[bytes]) -> Generator[bytes]: was_blank = False - for line in io: + for line in stream: is_blank = len(line) < 2 if was_blank and is_blank: @@ -19,14 +19,14 @@ def squeeze_blank_lines(io: BinaryIO) -> Generator[bytes]: was_blank = is_blank -def number_lines(io: BinaryIO) -> Generator[bytes]: - for i, _ in enumerate(io): +def number_lines(stream: Iterable[bytes]) -> Generator[bytes]: + for i, _ in enumerate(stream): yield f"{i + 1:>6} ".encode() -def number_nonblank_lines(io: BinaryIO) -> Generator[bytes]: +def number_nonblank_lines(stream: Iterable[bytes]) -> Generator[bytes]: i = 1 - for line in io: + for line in stream: if len(line) > 1: yield f"{i:>6} ".encode() i += 1 @@ -62,16 +62,16 @@ def format_chars( yield n.to_bytes() -def format_lines(io: BinaryIO, *args) -> Generator[bytes]: - for line in io: +def format_lines(stream: Iterable[bytes], *args) -> Generator[bytes]: + for line in stream: yield b"".join(format_chars(line, *args)) -def cat_io(opts, io: BinaryIO) -> None: +def cat_io(opts, stream: Iterable[bytes]) -> None: if opts.squeeze_blank: - io = squeeze_blank_lines(io) + stream = squeeze_blank_lines(stream) - io1, io2 = itertools.tee(io, 2) + io1, io2 = itertools.tee(stream, 2) gen1, gen2 = None, None if opts.number_nonblank: @@ -94,8 +94,8 @@ def cat_io(opts, io: BinaryIO) -> None: sys.stdout.buffer.flush() -parser = core.create_parser( - usage=("%prog [OPTION]... [FILE]...",), +parser = core.ExtendedOptionParser( + usage="%prog [OPTION]... [FILE]...", description="Concatenate each FILE to standard output.", ) @@ -134,7 +134,7 @@ def cat_io(opts, io: BinaryIO) -> None: @core.command(parser) -def python_userland_cat(opts, args): +def python_userland_cat(opts, args: list[str]) -> int: if opts.show_all: opts.show_ends = True opts.show_tabs = True @@ -146,20 +146,29 @@ def python_userland_cat(opts, args): opts.show_tabs = True opts.show_nonprinting = True - generators = [ - core.readlines_stdin_raw() if name == "-" else open(name, "rb") - for name in args or ["-"] - ] + streams: list[Iterable[bytes]] = [] + failed = False + + for name in args or ["-"]: + if name == "-": + streams.append(sys.stdin.buffer) + else: + try: + # pylint: disable=consider-using-with + streams.append(open(name, "rb")) + except OSError as e: + failed = True + core.perror(e) try: - cat_io(opts, itertools.chain(*generators)) + cat_io(opts, itertools.chain(*streams)) except KeyboardInterrupt: print() return 130 finally: - for gen in generators: + for gen in streams: # Close opened files other than stdin. if isinstance(gen, BufferedReader): gen.close() - return 0 + return int(failed) diff --git a/userland/utilities/chgrp.py b/userland/utilities/chgrp.py new file mode 100644 index 0000000..d2ce7ed --- /dev/null +++ b/userland/utilities/chgrp.py @@ -0,0 +1,212 @@ +import shutil +import sys +from pathlib import Path + +from tqdm import tqdm + +from .. import core + + +parser = core.ExtendedOptionParser( + usage=( + "%prog [OPTION]... GROUP FILE...", + "%prog [OPTION]... --reference=RFILE FILE...", + ), + description="Change the group ownership of each FILE.", +) + +parser.add_option( + "-f", + "--silent", + "--quiet", + dest="verbosity", + action="store_const", + const=0, + default=1, + help="suppress most error messages", +) +parser.add_option( + "-c", + "--changes", + dest="verbosity", + action="store_const", + const=2, + help="report only when changes are made", +) +parser.add_option( + "-v", + "--verbose", + dest="verbosity", + action="store_const", + const=3, + help="print a diagnostic for each file", +) + +parser.add_option( + "--progress", + dest="progress", + action="store_true", + help="show a progress bar when changing groups", +) +parser.add_option( + "--no-progress", + dest="progress", + action="store_false", + default=False, + help="do not show a progress bar (default)", +) + +parser.add_option( + "--dereference", + action="store_true", + default=True, + help="affect symlink referents instead of the symlinks themselves (default)", +) +parser.add_option( + "-h", + "--no-dereference", + dest="dereference", + action="store_false", + help="opposite of --dereference", +) + +parser.add_option( + "--no-preserve-root", + dest="preserve_root", + action="store_false", + default=False, + help="do not treat '/' specially (default)", +) +parser.add_option( + "--preserve-root", + action="store_true", + help="fail to operate recursively on '/'", +) + +parser.add_option( + "--from", + dest="from_spec", # prevent name collision with the `from` keyword + metavar="[CURRENT_OWNER][:[CURRENT_GROUP]]", + help="only affect files with CURRENT_OWNER and CURRENT_GROUP" + " (either is optional and only checked if given)", +) + +parser.add_option( + "--reference", + metavar="RFILE", + help="use the group of RFILE instead of from an argument", +) + +parser.add_option( + "-R", "--recursive", action="store_true", help="operate on directories recursively" +) +parser.add_option( + "-H", + dest="recurse_mode", + action="store_const", + const="H", + help="traverse directory symlinks only if they were given as command line arguments", +) +parser.add_option( + "-L", + dest="recurse_mode", + action="store_const", + const="L", + help="traverse all directory symlinks encountered", +) +parser.add_option( + "-P", + dest="recurse_mode", + action="store_const", + const="P", + default="P", + help="do not traverse any symlinks (default)", +) + + +def get_new_group(opts, args: list[str]) -> tuple[int, str]: + if opts.reference: + gid = Path(opts.reference).stat(follow_symlinks=True).st_gid + + return gid, core.group_display_name_from_id(gid) + + parser.expect_nargs(args, (2,)) + gname = args.pop(0) + + return parser.parse_group(gname), gname + + +@core.command(parser) +def python_userland_chgrp(opts, args: list[str]) -> int: + parser.expect_nargs(args, (1,)) + + from_uid: int | None = None + from_gid: int | None = None + + if opts.from_spec: + from_uid, from_gid = parser.parse_owner_spec(opts.from_spec) + + try: + gid, gname = get_new_group(opts, args) + except OSError as e: + core.perror(e) + return 1 + + failed = False + + def handle_error(err: Exception, level: int, msg: str) -> None: + nonlocal failed + failed = True + + if opts.verbosity: + core.perror(err) + if opts.verbosity > level: + print(msg, file=sys.stderr) + + for file in core.traverse_files( + ( + tqdm(args, ascii=True, desc="Changing group ownership") + if opts.progress + else args + ), + recurse_mode=opts.recurse_mode if opts.recursive else None, + preserve_root=opts.preserve_root, + ): + if not file: + failed = True + continue + + try: + stat = file.stat(follow_symlinks=opts.dereference) + prev_uid = stat.st_uid + prev_gid = stat.st_gid + except OSError as e: + handle_error(e, 2, f"failed to change group of '{file}' to {gname or gid}") + continue + + prev_gname = core.group_display_name_from_id(prev_gid) + + # Note: while it's possible, we do not check if prev_gid == gid at + # this point because even if they are the same, an error should be + # printed if the current user has insufficient permission to change + # the group membership of that file (for coreutils compat). + if (from_uid is not None and prev_uid != from_uid) or ( + from_gid is not None and prev_gid != from_gid + ): + if opts.verbosity > 2: + print(f"group of '{file}' retained as {prev_gname}") + continue + + try: + shutil.chown(file, group=gid, follow_symlinks=opts.dereference) + except OSError as e: + handle_error(e, 2, f"failed to change group of '{file}' to {gname or gid}") + continue + + if prev_gid == gid: + if opts.verbosity > 2: + print(f"group of '{file}' retained as {prev_gname}") + elif opts.verbosity > 1: + print(f"changed group of '{file}' from {prev_gname} to {gname or gid}") + + return int(failed) diff --git a/userland/utilities/chown.py b/userland/utilities/chown.py new file mode 100644 index 0000000..a193c08 --- /dev/null +++ b/userland/utilities/chown.py @@ -0,0 +1,233 @@ +import re + +import shutil +import sys +from pathlib import Path + +from tqdm import tqdm + +from .. import core + + +CHOWN_PATTERN = re.compile("^([^:]+)?(:([^:]+))?$") + +parser = core.ExtendedOptionParser( + usage=( + "%prog [OPTION]... [USER][:[GROUP]] FILE...", + "%prog [OPTION]... --reference=RFILE FILE...", + ), + description="Change the user and/or group ownership of each FILE.", +) + +parser.add_option( + "-f", + "--silent", + "--quiet", + dest="verbosity", + action="store_const", + const=0, + default=1, + help="suppress most error messages", +) +parser.add_option( + "-c", + "--changes", + dest="verbosity", + action="store_const", + const=2, + help="report only when changes are made", +) +parser.add_option( + "-v", + "--verbose", + dest="verbosity", + action="store_const", + const=3, + help="print a diagnostic for each file", +) + +parser.add_option( + "--progress", + dest="progress", + action="store_true", + help="show a progress bar when changing groups", +) +parser.add_option( + "--no-progress", + dest="progress", + action="store_false", + default=False, + help="do not show a progress bar (default)", +) + +parser.add_option( + "--dereference", + action="store_true", + default=True, + help="affect symlink referents instead of the symlinks themselves (default)", +) +parser.add_option( + "-h", + "--no-dereference", + dest="dereference", + action="store_false", + help="opposite of --dereference", +) + +parser.add_option( + "--no-preserve-root", + dest="preserve_root", + action="store_false", + default=False, + help="do not treat '/' specially (default)", +) +parser.add_option( + "--preserve-root", + dest="preserve_root", + action="store_true", + help="fail to operate recursively on '/'", +) + +parser.add_option( + "--from", + dest="from_spec", # prevent name collision with the `from` keyword + metavar="[CURRENT_OWNER][:[CURRENT_GROUP]]", + help="only affect files with CURRENT_OWNER and CURRENT_GROUP" + " (either is optional and only checked if given)", +) + +parser.add_option("--reference", metavar="RFILE") + +parser.add_option( + "-R", "--recursive", action="store_true", help="operate on directories recursively" +) +parser.add_option( + "-H", + dest="recurse_mode", + action="store_const", + const="H", + help="traverse directory symlinks only if they were given as command line arguments", +) +parser.add_option( + "-L", + dest="recurse_mode", + action="store_const", + const="L", + help="traverse all directory symlinks encountered", +) +parser.add_option( + "-P", + dest="recurse_mode", + action="store_const", + const="P", + default="P", + help="do not traverse any symlinks (default)", +) + + +def get_new_owner(opts, args: list[str], chown_args: dict) -> str | None: + if opts.reference: + try: + ref_stat = Path(opts.reference).stat(follow_symlinks=True) + except OSError as e: + core.perror(e) + return None + + chown_args["user"] = ref_stat.st_uid + chown_args["group"] = ref_stat.st_gid + + return ( + core.user_display_name_from_id(ref_stat.st_uid) + + ":" + + core.group_display_name_from_id(ref_stat.st_gid) + ) + + parser.expect_nargs(args, (2,)) + owner_spec = args.pop(0) + + if not (owner_match := CHOWN_PATTERN.match(owner_spec)): + parser.error(f"invalid owner spec: {owner_spec}") + + chown_args["user"] = ( + parser.parse_user(owner_match.group(1)) if owner_match.group(1) else None + ) + chown_args["group"] = ( + parser.parse_group(owner_match.group(3)) if owner_match.group(3) else None + ) + + return owner_spec + + +@core.command(parser) +def python_userland_chown(opts, args: list[str]) -> int: + parser.expect_nargs(args, (1,)) + + from_uid: int | None = None + from_gid: int | None = None + + if opts.from_spec: + from_uid, from_gid = parser.parse_owner_spec(opts.from_spec) + + chown_args = {"follow_symlinks": opts.dereference} + + if not (owner_spec := get_new_owner(opts, args, chown_args)): + return 1 + + failed = False + + def handle_error(err: Exception, level: int, msg: str) -> None: + nonlocal failed + failed = True + + if opts.verbosity: + core.perror(err) + if opts.verbosity > level: + print(msg, file=sys.stderr) + + for file in core.traverse_files( + (tqdm(args, ascii=True, desc="Changing ownership") if opts.progress else args), + recurse_mode=opts.recurse_mode if opts.recursive else None, + preserve_root=opts.preserve_root, + ): + if not file: + failed = True + continue + + try: + stat = file.stat(follow_symlinks=opts.dereference) + prev_uid = stat.st_uid + prev_gid = stat.st_gid + except OSError as e: + handle_error( + e, 2, f"failed to change ownership of '{file}' to {owner_spec}" + ) + continue + + prev_uname = core.user_display_name_from_id(prev_uid) + prev_gname = core.group_display_name_from_id(prev_gid) + + if (from_uid is not None and prev_uid != from_uid) or ( + from_gid is not None and prev_gid != from_gid + ): + if opts.verbosity > 2: + print(f"ownership of '{file}' retained as {prev_uname}:{prev_gname}") + continue + + try: + shutil.chown(file, **chown_args) + except OSError as e: + handle_error( + e, 2, f"failed to change ownership of '{file}' to {owner_spec}" + ) + continue + + if prev_uid == chown_args["user"] or prev_gid == chown_args["group"]: + if opts.verbosity > 2: + print(f"ownership of '{file}' retained as {prev_uname}:{prev_gname}") + elif opts.verbosity > 1: + print( + f"changed ownership of '{file}' from" + f" {prev_uname}:{prev_gname} to {owner_spec}" + ) + + return int(failed) diff --git a/userland/utilities/clear.py b/userland/utilities/clear.py index a60789a..162cae4 100644 --- a/userland/utilities/clear.py +++ b/userland/utilities/clear.py @@ -3,24 +3,23 @@ # clear(1), roughly modelled off the ncurses implementation. -parser = core.create_parser( - usage=("%prog [OPTION]...",), +parser = core.ExtendedOptionParser( + usage="%prog [OPTION]...", description="Clear the terminal screen.", ) -parser.add_option("-T", metavar="TERM", help="(unimplemented)") - parser.add_option( "-x", action="store_true", help="do not try to clear the scrollback buffer" ) @core.command(parser) -def python_userland_clear(opts, args): +def python_userland_clear(opts, args: list[str]) -> int: if args: return 1 print("\x1b[2J\x1b[H", end="") + if not opts.x: print("\x1b[3J", end="") diff --git a/userland/utilities/cut.py b/userland/utilities/cut.py new file mode 100644 index 0000000..b87daed --- /dev/null +++ b/userland/utilities/cut.py @@ -0,0 +1,221 @@ +import sys +from typing import BinaryIO, Callable, Iterable, cast + +from .. import core + + +type Ranges = list[int | tuple[int, int | None]] +type RangeChecker = Callable[[int], bool] + +type Cutter = Callable[[bytes], bytes | None] + + +def get_check_range(ranges: Ranges, complement: bool) -> RangeChecker: + def check_range(pos: int) -> bool: + for r in ranges: + match r: + case [min_pos, None]: + if pos >= min_pos: + return True + case [min_pos, max_pos]: + if min_pos <= pos <= max_pos: + return True + case wanted_pos: + if pos == wanted_pos: + return True + + return False + + return (lambda pos: not check_range(pos)) if complement else check_range + + +def get_cut_by_bytes(check_range: RangeChecker, line_terminator: bytes) -> Cutter: + def cut_by_bytes(data: bytes) -> bytes: + return b"".join( + [n.to_bytes() for i, n in enumerate(data) if check_range(i + 1)] + + [line_terminator] + ) + + return cut_by_bytes + + +def get_cut_by_fields( + check_range: RangeChecker, + input_delimiter: bytes, + output_delimiter: bytes, + only_delimited: bool, +) -> Cutter: + def cut_by_fields(data: bytes) -> bytes | None: + fields = data.split(input_delimiter) + + if len(fields) < 2: + return None if only_delimited else data + + return output_delimiter.join( + [field for i, field in enumerate(fields) if check_range(i + 1)] + ) + + return cut_by_fields + + +def cut_and_print_stream(stream: Iterable[bytes], cutter: Cutter) -> None: + for line in stream: + if (processed := cutter(line)) is not None: + sys.stdout.buffer.write(processed) + sys.stdout.buffer.flush() + + +parser = core.ExtendedOptionParser(usage="%prog OPTION... [FILE]...", description="wow") + +parser.add_option("-b", "--bytes", metavar="LIST", help="select bytes in LIST") +parser.add_option("-c", "--characters", metavar="LIST", help="identical to -b") +parser.add_option("-f", "--fields", metavar="LIST", help="select fields in LIST") + +parser.add_option("--complement", action="store_true", help="invert selection") + +parser.add_option( + "-s", + "--only-delimited", + action="store_true", + help="ignore lines not containing the delimiter", +) +parser.add_option( + "-d", + "--delimiter", + metavar="STRING", + help="use STRING instead of TAB as field delimiter", +) +parser.add_option( + "--output-delimiter", + metavar="STRING", + help="use STRING instead of input delimiter as output delimiter", +) + +parser.add_option( + "-z", + "--zero-terminated", + action="store_true", + help="line delimiter is NUL instead of newline", +) + +parser.add_option( + "-n", action="store_true", help="(ignored; present for POSIX compatibility)" +) + + +def parse_range(range_specs: str) -> Ranges: + ranges: Ranges = [] + + for range_spec in range_specs.split(","): + parts = range_spec.split("-") + + try: + match parts: + case [n]: + ranges.append(int(n)) + case [n, ""]: + ranges.append((int(n), None)) + case ["", m]: + ranges.append((0, int(m))) + case [n, m]: + ranges.append((int(n), int(m))) + case _: + raise ValueError + except ValueError: + parser.error(f"invalid range specification: {range_specs}") + + return ranges + + +@core.command(parser) +def python_userland_cut(opts, args: list[str]) -> int: + cutter: Cutter + + match (opts.bytes, opts.characters, opts.fields): + case (None, None, None): + parser.error("expected one of --bytes, --characters or --fields") + case (byte_range_spec, None, None) | (None, byte_range_spec, None): + if opts.delimiter: + parser.error("--delimiter is only allowed with --fields") + + if opts.only_delimited: + parser.error("--only-delimited is only allowed with --fields") + + cutter = get_cut_by_bytes( + check_range=get_check_range( + parse_range(cast(str, byte_range_spec)), opts.complement + ), + line_terminator=b"\0" if opts.zero_terminated else b"\n", + ) + case (None, None, field_range_spec): + opts.delimiter = opts.delimiter or "\t" + + if len(opts.delimiter) > 1: + parser.error("the delimiter must be a single character") + + cutter = get_cut_by_fields( + check_range=get_check_range( + parse_range(field_range_spec), opts.complement + ), + input_delimiter=(input_delimiter := opts.delimiter.encode()), + output_delimiter=( + opts.output_delimiter.encode() + if opts.output_delimiter is not None + else input_delimiter + ), + only_delimited=opts.only_delimited, + ) + case _: + parser.error("only one list may be specified") + + append_newline = False + + # This is a hack to handle "\n" as a field delimiter. + def process_line_stream(stream: BinaryIO) -> Iterable[bytes]: + nonlocal append_newline + + if not (opts.fields and opts.delimiter == "\n"): + return stream + + data = stream.read() + if data and data[-1] == ord(b"\n"): + # Don't treat the last newline as a delimiter. + data = data[:-1] + append_newline = True + + return (data for _ in (None,)) + + failed = False + + for name in args or ["-"]: + append_newline = False + + if name == "-": + cut_and_print_stream( + ( + core.get_lines_by_delimiter(sys.stdin.buffer, b"\0") + if opts.zero_terminated + else process_line_stream(sys.stdin.buffer) + ), + cutter, + ) + else: + try: + with open(name, "rb") as f: + cut_and_print_stream( + ( + core.get_lines_by_delimiter(f, b"\0") + if opts.zero_terminated + else process_line_stream(f) + ), + cutter, + ) + except OSError as e: + failed = True + core.perror(e) + continue + + if append_newline: + print() + + return int(failed) diff --git a/userland/utilities/dirname.py b/userland/utilities/dirname.py index 5683e22..73876d8 100644 --- a/userland/utilities/dirname.py +++ b/userland/utilities/dirname.py @@ -3,8 +3,8 @@ from .. import core -parser = core.create_parser( - usage=("%prog [OPTION]... NAME...",), +parser = core.ExtendedOptionParser( + usage="%prog [OPTION]... NAME...", description=( "Print each path NAME with the last component removed," " or '.' if NAME is the only component." @@ -20,9 +20,8 @@ @core.command(parser) -def python_userland_dirname(opts, args): - if not args: - parser.error("missing operand") +def python_userland_dirname(opts, args: list[str]) -> int: + parser.expect_nargs(args, (1,)) for path in map(PurePath, args): print(path.parent, end="\0" if opts.zero else "\n") diff --git a/userland/utilities/echo.py b/userland/utilities/echo.py index c632c18..18031fb 100644 --- a/userland/utilities/echo.py +++ b/userland/utilities/echo.py @@ -1,6 +1,6 @@ import codecs import re -from optparse import OptionParser, BadOptionError, AmbiguousOptionError +from optparse import BadOptionError, AmbiguousOptionError from .. import core @@ -11,7 +11,7 @@ ) -class PassthroughOptionParser(OptionParser): +class PassthroughOptionParser(core.ExtendedOptionParser): """ A modified version of OptionParser that treats unknown options and "--" as regular arguments. Always behaves as if interspersed args are disabled. @@ -34,10 +34,9 @@ def _process_args(self, largs, rargs, values): rargs.clear() -parser = core.create_parser( - usage=("%prog [OPTION]... [STRING]...",), +parser = PassthroughOptionParser( + usage="%prog [OPTION]... [STRING]...", description="Print STRING(s) to standard output.", - parser_class=PassthroughOptionParser, ) parser.disable_interspersed_args() @@ -58,7 +57,7 @@ def _process_args(self, largs, rargs, values): @core.command(parser) -def python_userland_echo(opts, args): +def python_userland_echo(opts, args: list[str]) -> int: string = " ".join(args) if opts.escapes: diff --git a/userland/utilities/env.py b/userland/utilities/env.py new file mode 100644 index 0000000..c21ff5b --- /dev/null +++ b/userland/utilities/env.py @@ -0,0 +1,105 @@ +import os +import shlex + +from .. import core + + +parser = core.ExtendedOptionParser( + usage="%prog [OPTION]... [-] [NAME=VALUE]... [COMMAND [ARG]...]", + description="Run a program in a modified environment or print environment variables.", +) +parser.disable_interspersed_args() + +parser.add_option( + "-a", + "--argv0", + metavar="ARG", + help="pass ARG as zeroth argument instead of COMMAND", +) +parser.add_option( + "-i", + "--ignore-environment", + action="store_true", + help="start with empty environment", +) +parser.add_option( + "-u", + "--unset", + action="append", + metavar="NAME", + help="remove variable NAME from the environment", +) +parser.add_option( + "-0", + "--null", + action="store_true", + help="terminate outputs with NUL instead of newline", +) +parser.add_option( + "-C", + "--chdir", + metavar="DIR", + help="change working directory to DIR", +) +parser.add_option( + "-S", + "--split-string", + metavar="STRING", + help="split STRING into separate arguments", +) + + +def parse_env_args(args: list[str], env: dict[str, str], prog_args: list[str]) -> None: + parsing_decls = True + + for arg in args: + if parsing_decls and (eq_pos := arg.find("=")) >= 0: + env[arg[:eq_pos]] = arg[eq_pos + 1 :] + else: + prog_args.append(arg) + parsing_decls = False + + +@core.command(parser) +# pylint: disable=inconsistent-return-statements +def python_userland_env(opts, args: list[str]) -> int: + if args and args[0] == "-": + opts.ignore_environment = True + del args[0] + + env: dict[str, str] + + if opts.ignore_environment: + env = {} + elif opts.unset: + env = { + name: value for name, value in os.environ.items() if name not in opts.unset + } + else: + env = os.environ.copy() + + prog_args = [] + parse_env_args(args, env, prog_args) + + if opts.split_string: + prog_args = shlex.split(opts.split_string) + prog_args + + if not prog_args: + for name, value in env.items(): + print(f"{name}={value}", end="\0" if opts.null else "\n") + return 0 + + if opts.chdir: + try: + os.chdir(opts.chdir) + except OSError as e: + core.perror(e) + return 125 + + prog_args.insert(1, opts.argv0 if opts.argv0 else prog_args[0]) + + try: + os.execvpe(prog_args[0], prog_args[1:], env) + except OSError as e: + core.perror(e) + return 126 if isinstance(e, FileNotFoundError) else 127 diff --git a/userland/utilities/factor.py b/userland/utilities/factor.py index 5377389..c6e1655 100644 --- a/userland/utilities/factor.py +++ b/userland/utilities/factor.py @@ -1,6 +1,5 @@ import math -import sys -from typing import Generator, Iterable +from typing import Generator, Iterable, cast from .. import core @@ -16,7 +15,7 @@ def miller_rabin(n: int) -> bool: d = n - 1 s = 0 - while not d & 1: + while d & 1 == 0: d >>= 1 s += 1 @@ -60,7 +59,7 @@ def factorize( while n > 1: factor: int | None = None - if not n & 1: + if n & 1 == 0: yield (factor := 2) elif factor := cache.get(n): yield factor @@ -77,7 +76,7 @@ def factorize( if factor == n: break - n //= factor + n //= cast(int, factor) def format_exponents(factors: Iterable[int]) -> str: @@ -100,8 +99,8 @@ def format_exponents(factors: Iterable[int]) -> str: return " ".join(processed) -parser = core.create_parser( - usage=("%prog [OPTION] [NUMBER]...",), +parser = core.ExtendedOptionParser( + usage="%prog [OPTION] [NUMBER]...", description="Compute and print the prime factors of each positive integer NUMBER.", ) @@ -109,32 +108,28 @@ def format_exponents(factors: Iterable[int]) -> str: @core.command(parser) -def python_userland_factor(opts, args): +def python_userland_factor(opts, args: list[str]) -> int: failed = False - try: - for arg in args or core.readwords_stdin(): - try: - num = int(arg) - if num < 0: - raise ValueError - except ValueError: - failed = True - print(f"'{arg}' is not a valid positive integer", file=sys.stderr) - continue - - if num < 2: - print(f"{num}:") - continue - - factors = sorted(factorize(num)) - - print( - f"{num}: {format_exponents(factors) if opts.exponents - else " ".join(map(str, factors))}" - ) - except KeyboardInterrupt: - print() - return 130 + for arg in args or core.readwords_stdin(): + try: + num = int(arg) + if num < 0: + raise ValueError + except ValueError: + failed = True + core.perror(f"'{arg}' is not a valid positive integer") + continue + + if num < 2: + print(f"{num}:") + continue + + factors = sorted(factorize(num)) + + print( + f"{num}: {format_exponents(factors) if opts.exponents + else " ".join(map(str, factors))}" + ) return int(failed) diff --git a/userland/utilities/false.py b/userland/utilities/false.py index a6bbcaf..dadad57 100644 --- a/userland/utilities/false.py +++ b/userland/utilities/false.py @@ -5,7 +5,7 @@ @core.command() -def python_userland_false(_, args): +def python_userland_false(_, args: list[str]) -> int: if args and args[0] == "--help": print( f"""\ diff --git a/userland/utilities/groups.py b/userland/utilities/groups.py index fabfe91..8f6509c 100644 --- a/userland/utilities/groups.py +++ b/userland/utilities/groups.py @@ -1,19 +1,18 @@ import grp import pwd import os -import sys from .. import core -parser = core.create_parser( - usage=("%prog [USERNAME]...",), +parser = core.ExtendedOptionParser( + usage="%prog [USERNAME]...", description="Print a list of groups for each USERNAME or the current user.", ) @core.command(parser) -def python_userland_groups(_, args): +def python_userland_groups(_, args) -> int: failed = False for user in args or [os.getlogin()]: @@ -21,7 +20,7 @@ def python_userland_groups(_, args): user_info = pwd.getpwnam(user) except KeyError as e: failed = True - print(e, file=sys.stderr) + core.perror(e) continue print( diff --git a/userland/utilities/hostid.py b/userland/utilities/hostid.py index 0ff05ad..4af52f6 100644 --- a/userland/utilities/hostid.py +++ b/userland/utilities/hostid.py @@ -1,17 +1,16 @@ from .. import core -parser = core.create_parser( - usage=("%prog",), +parser = core.ExtendedOptionParser( + usage="%prog", description="Print a 32-bit numeric host machine identifier.", epilog="This implementation gives an all-zero identifier.", ) @core.command(parser) -def python_userland_hostid(_, args): - if args: - parser.error(f"extra operand '{args[0]}'") +def python_userland_hostid(_, args) -> int: + parser.expect_nargs(args, 0) # We're not the only ones being lazy here... musl libc's gethostid(3) # returns zero as well. hostid can arguably be considered as obsolete. diff --git a/userland/utilities/id.py b/userland/utilities/id.py index 5a9a16e..db4af4c 100644 --- a/userland/utilities/id.py +++ b/userland/utilities/id.py @@ -1,13 +1,12 @@ import grp import pwd import os -import sys from .. import core -parser = core.create_parser( - usage=("%prog [OPTION]... [USER]...",), +parser = core.ExtendedOptionParser( + usage="%prog [OPTION]... [USER]...", description="Print user and group information for each USER or the current user.", ) @@ -45,17 +44,17 @@ @core.command(parser) -def python_userland_id(opts, args): +def python_userland_id(opts, args: list[str]) -> int: if opts.context: parser.error("--context (-Z) is not supported") - if (ugG := (opts.user, opts.group, opts.groups)).count(True) > 1: + if (ugg := (opts.user, opts.group, opts.groups)).count(True) > 1: parser.error("cannot print more than one of -ugG") - if opts.name or opts.real and not any(ugG): + if opts.name or opts.real and not any(ugg): parser.error("cannot print only names or real IDs in default format") - if opts.zero and not any(ugG): + if opts.zero and not any(ugg): parser.error("option --zero not permitted in default format") process_uid = os.getuid() if opts.real else os.geteuid() @@ -71,7 +70,7 @@ def python_userland_id(opts, args): passwd = pwd.getpwuid(int(user)) except (KeyError, ValueError): failed = True - print(e, file=sys.stderr) + core.perror(e) continue if opts.user: diff --git a/userland/utilities/logname.py b/userland/utilities/logname.py index a63afe1..927f19e 100644 --- a/userland/utilities/logname.py +++ b/userland/utilities/logname.py @@ -3,16 +3,15 @@ from .. import core -parser = core.create_parser( - usage=("%prog",), +parser = core.ExtendedOptionParser( + usage="%prog", description="Print the current user's login name.", ) @core.command(parser) -def python_userland_logname(_, args): - if args: - parser.error(f"extra operand '{args[0]}'") +def python_userland_logname(_, args) -> int: + parser.expect_nargs(args, 0) print(os.getlogin()) diff --git a/userland/utilities/nologin.py b/userland/utilities/nologin.py index fdaac77..7f940cf 100644 --- a/userland/utilities/nologin.py +++ b/userland/utilities/nologin.py @@ -1,13 +1,13 @@ from .. import core -parser = core.create_parser( - usage=("%prog",), +parser = core.ExtendedOptionParser( + usage="%prog", description="Politely refuse a login.", ) @core.command(parser) -def python_userland_nologin(*_): +def python_userland_nologin(*_) -> int: print("This account is currently not available.") return 1 diff --git a/userland/utilities/nproc.py b/userland/utilities/nproc.py index 8ff0fa7..a6f1f75 100644 --- a/userland/utilities/nproc.py +++ b/userland/utilities/nproc.py @@ -3,8 +3,8 @@ from .. import core -parser = core.create_parser( - usage=(" %prog [OPTION]...",), +parser = core.ExtendedOptionParser( + usage=" %prog [OPTION]...", description="Print the number of processing units available to the process.", ) @@ -24,9 +24,8 @@ @core.command(parser) -def python_userland_nproc(opts, args): - if args: - parser.error(f"extra operand '{args[0]}'") +def python_userland_nproc(opts, args: list[str]) -> int: + parser.expect_nargs(args, 0) n_cpus = os.cpu_count() if opts.all else os.process_cpu_count() diff --git a/userland/utilities/printenv.py b/userland/utilities/printenv.py index d239445..c7df6a6 100644 --- a/userland/utilities/printenv.py +++ b/userland/utilities/printenv.py @@ -3,8 +3,8 @@ from .. import core -parser = core.create_parser( - usage=(" %prog [OPTION] [VARIABLE]...",), +parser = core.ExtendedOptionParser( + usage=" %prog [OPTION] [VARIABLE]...", description="Print VARIABLE(s) or all environment variables, and their values.", ) @@ -12,7 +12,7 @@ @core.command(parser) -def python_userland_printenv(opts, var_names: list[str]): +def python_userland_printenv(opts, var_names: list[str]) -> int: endchar = "\0" if opts.null else "\n" if not var_names: diff --git a/userland/utilities/pwd.py b/userland/utilities/pwd.py index b40ddae..8a42ed5 100644 --- a/userland/utilities/pwd.py +++ b/userland/utilities/pwd.py @@ -3,8 +3,8 @@ from .. import core -parser = core.create_parser( - usage=("%prog [OPTION]",), +parser = core.ExtendedOptionParser( + usage="%prog [OPTION]", description="Print the path to the current working directory.", ) @@ -26,7 +26,7 @@ @core.command(parser) -def python_userland_pwd(opts, args): +def python_userland_pwd(opts, args: list[str]) -> int: if args: parser.error("too many arguments") diff --git a/userland/utilities/readlink.py b/userland/utilities/readlink.py index 5d1e87a..0136a25 100644 --- a/userland/utilities/readlink.py +++ b/userland/utilities/readlink.py @@ -1,11 +1,12 @@ -import sys from pathlib import Path -from typing import Callable +from typing import Callable, Literal from .. import core -def readlink_function(can_mode: str | None) -> Callable[[Path], str]: +def readlink_function( + can_mode: Literal["f", "e", "m"] | None, +) -> Callable[[Path], Path]: match can_mode: case None: return lambda path: path.readlink() @@ -19,8 +20,8 @@ def readlink_function(can_mode: str | None) -> Callable[[Path], str]: return lambda path: path.resolve(strict=can_mode == "e") -parser = core.create_parser( - usage=("%prog [OPTION]... FILE...",), +parser = core.ExtendedOptionParser( + usage="%prog [OPTION]... FILE...", description="Print the target of each symbolic link FILE.", ) @@ -82,12 +83,11 @@ def readlink_function(can_mode: str | None) -> Callable[[Path], str]: @core.command(parser) -def python_userland_readlink(opts, args): - if not args: - parser.error("missing operand") +def python_userland_readlink(opts, args: list[str]) -> int: + parser.expect_nargs(args, (1,)) if opts.no_newline and len(args) > 1: - print("ignoring --no-newline with multiple arguments", file=sys.stderr) + core.perror("ignoring --no-newline with multiple arguments") opts.no_newline = False # This is the precise behavior of GNU readlink regardless @@ -104,6 +104,6 @@ def python_userland_readlink(opts, args): failed = True if opts.verbose: - print(e, file=sys.stderr) + core.perror(e) return int(failed) diff --git a/userland/utilities/realpath.py b/userland/utilities/realpath.py index f2cef00..3e58c8c 100644 --- a/userland/utilities/realpath.py +++ b/userland/utilities/realpath.py @@ -1,5 +1,4 @@ import os -import sys from .. import core @@ -29,8 +28,8 @@ def resolve_filename(opts, name: str) -> str: return name -parser = core.create_parser( - usage=("%prog [OPTION]... FILE...",), +parser = core.ExtendedOptionParser( + usage="%prog [OPTION]... FILE...", description="Print the resolved path of each FILE.", ) @@ -99,14 +98,13 @@ def resolve_filename(opts, name: str) -> str: @core.command(parser) -def python_userland_realpath(opts, args): - if not args: - parser.error("missing operand") +def python_userland_realpath(opts, args: list[str]) -> int: + parser.expect_nargs(args, (1,)) endchar = "\0" if opts.zero else "\n" if opts.relative_to: - args = (os.path.join(opts.relative_to, name) for name in args) + args = [os.path.join(opts.relative_to, name) for name in args] failed = False @@ -117,7 +115,7 @@ def python_userland_realpath(opts, args): failed = True if not opts.quiet: - print(e, file=sys.stderr) + core.perror(e) else: if opts.relative_to and not opts.relative_base: name = os.path.relpath(name, opts.relative_to) diff --git a/userland/utilities/reset.py b/userland/utilities/reset.py index dbaf7f0..8a4f7f4 100644 --- a/userland/utilities/reset.py +++ b/userland/utilities/reset.py @@ -1,20 +1,15 @@ import os -import sys from .. import core # reset(1), roughly modelled off the ncurses implementation. -parser = core.create_parser( - usage=("%prog [OPTION]... [IGNORED]...",), +parser = core.ExtendedOptionParser( + usage="%prog [OPTION]... [IGNORED]...", description="Initialize or reset the terminal state.", ) -parser.add_option("-I", action="store_true", help="(unimplemented)") -parser.add_option("-c", action="store_true", help="(unimplemented)") -parser.add_option("-Q", action="store_true", help="(unimplemented)") - parser.add_option( "-q", action="store_true", @@ -29,17 +24,9 @@ help="print the sequence of shell commands to initialize the TERM environment variable", ) -parser.add_option("-w", action="store_true", help="(unimplemented)") - -parser.add_option("-e", metavar="CHAR", help="(unimplemented)") -parser.add_option("-i", metavar="CHAR", help="(unimplemented)") -parser.add_option("-k", metavar="CHAR", help="(unimplemented)") - -parser.add_option("-m", metavar="MAPPING", help="(unimplemented)") - @core.command(parser) -def python_userland_reset(opts, args): +def python_userland_reset(opts, args: list[str]) -> int: if args and args[0] == "-": opts.q = True del args[0] @@ -48,14 +35,10 @@ def python_userland_reset(opts, args): if opts.q: if not term: - print("unknown terminal type ", file=sys.stderr) - try: - while True: - if term := input("Terminal type? "): - break - except KeyboardInterrupt: - print() - return 130 + core.perror("unknown terminal type ") + while True: + if term := input("Terminal type? "): + break print(term) return 0 diff --git a/userland/utilities/seq.py b/userland/utilities/seq.py new file mode 100644 index 0000000..40e4483 --- /dev/null +++ b/userland/utilities/seq.py @@ -0,0 +1,113 @@ +import math +from decimal import Decimal, InvalidOperation +from typing import cast + +from .. import core + + +parser = core.ExtendedOptionParser( + usage=( + "%prog [OPTION]... LAST", + "%prog [OPTION]... FIRST LAST", + "%prog [OPTION]... FIRST INCREMENT LAST", + ), + description="Print numbers from FIRST to LAST, stepping by INCREMENT.", +) + +parser.add_option( + "-f", + "--format", + metavar="FORMAT", + help="format numbers with printf-style FORMAT", +) +parser.add_option( + "-s", + "--separator", + default="\n", + metavar="STRING", + help="delimit outputs with STRING instead of newline", +) +parser.add_option( + "-w", + "--equal-width", + action="store_true", + help="pad with leading zeros to ensure equal width", +) + + +@core.command(parser) +def python_userland_seq(opts, args: list[str]) -> int: + parser.expect_nargs(args, (1, 3)) + + if opts.format and opts.equal_width: + parser.error("--format and --equal-width are mutually exclusive") + + def arg_to_decimal(arg: str) -> Decimal: + try: + result = Decimal(arg) + except InvalidOperation: + result = None + + if result is None or (result != 0 and not result.is_normal()): + parser.error(f"invalid decimal argument: {arg}") + + return result + + first: Decimal + increment: Decimal + last: Decimal + exponent: int + + if len(args) == 1: + last = arg_to_decimal(args[0]) + if last == 0: + return 0 + + first = Decimal(1) + increment = Decimal(1) + exponent = 0 + elif len(args) == 2: + first = arg_to_decimal(args[0]) + exponent = cast(int, first.as_tuple().exponent) + increment = Decimal(1) + last = arg_to_decimal(args[1]).quantize(first) + else: + first = arg_to_decimal(args[0]) + increment = arg_to_decimal(args[1]) + exponent = cast( + int, min(first.as_tuple().exponent, increment.as_tuple().exponent) + ) + last = arg_to_decimal(args[2]).quantize( + first + if cast(int, first.as_tuple().exponent) + < cast(int, increment.as_tuple().exponent) + else increment + ) + + if not increment: + parser.error(f"invalid zero increment value: '{increment}'") + + formatstr: str + + if opts.equal_width: + padding = 0 if last == 0 else math.floor(math.log10(last)) + padding += -exponent + 2 if exponent else 1 + if first < 0 or last < 0: + padding += 1 + + formatstr = f"%0{padding}.{-exponent}f" if exponent else f"%0{padding}g" + elif opts.format: + formatstr = opts.format + else: + formatstr = f"%.{-exponent}f" if exponent else "%g" + + scale = 10**-exponent + + print(formatstr % first, end="") + for i in range( + int((first + increment) * scale), int(last * scale) + 1, int(increment * scale) + ): + print(opts.separator + formatstr % (i / scale), end="") + print() + + return 0 diff --git a/userland/utilities/sleep.py b/userland/utilities/sleep.py index b531b98..c0b006c 100644 --- a/userland/utilities/sleep.py +++ b/userland/utilities/sleep.py @@ -7,8 +7,8 @@ SUFFIXES = {"s": 1, "m": 60, "h": 60 * 60, "d": 24 * 60 * 60} -parser = core.create_parser( - usage=("%prog DURATION[SUFFIX]...",), +parser = core.ExtendedOptionParser( + usage="%prog DURATION[SUFFIX]...", description=( "Delay for the sum of each DURATION." f" SUFFIX may be one of the following: {", ".join(SUFFIXES.keys())}." @@ -17,7 +17,7 @@ @core.command(parser) -def python_userland_sleep(_, args): +def python_userland_sleep(_, args) -> int: total_secs = Decimal() for spec in args: @@ -28,10 +28,6 @@ def python_userland_sleep(_, args): parser.error(f"invalid duration: {spec}") total_secs += Decimal(spec[:-1]) * multiplier - try: - time.sleep(float(total_secs)) - except KeyboardInterrupt: - print() - return 130 + time.sleep(float(total_secs)) return 0 diff --git a/userland/utilities/sum.py b/userland/utilities/sum.py index 4a62946..54ec2ef 100644 --- a/userland/utilities/sum.py +++ b/userland/utilities/sum.py @@ -15,7 +15,7 @@ def sum_bsd(data: bytes) -> str: # SYSV checksum -def sum_sysv(data: bytes) -> int: +def sum_sysv(data: bytes) -> str: s = sum(data) r = s % 2**16 + (s % 2**32) // 2**16 checksum = (r % 2**16) + r // 2**16 @@ -25,8 +25,8 @@ def sum_sysv(data: bytes) -> int: SUM_ALGORITHMS = {"bsd": sum_bsd, "sysv": sum_sysv} -parser = core.create_parser( - usage=("%prog [OPTION] [FILE]...",), +parser = core.ExtendedOptionParser( + usage="%prog [OPTION] [FILE]...", ) parser.add_option( @@ -47,12 +47,18 @@ def sum_sysv(data: bytes) -> int: @core.command(parser) -def python_userland_sum(opts, args): +def python_userland_sum(opts, args: list[str]) -> int: + failed = False + for name in args or ["-"]: if name == "-": print(SUM_ALGORITHMS[opts.algorithm](sys.stdin.buffer.read())) else: - with open(name, "rb") as io: - print(f"{SUM_ALGORITHMS[opts.algorithm](io.read())} {name}") + try: + with open(name, "rb") as f: + print(f"{SUM_ALGORITHMS[opts.algorithm](f.read())} {name}") + except OSError as e: + failed = True + core.perror(e) - return 0 + return int(failed) diff --git a/userland/utilities/sync.py b/userland/utilities/sync.py index cbbf008..cfadf8f 100644 --- a/userland/utilities/sync.py +++ b/userland/utilities/sync.py @@ -1,29 +1,44 @@ import os -import sys + +from tqdm import tqdm from .. import core -parser = core.create_parser( - usage=("%prog [FILE]...",), +parser = core.ExtendedOptionParser( + usage="%prog [OPTION] [FILE]...", description="Sync the filesystem or write each FILE's blocks to disk.", ) +parser.add_option( + "--progress", + dest="progress", + action="store_true", + help="show a progress bar when syncing files", +) +parser.add_option( + "--no-progress", + dest="progress", + action="store_false", + default=False, + help="do not show a progress bar (default)", +) + @core.command(parser) -def python_userland_sync(_, args): - if args: - failed = False - - for name in args: - try: - with open(name, "rb+") as io: - os.fsync(io) - except OSError as e: - failed = True - print(e, file=sys.stderr) - - return int(failed) - - os.sync() - return 0 +def python_userland_sync(opts, args: list[str]) -> int: + if not args: + os.sync() + return 0 + + failed = False + + for name in tqdm(args, ascii=True, desc="Syncing files") if opts.progress else args: + try: + with open(name, "rb+") as f: + os.fsync(f) + except OSError as e: + failed = True + core.perror(e) + + return int(failed) diff --git a/userland/utilities/true.py b/userland/utilities/true.py index fa85bfb..118a193 100644 --- a/userland/utilities/true.py +++ b/userland/utilities/true.py @@ -5,7 +5,7 @@ @core.command() -def python_userland_true(_, args): +def python_userland_true(_, args: list[str]) -> int: if args and args[0] == "--help": print( f"""\ @@ -16,3 +16,5 @@ def python_userland_true(_, args): Options: --help show usage information and exit""" ) + + return 0 diff --git a/userland/utilities/truncate.py b/userland/utilities/truncate.py index c1110cd..d29d193 100644 --- a/userland/utilities/truncate.py +++ b/userland/utilities/truncate.py @@ -1,11 +1,21 @@ -import sys +import operator from pathlib import Path from typing import Callable +from tqdm import tqdm + from .. import core +PREFIXES: dict[str, Callable[[int, int], int]] = { + "+": operator.add, + "-": operator.sub, + "<": min, + ">": max, + "/": lambda old_size, size_num: size_num * (old_size // size_num), + "%": lambda old_size, size_num: size_num * -(old_size // -size_num), +} -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=( "%prog [OPTION]... -s SIZE FILE...", "%prog [OPTION]... -r RFILE FILE...", @@ -13,6 +23,20 @@ description="Shrink or extend each FILE to SIZE.", ) +parser.add_option( + "--progress", + dest="progress", + action="store_true", + help="show a progress bar when truncating files", +) +parser.add_option( + "--no-progress", + dest="progress", + action="store_false", + default=False, + help="do not show a progress bar (default)", +) + parser.add_option("-c", "--no-create", action="store_true", help="do not create files") parser.add_option("-s", "--size", help="set or adjust file size by SIZE bytes") parser.add_option( @@ -25,20 +49,30 @@ parser.add_option("-r", "--reference", metavar="RFILE", help="base size on RFILE") +def parse_size_spec(spec: str) -> tuple[str | None, int]: + prefix = spec[0] if spec[0] in frozenset("+-<>/%") else None + return prefix, int(spec[1:] if prefix else spec) + + +def get_size_changer(prefix: str | None, num: int | None) -> Callable[[int], int]: + if prefix: + assert num is not None + return lambda old_size: PREFIXES[prefix](old_size, num) + + return (lambda _: num) if num is not None else (lambda old_size: old_size) + + @core.command(parser) -def python_userland_truncate(opts, args): +def python_userland_truncate(opts, args: list[str]) -> int: if opts.reference: opts.reference = Path(opts.reference) - size_prefix: int | None = None + size_prefix: str | None = None size_num: int | None = None if opts.size: - if opts.size[0] in frozenset("+-<>/%"): - size_prefix = opts.size[0] - try: - size_num = int(opts.size[1:] if size_prefix else opts.size) + size_prefix, size_num = parse_size_spec(opts.size) except ValueError: parser.error(f"invalid number: '{opts.size}'") @@ -50,22 +84,7 @@ def python_userland_truncate(opts, args): if not args: parser.error("missing file operand") - get_new_size: Callable[[int], int] = ( - { - "+": lambda old_size: old_size + size_num, - "-": lambda old_size: old_size - size_num, - "<": lambda old_size: min(old_size, size_num), - ">": lambda old_size: max(old_size, size_num), - "/": lambda old_size: size_num * (old_size // size_num), - "%": lambda old_size: size_num * -(old_size // -size_num), - }[size_prefix] - if size_prefix - else ( - (lambda _: size_num) - if size_num is not None - else (lambda old_size: old_size) - ) - ) + get_new_size = get_size_changer(size_prefix, size_num) size_attr = "st_blocks" if opts.io_blocks else "st_size" @@ -76,10 +95,14 @@ def python_userland_truncate(opts, args): else None ) except OSError as e: - print(e, file=sys.stderr) + core.perror(e) return 1 - for file in map(Path, args): + failed = False + + for file in map( + Path, tqdm(args, ascii=True, desc="Truncating files") if opts.progress else args + ): if not file.exists() and opts.no_create: continue @@ -92,12 +115,12 @@ def python_userland_truncate(opts, args): continue try: - with file.open("rb+") as io: - io.truncate( + with open(file, "rb+") as f: + f.truncate( new_size * stat.st_blksize if opts.io_blocks else new_size, ) except OSError as e: - print(e, file=sys.stderr) - return 1 + failed = True + core.perror(e) - return 0 + return int(failed) diff --git a/userland/utilities/tty.py b/userland/utilities/tty.py index dba4f71..fe51a6a 100644 --- a/userland/utilities/tty.py +++ b/userland/utilities/tty.py @@ -4,8 +4,8 @@ from .. import core -parser = core.create_parser( - usage=("%prog [OPTION]",), +parser = core.ExtendedOptionParser( + usage="%prog [OPTION]", description="Print the path to the terminal connected to standard input.", ) @@ -19,9 +19,8 @@ @core.command(parser) -def python_userland_tty(opts, args): - if args: - parser.error(f"extra operand '{args[0]}'") +def python_userland_tty(opts, args: list[str]) -> int: + parser.expect_nargs(args, 0) try: ttyname = os.ttyname(sys.stdin.fileno()) diff --git a/userland/utilities/uname.py b/userland/utilities/uname.py index 496366d..f023f0d 100644 --- a/userland/utilities/uname.py +++ b/userland/utilities/uname.py @@ -13,8 +13,8 @@ } -parser = core.create_parser( - usage=("%prog [OPTION]...",), +parser = core.ExtendedOptionParser( + usage="%prog [OPTION]...", description="Print system information.", ) @@ -75,9 +75,8 @@ @core.command(parser) -def python_userland_uname(opts, args): - if args: - parser.error(f"extra operand '{args[0]}'") +def python_userland_uname(opts, args: list[str]) -> int: + parser.expect_nargs(args, 0) extras: list[str] = [] diff --git a/userland/utilities/whoami.py b/userland/utilities/whoami.py index a94c9b7..8feadad 100644 --- a/userland/utilities/whoami.py +++ b/userland/utilities/whoami.py @@ -3,16 +3,15 @@ from .. import core -parser = core.create_parser( - usage=("%prog",), +parser = core.ExtendedOptionParser( + usage="%prog", description="Print the current username. Same as `id -un`.", ) @core.command(parser) -def python_userland_whoami(_, args): - if args: - parser.error(f"extra operand '{args[0]}'") +def python_userland_whoami(_, args) -> int: + parser.expect_nargs(args, 0) print(os.getlogin()) diff --git a/userland/utilities/yes.py b/userland/utilities/yes.py index 802681a..fd31867 100644 --- a/userland/utilities/yes.py +++ b/userland/utilities/yes.py @@ -1,17 +1,18 @@ from .. import core -parser = core.create_parser( - ("%prog [STRING]...",), +parser = core.ExtendedOptionParser( + "%prog [STRING]...", description="Repeatedly output a line with STRING(s) (or 'y' by default).", ) @core.command(parser) -def python_userland_yes(_, args): +def python_userland_yes(_, args) -> int: try: string = " ".join(args or ["y"]) while True: print(string) except KeyboardInterrupt: + # Do not emit a trailing newline on keyboard interrupt. return 130