From 4ea011d8764a2682064f11e2d2a92869b4a8975b Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Wed, 2 Apr 2025 03:58:21 +0000 Subject: [PATCH 01/33] clear, reset: Remove unimplemented options --- userland/utilities/clear.py | 3 +-- userland/utilities/reset.py | 12 ------------ 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/userland/utilities/clear.py b/userland/utilities/clear.py index a60789a..1febd8f 100644 --- a/userland/utilities/clear.py +++ b/userland/utilities/clear.py @@ -8,8 +8,6 @@ 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" ) @@ -21,6 +19,7 @@ def python_userland_clear(opts, args): return 1 print("\x1b[2J\x1b[H", end="") + if not opts.x: print("\x1b[3J", end="") diff --git a/userland/utilities/reset.py b/userland/utilities/reset.py index dbaf7f0..2b14575 100644 --- a/userland/utilities/reset.py +++ b/userland/utilities/reset.py @@ -11,10 +11,6 @@ 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,14 +25,6 @@ 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): From 1211c3d4e264c01febef807a20eec96743625215 Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Wed, 2 Apr 2025 06:48:38 +0000 Subject: [PATCH 02/33] env: Add env.py --- userland/utilities/env.py | 98 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 userland/utilities/env.py diff --git a/userland/utilities/env.py b/userland/utilities/env.py new file mode 100644 index 0000000..9bd3e29 --- /dev/null +++ b/userland/utilities/env.py @@ -0,0 +1,98 @@ +import os +import shlex +import sys + +from .. import core + + +parser = core.create_parser( + usage=("%prog [OPTION]... [-] [NAME=VALUE]... [COMMAND [ARG]...]",), +) +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", +) + + +@core.command(parser) +def python_userland_env(opts, args): + if args and args[0] == "-": + opts.ignore_environment = True + del args[0] + + 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 = [] + + 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 + + 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 + + if opts.chdir: + try: + os.chdir(opts.chdir) + except OSError as e: + print(e, file=sys.stderr) + 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: + print(e, file=sys.stderr) + return 126 if isinstance(e, FileNotFoundError) else 127 From 36c75e71d3e010024c569e5e864391d3564cb1ce Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Wed, 2 Apr 2025 06:53:52 +0000 Subject: [PATCH 03/33] env: Add missing description --- userland/utilities/env.py | 1 + 1 file changed, 1 insertion(+) diff --git a/userland/utilities/env.py b/userland/utilities/env.py index 9bd3e29..d30920d 100644 --- a/userland/utilities/env.py +++ b/userland/utilities/env.py @@ -7,6 +7,7 @@ parser = core.create_parser( usage=("%prog [OPTION]... [-] [NAME=VALUE]... [COMMAND [ARG]...]",), + description="Run a program in a modified environment or print environment variables.", ) parser.disable_interspersed_args() From 1aea663bfd34e53c8e90304925ed9bbeb3786ae7 Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Tue, 8 Apr 2025 09:44:31 +0000 Subject: [PATCH 04/33] seq: Add seq.py and test script --- tests/seq.sh | 18 +++++++ userland/utilities/seq.py | 98 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100755 tests/seq.sh create mode 100644 userland/utilities/seq.py diff --git a/tests/seq.sh b/tests/seq.sh new file mode 100755 index 0000000..0a6787b --- /dev/null +++ b/tests/seq.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env sh + +set -eux + +set -- \ + '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/userland/utilities/seq.py b/userland/utilities/seq.py new file mode 100644 index 0000000..3bb9129 --- /dev/null +++ b/userland/utilities/seq.py @@ -0,0 +1,98 @@ +import math +import sys +from decimal import Decimal, InvalidOperation + +from .. import core + + +parser = core.create_parser( + 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): + if not args: + parser.error("missing operand") + + if opts.format and opts.equal_width: + parser.error("--format and --equal-width are mutually exclusive") + + def arg_to_decimal(arg: str) -> Decimal: + try: + return Decimal( + arg, + ) + except InvalidOperation: + print(f"invalid decimal argument: {arg}", file=sys.stderr) + sys.exit(1) + + if len(args) == 1: + first = Decimal(1) + increment = Decimal(1) + last = arg_to_decimal(args[0]) + exponent = 0 + elif len(args) == 2: + first = arg_to_decimal(args[0]) + exponent = 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 = min(first.as_tuple().exponent, increment.as_tuple().exponent) + last = arg_to_decimal(args[2]).quantize( + first + if first.as_tuple().exponent < increment.as_tuple().exponent + else increment + ) + + formatstr: str + + if opts.equal_width: + padding = 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 From b8cb848d3d73dc3bee53a1ee7ad0670e61052d80 Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Tue, 8 Apr 2025 09:47:39 +0000 Subject: [PATCH 05/33] env: Fix inconsistent return values --- userland/utilities/env.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/userland/utilities/env.py b/userland/utilities/env.py index d30920d..d7b1f82 100644 --- a/userland/utilities/env.py +++ b/userland/utilities/env.py @@ -81,7 +81,7 @@ def python_userland_env(opts, args): if not prog_args: for name, value in env.items(): print(f"{name}={value}", end="\0" if opts.null else "\n") - return + return 0 if opts.chdir: try: @@ -97,3 +97,5 @@ def python_userland_env(opts, args): except OSError as e: print(e, file=sys.stderr) return 126 if isinstance(e, FileNotFoundError) else 127 + + return 0 From 196a476174ca4ef39f39c75ff0e8ccddcc38945e Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Thu, 10 Apr 2025 01:02:31 +0000 Subject: [PATCH 06/33] sync, truncate: Add `--progress`/`--no-progress` support Progress bars for coreutils are an extension specific to python-userland. --- pyproject.toml | 3 +++ userland/utilities/sync.py | 24 +++++++++++++++++++++--- userland/utilities/truncate.py | 20 +++++++++++++++++++- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 128d14c..2078fef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,9 @@ build-backend = "setuptools.build_meta" name = "userland" version = "0.0.2" requires-python = ">=3.13" +dependencies = [ + "tqdm>=4.67.1" +] authors = [ { name="Expertcoderz", email="expertcoderzx@gmail.com"} ] diff --git a/userland/utilities/sync.py b/userland/utilities/sync.py index cbbf008..a7b79f4 100644 --- a/userland/utilities/sync.py +++ b/userland/utilities/sync.py @@ -1,21 +1,39 @@ import os import sys +from tqdm import tqdm + from .. import core parser = core.create_parser( - usage=("%prog [FILE]...",), + 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): +def python_userland_sync(opts, args): if args: failed = False - for name in args: + for name in ( + tqdm(args, ascii=True, desc="Syncing files") if opts.progress else args + ): try: with open(name, "rb+") as io: os.fsync(io) diff --git a/userland/utilities/truncate.py b/userland/utilities/truncate.py index c1110cd..dab1687 100644 --- a/userland/utilities/truncate.py +++ b/userland/utilities/truncate.py @@ -2,6 +2,8 @@ from pathlib import Path from typing import Callable +from tqdm import tqdm + from .. import core @@ -13,6 +15,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( @@ -79,7 +95,9 @@ def python_userland_truncate(opts, args): print(e, file=sys.stderr) return 1 - for file in map(Path, args): + 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 From 86cbe1407512dc6afc4dcf81242b3475dd8f4883 Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Fri, 11 Apr 2025 01:27:18 +0000 Subject: [PATCH 07/33] chgrp: Add chgrp.py --- userland/utilities/chgrp.py | 267 ++++++++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 userland/utilities/chgrp.py diff --git a/userland/utilities/chgrp.py b/userland/utilities/chgrp.py new file mode 100644 index 0000000..b11147e --- /dev/null +++ b/userland/utilities/chgrp.py @@ -0,0 +1,267 @@ +import grp +import pwd +import shutil +import sys +from pathlib import Path + +from tqdm import tqdm + +from .. import core + + +parser = core.create_parser( + 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)", +) + + +@core.command(parser) +def python_userland_chgrp(opts, args): + if not args: + parser.error("missing operand") + + from_uid: int | None = None + from_gid: int | None = None + + if opts.from_spec: + from_spec = opts.from_spec.split(":") + + if from_spec[0]: + try: + from_uid = pwd.getpwnam(from_spec[0]) + except KeyError: + parser.error(f"invalid user: '{opts.from_spec}'") + + if len(from_spec) > 1 and from_spec[1]: + try: + from_gid = grp.getgrnam(from_spec[1]) + except KeyError: + parser.error(f"invalid group: '{opts.from_spec}'") + + gid: int + gname: str | None = None + + if opts.reference: + try: + gid = Path(opts.reference).stat(follow_symlinks=True).st_gid + except OSError as e: + print(e, file=sys.stderr) + return 1 + else: + gname = args.pop(0) + + if not args: + parser.error(f"missing operand after '{gname}'") + + if gname.isdecimal(): + gid = int(gname) + else: + try: + gid = grp.getgrnam(gname).gr_gid + except KeyError: + parser.error(f"invalid group: '{gname}'") + + failed = False + + def chown(file: Path) -> None: + nonlocal failed + + try: + stat = file.stat(follow_symlinks=opts.dereference) + prev_uid = stat.st_uid + prev_gid = stat.st_gid + except OSError as e: + failed = True + if opts.verbosity: + print(e, file=sys.stderr) + print( + f"failed to change group of '{file}' to {gname or gid}", + file=sys.stderr, + ) + return + + try: + prev_gname = grp.getgrgid(prev_gid).gr_name + except KeyError: + prev_gname = str(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}") + return + + try: + shutil.chown(file, group=gid, follow_symlinks=opts.dereference) + except OSError as e: + failed = True + if opts.verbosity: + print(e, file=sys.stderr) + if opts.verbosity: + print( + f"failed to change group of '{file}' to {gname or gid}", + file=sys.stderr, + ) + return + + 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}") + + files = map( + Path, + ( + tqdm(args, ascii=True, desc="Changing group ownership") + if opts.progress + else args + ), + ) + + if opts.recursive: + + def traverse(file: Path) -> None: + nonlocal failed + + if opts.preserve_root and file.root == str(file): + print( + f"recursive operation on '{file}' prevented; use --no-preserve-root to override", + file=sys.stderr, + ) + failed = True + return + + for child in file.iterdir(): + if child.is_dir(follow_symlinks=opts.recurse_mode == "L"): + traverse(child) + chown(file) + + for file in files: + if file.is_dir( + follow_symlinks=opts.recurse_mode == "H" or opts.recurse_mode == "L" + ): + traverse(file) + else: + chown(file) + else: + for file in files: + chown(file) + + return int(failed) From f48bed88a81c4691ce0dd28497097a6b7e6c9b24 Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Fri, 11 Apr 2025 15:48:12 +0000 Subject: [PATCH 08/33] chgrp: Move `--from` argument parsing logic to core module This prevents duplication when it has to be used in some other future utilities such as chown. Also added support for numeric UIDs/GIDs instead of only accepting user/group names. --- userland/core/__init__.py | 1 + userland/core/users.py | 38 +++++++++++++++++++++++++++++++++++++ userland/utilities/chgrp.py | 14 +------------- 3 files changed, 40 insertions(+), 13 deletions(-) create mode 100644 userland/core/users.py diff --git a/userland/core/__init__.py b/userland/core/__init__.py index fa43b6c..3ed55ed 100644 --- a/userland/core/__init__.py +++ b/userland/core/__init__.py @@ -1,2 +1,3 @@ from .command import * from .io import * +from .users import * diff --git a/userland/core/users.py b/userland/core/users.py new file mode 100644 index 0000000..1fefbd7 --- /dev/null +++ b/userland/core/users.py @@ -0,0 +1,38 @@ +import grp +import pwd + +from optparse import OptionParser + + +def parse_onwer_spec( + parser: OptionParser, owner_spec: str +) -> tuple[int | None, int | None]: + """ + Process 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]: + if tokens[0].isdecimal(): + uid = int(tokens[0]) + else: + try: + uid = pwd.getpwnam(tokens[0]) + except KeyError: + parser.error(f"invalid user: '{tokens}'") + + if len(tokens) > 1 and tokens[1]: + if tokens[1].isdecimal(): + gid = int(tokens[1]) + else: + try: + gid = grp.getgrnam(tokens[1]) + except KeyError: + parser.error(f"invalid group: '{tokens}'") + + return uid, gid diff --git a/userland/utilities/chgrp.py b/userland/utilities/chgrp.py index b11147e..9011ca3 100644 --- a/userland/utilities/chgrp.py +++ b/userland/utilities/chgrp.py @@ -135,19 +135,7 @@ def python_userland_chgrp(opts, args): from_gid: int | None = None if opts.from_spec: - from_spec = opts.from_spec.split(":") - - if from_spec[0]: - try: - from_uid = pwd.getpwnam(from_spec[0]) - except KeyError: - parser.error(f"invalid user: '{opts.from_spec}'") - - if len(from_spec) > 1 and from_spec[1]: - try: - from_gid = grp.getgrnam(from_spec[1]) - except KeyError: - parser.error(f"invalid group: '{opts.from_spec}'") + from_uid, from_gid = core.parse_onwer_spec(parser, opts.from_spec) gid: int gname: str | None = None From de3d12c760c0f4f23c2ad07e6a949c6894f87e76 Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Fri, 11 Apr 2025 15:50:40 +0000 Subject: [PATCH 09/33] chgrp: Fix `--from` handling logic --- userland/utilities/chgrp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/userland/utilities/chgrp.py b/userland/utilities/chgrp.py index 9011ca3..3b995c6 100644 --- a/userland/utilities/chgrp.py +++ b/userland/utilities/chgrp.py @@ -188,8 +188,8 @@ def chown(file: Path) -> None: # 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 (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}") From 31256bf8474d37f95d8654aef22c0e4e6f96a8c6 Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Sat, 12 Apr 2025 03:12:45 +0000 Subject: [PATCH 10/33] chgrp: Improve `--preserve-root` handling efficiency --- userland/utilities/chgrp.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/userland/utilities/chgrp.py b/userland/utilities/chgrp.py index 3b995c6..5ae3163 100644 --- a/userland/utilities/chgrp.py +++ b/userland/utilities/chgrp.py @@ -88,7 +88,7 @@ parser.add_option( "--from", dest="from_spec", # prevent name collision with the `from` keyword - metavar="[CURRENT_OWNER][:CURRENT_GROUP]", + metavar="[CURRENT_OWNER][:[CURRENT_GROUP]]", help="only affect files with CURRENT_OWNER and CURRENT_GROUP" " (either is optional and only checked if given)", ) @@ -226,16 +226,6 @@ def chown(file: Path) -> None: if opts.recursive: def traverse(file: Path) -> None: - nonlocal failed - - if opts.preserve_root and file.root == str(file): - print( - f"recursive operation on '{file}' prevented; use --no-preserve-root to override", - file=sys.stderr, - ) - failed = True - return - for child in file.iterdir(): if child.is_dir(follow_symlinks=opts.recurse_mode == "L"): traverse(child) @@ -245,6 +235,15 @@ def traverse(file: Path) -> None: if file.is_dir( follow_symlinks=opts.recurse_mode == "H" or opts.recurse_mode == "L" ): + if opts.preserve_root and file.root == str(file): + failed = True + print( + f"recursive operation on '{file}' prevented;" + " use --no-preserve-root to override", + file=sys.stderr, + ) + continue + traverse(file) else: chown(file) From 6383f56375acfd44cd3a7887546c7a09bf7dc01d Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Sat, 12 Apr 2025 10:34:55 +0000 Subject: [PATCH 11/33] chgrp: Move a bunch of stuff to the core modules --- userland/core/__init__.py | 1 + userland/core/paths.py | 34 ++++++++++++++++++ userland/core/users.py | 65 +++++++++++++++++++++++++-------- userland/utilities/chgrp.py | 72 +++++++++---------------------------- 4 files changed, 102 insertions(+), 70 deletions(-) create mode 100644 userland/core/paths.py diff --git a/userland/core/__init__.py b/userland/core/__init__.py index 3ed55ed..9f2f8f9 100644 --- a/userland/core/__init__.py +++ b/userland/core/__init__.py @@ -1,3 +1,4 @@ from .command import * from .io import * +from .paths import * from .users import * diff --git a/userland/core/paths.py b/userland/core/paths.py new file mode 100644 index 0000000..64b0047 --- /dev/null +++ b/userland/core/paths.py @@ -0,0 +1,34 @@ +import sys +from pathlib import Path +from typing import Generator, Literal + + +def traverse_files( + filenames: list[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 index 1fefbd7..d5950ab 100644 --- a/userland/core/users.py +++ b/userland/core/users.py @@ -1,3 +1,4 @@ +import functools import grp import pwd @@ -8,7 +9,7 @@ def parse_onwer_spec( parser: OptionParser, owner_spec: str ) -> tuple[int | None, int | None]: """ - Process a string in the form ``[USER][:GROUP]`` and return the UID and GID. + 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. """ @@ -18,21 +19,55 @@ def parse_onwer_spec( gid: int | None = None if tokens[0]: - if tokens[0].isdecimal(): - uid = int(tokens[0]) - else: - try: - uid = pwd.getpwnam(tokens[0]) - except KeyError: - parser.error(f"invalid user: '{tokens}'") + uid = parse_user(parser, tokens[0]) if len(tokens) > 1 and tokens[1]: - if tokens[1].isdecimal(): - gid = int(tokens[1]) - else: - try: - gid = grp.getgrnam(tokens[1]) - except KeyError: - parser.error(f"invalid group: '{tokens}'") + gid = parse_group(parser, tokens[1]) return uid, gid + + +@functools.lru_cache(1000) +def parse_user(parser: OptionParser, 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: + parser.error(f"invalid user: {user}") + + +@functools.lru_cache(1000) +def parse_group(parser: OptionParser, 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: + parser.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/chgrp.py b/userland/utilities/chgrp.py index 5ae3163..e350462 100644 --- a/userland/utilities/chgrp.py +++ b/userland/utilities/chgrp.py @@ -1,5 +1,3 @@ -import grp -import pwd import shutil import sys from pathlib import Path @@ -152,18 +150,22 @@ def python_userland_chgrp(opts, args): if not args: parser.error(f"missing operand after '{gname}'") - if gname.isdecimal(): - gid = int(gname) - else: - try: - gid = grp.getgrnam(gname).gr_gid - except KeyError: - parser.error(f"invalid group: '{gname}'") + gid = core.parse_group(parser, gname) failed = False - def chown(file: Path) -> None: - nonlocal failed + 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) @@ -177,12 +179,9 @@ def chown(file: Path) -> None: f"failed to change group of '{file}' to {gname or gid}", file=sys.stderr, ) - return + continue - try: - prev_gname = grp.getgrgid(prev_gid).gr_name - except KeyError: - prev_gname = str(prev_gid) + 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 @@ -193,7 +192,7 @@ def chown(file: Path) -> None: ): if opts.verbosity > 2: print(f"group of '{file}' retained as {prev_gname}") - return + continue try: shutil.chown(file, group=gid, follow_symlinks=opts.dereference) @@ -206,7 +205,7 @@ def chown(file: Path) -> None: f"failed to change group of '{file}' to {gname or gid}", file=sys.stderr, ) - return + continue if prev_gid == gid: if opts.verbosity > 2: @@ -214,41 +213,4 @@ def chown(file: Path) -> None: elif opts.verbosity > 1: print(f"changed group of '{file}' from {prev_gname} to {gname or gid}") - files = map( - Path, - ( - tqdm(args, ascii=True, desc="Changing group ownership") - if opts.progress - else args - ), - ) - - if opts.recursive: - - def traverse(file: Path) -> None: - for child in file.iterdir(): - if child.is_dir(follow_symlinks=opts.recurse_mode == "L"): - traverse(child) - chown(file) - - for file in files: - if file.is_dir( - follow_symlinks=opts.recurse_mode == "H" or opts.recurse_mode == "L" - ): - if opts.preserve_root and file.root == str(file): - failed = True - print( - f"recursive operation on '{file}' prevented;" - " use --no-preserve-root to override", - file=sys.stderr, - ) - continue - - traverse(file) - else: - chown(file) - else: - for file in files: - chown(file) - return int(failed) From 13d23b46c0a201f5c31b32cae9a5491501d2a1fa Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Sat, 12 Apr 2025 11:54:18 +0000 Subject: [PATCH 12/33] chown: Add chown.py --- userland/utilities/chown.py | 225 ++++++++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 userland/utilities/chown.py diff --git a/userland/utilities/chown.py b/userland/utilities/chown.py new file mode 100644 index 0000000..7a6b881 --- /dev/null +++ b/userland/utilities/chown.py @@ -0,0 +1,225 @@ +import re + +import shutil +import sys +from pathlib import Path + +from tqdm import tqdm + +from .. import core + + +CHOWN_PATTERN = re.compile("^([^:]+)?(:([^:]+))?$") + +parser = core.create_parser( + 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)", +) + + +@core.command(parser) +def python_userland_chown(opts, args): + if not args: + parser.error("missing operand") + + from_uid: int | None = None + from_gid: int | None = None + + if opts.from_spec: + from_uid, from_gid = core.parse_onwer_spec(parser, opts.from_spec) + + chown_args = {"follow_symlinks": opts.dereference} + + if opts.reference: + try: + ref_stat = Path(opts.reference).stat(follow_symlinks=True) + except OSError as e: + print(e, file=sys.stderr) + return 1 + + chown_args["user"] = ref_stat.st_uid + chown_args["group"] = ref_stat.st_gid + else: + owner_spec = args.pop(0) + + if not args: + parser.error(f"missing operand after '{owner_spec}'") + + if not (owner_match := CHOWN_PATTERN.match(owner_spec)): + parser.error(f"invalid owner spec: {owner_spec}") + + chown_args["user"] = ( + core.parse_user(parser, owner_match.group(1)) + if owner_match.group(1) + else None + ) + chown_args["group"] = ( + core.parse_group(parser, owner_match.group(3)) + if owner_match.group(3) + else None + ) + + failed = False + + 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: + failed = True + print(e, file=sys.stderr) + print( + f"failed to change ownership of '{file}' to {owner_spec}", + file=sys.stderr, + ) + 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: + failed = True + if opts.verbosity: + print(e, file=sys.stderr) + print( + f"failed to change ownership of '{file}' to {owner_spec}", + file=sys.stderr, + ) + 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) From fec7af9e69043a10f7b153324ac1aacefc6c3b56 Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Sat, 12 Apr 2025 15:08:27 +0000 Subject: [PATCH 13/33] seq: Use `parser.error()` for reporting invalid decimal arguments --- userland/utilities/seq.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/userland/utilities/seq.py b/userland/utilities/seq.py index 3bb9129..502a304 100644 --- a/userland/utilities/seq.py +++ b/userland/utilities/seq.py @@ -1,5 +1,4 @@ import math -import sys from decimal import Decimal, InvalidOperation from .. import core @@ -45,12 +44,9 @@ def python_userland_seq(opts, args): def arg_to_decimal(arg: str) -> Decimal: try: - return Decimal( - arg, - ) + return Decimal(arg) except InvalidOperation: - print(f"invalid decimal argument: {arg}", file=sys.stderr) - sys.exit(1) + parser.error(f"invalid decimal argument: {arg}") if len(args) == 1: first = Decimal(1) From 96ae1485e3dec4c463c18069054b72843eb7ff58 Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Sat, 12 Apr 2025 15:19:15 +0000 Subject: [PATCH 14/33] Create pylint.yml --- .github/workflows/pylint.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/pylint.yml diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml new file mode 100644 index 0000000..3ca7b11 --- /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 + - name: Analysing the code with pylint + run: | + pylint $(git ls-files '*.py') From 2c1fcd9b2a72517b0f97c86a2b5a8be32827785c Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Mon, 14 Apr 2025 01:13:09 +0000 Subject: [PATCH 15/33] Consolidate parser-related functions into ExtendedOptionParser --- userland/core/command.py | 51 ++++++++++++------ userland/core/users.py | 95 ++++++++++++++++------------------ userland/utilities/basename.py | 8 ++- userland/utilities/cat.py | 2 +- userland/utilities/chgrp.py | 13 ++--- userland/utilities/chown.py | 15 +++--- userland/utilities/clear.py | 2 +- userland/utilities/dirname.py | 5 +- userland/utilities/echo.py | 7 ++- userland/utilities/env.py | 2 +- userland/utilities/factor.py | 2 +- userland/utilities/groups.py | 2 +- userland/utilities/hostid.py | 5 +- userland/utilities/id.py | 2 +- userland/utilities/logname.py | 5 +- userland/utilities/nologin.py | 2 +- userland/utilities/nproc.py | 5 +- userland/utilities/printenv.py | 2 +- userland/utilities/pwd.py | 2 +- userland/utilities/readlink.py | 5 +- userland/utilities/realpath.py | 5 +- userland/utilities/reset.py | 2 +- userland/utilities/seq.py | 5 +- userland/utilities/sleep.py | 2 +- userland/utilities/sum.py | 2 +- userland/utilities/sync.py | 2 +- userland/utilities/truncate.py | 2 +- userland/utilities/tty.py | 5 +- userland/utilities/uname.py | 5 +- userland/utilities/whoami.py | 5 +- userland/utilities/yes.py | 2 +- 31 files changed, 131 insertions(+), 138 deletions(-) diff --git a/userland/core/command.py b/userland/core/command.py index 7c6b553..d6295f5 100644 --- a/userland/core/command.py +++ b/userland/core/command.py @@ -2,23 +2,40 @@ 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 .users import OptionParserUsersMixin + + +class ExtendedOptionParser(OptionParserUsersMixin, OptionParser): + def __init__(self, usage: tuple[str], **kwargs): + super().__init__( + usage="Usage: " + f"\n{7 * " "}".join(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): diff --git a/userland/core/users.py b/userland/core/users.py index d5950ab..2ab5f44 100644 --- a/userland/core/users.py +++ b/userland/core/users.py @@ -5,56 +5,51 @@ from optparse import OptionParser -def parse_onwer_spec( - parser: OptionParser, 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 = parse_user(parser, tokens[0]) - - if len(tokens) > 1 and tokens[1]: - gid = parse_group(parser, tokens[1]) - - return uid, gid - - -@functools.lru_cache(1000) -def parse_user(parser: OptionParser, 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: - parser.error(f"invalid user: {user}") - - -@functools.lru_cache(1000) -def parse_group(parser: OptionParser, 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: - parser.error(f"invalid group: {group}") +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 + + 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}") + + 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) diff --git a/userland/utilities/basename.py b/userland/utilities/basename.py index 7b591b5..636ac7b 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.", ) @@ -27,14 +27,12 @@ @core.command(parser) def python_userland_basename(opts, args): - if not args: - parser.error("missing operand") + 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..edfc19f 100644 --- a/userland/utilities/cat.py +++ b/userland/utilities/cat.py @@ -94,7 +94,7 @@ def cat_io(opts, io: BinaryIO) -> None: sys.stdout.buffer.flush() -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog [OPTION]... [FILE]...",), description="Concatenate each FILE to standard output.", ) diff --git a/userland/utilities/chgrp.py b/userland/utilities/chgrp.py index e350462..9e70e7e 100644 --- a/userland/utilities/chgrp.py +++ b/userland/utilities/chgrp.py @@ -7,7 +7,7 @@ from .. import core -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=( "%prog [OPTION]... GROUP FILE...", "%prog [OPTION]... --reference=RFILE FILE...", @@ -126,14 +126,13 @@ @core.command(parser) def python_userland_chgrp(opts, args): - if not args: - parser.error("missing operand") + parser.expect_nargs(args, (1,)) from_uid: int | None = None from_gid: int | None = None if opts.from_spec: - from_uid, from_gid = core.parse_onwer_spec(parser, opts.from_spec) + from_uid, from_gid = parser.parse_owner_spec(opts.from_spec) gid: int gname: str | None = None @@ -145,12 +144,10 @@ def python_userland_chgrp(opts, args): print(e, file=sys.stderr) return 1 else: + parser.expect_nargs(args, (2,)) gname = args.pop(0) - if not args: - parser.error(f"missing operand after '{gname}'") - - gid = core.parse_group(parser, gname) + gid = parser.parse_group(gname) failed = False diff --git a/userland/utilities/chown.py b/userland/utilities/chown.py index 7a6b881..7c6da31 100644 --- a/userland/utilities/chown.py +++ b/userland/utilities/chown.py @@ -11,7 +11,7 @@ CHOWN_PATTERN = re.compile("^([^:]+)?(:([^:]+))?$") -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=( "%prog [OPTION]... [USER][:[GROUP]] FILE...", "%prog [OPTION]... --reference=RFILE FILE...", @@ -127,14 +127,13 @@ @core.command(parser) def python_userland_chown(opts, args): - if not args: - parser.error("missing operand") + parser.expect_nargs(args, (1,)) from_uid: int | None = None from_gid: int | None = None if opts.from_spec: - from_uid, from_gid = core.parse_onwer_spec(parser, opts.from_spec) + from_uid, from_gid = parser.parse_owner_spec(opts.from_spec) chown_args = {"follow_symlinks": opts.dereference} @@ -148,21 +147,19 @@ def python_userland_chown(opts, args): chown_args["user"] = ref_stat.st_uid chown_args["group"] = ref_stat.st_gid else: + parser.expect_nargs(args, (2,)) owner_spec = args.pop(0) - if not args: - parser.error(f"missing operand after '{owner_spec}'") - if not (owner_match := CHOWN_PATTERN.match(owner_spec)): parser.error(f"invalid owner spec: {owner_spec}") chown_args["user"] = ( - core.parse_user(parser, owner_match.group(1)) + parser.parse_user(owner_match.group(1)) if owner_match.group(1) else None ) chown_args["group"] = ( - core.parse_group(parser, owner_match.group(3)) + parser.parse_group(owner_match.group(3)) if owner_match.group(3) else None ) diff --git a/userland/utilities/clear.py b/userland/utilities/clear.py index 1febd8f..bcb2996 100644 --- a/userland/utilities/clear.py +++ b/userland/utilities/clear.py @@ -3,7 +3,7 @@ # clear(1), roughly modelled off the ncurses implementation. -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog [OPTION]...",), description="Clear the terminal screen.", ) diff --git a/userland/utilities/dirname.py b/userland/utilities/dirname.py index 5683e22..18e4715 100644 --- a/userland/utilities/dirname.py +++ b/userland/utilities/dirname.py @@ -3,7 +3,7 @@ from .. import core -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog [OPTION]... NAME...",), description=( "Print each path NAME with the last component removed," @@ -21,8 +21,7 @@ @core.command(parser) def python_userland_dirname(opts, args): - if not args: - parser.error("missing operand") + 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..7968b2f 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( +parser = PassthroughOptionParser( usage=("%prog [OPTION]... [STRING]...",), description="Print STRING(s) to standard output.", - parser_class=PassthroughOptionParser, ) parser.disable_interspersed_args() diff --git a/userland/utilities/env.py b/userland/utilities/env.py index d7b1f82..4a0a4d5 100644 --- a/userland/utilities/env.py +++ b/userland/utilities/env.py @@ -5,7 +5,7 @@ from .. import core -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog [OPTION]... [-] [NAME=VALUE]... [COMMAND [ARG]...]",), description="Run a program in a modified environment or print environment variables.", ) diff --git a/userland/utilities/factor.py b/userland/utilities/factor.py index 5377389..f165c76 100644 --- a/userland/utilities/factor.py +++ b/userland/utilities/factor.py @@ -100,7 +100,7 @@ def format_exponents(factors: Iterable[int]) -> str: return " ".join(processed) -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog [OPTION] [NUMBER]...",), description="Compute and print the prime factors of each positive integer NUMBER.", ) diff --git a/userland/utilities/groups.py b/userland/utilities/groups.py index fabfe91..a60c431 100644 --- a/userland/utilities/groups.py +++ b/userland/utilities/groups.py @@ -6,7 +6,7 @@ from .. import core -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog [USERNAME]...",), description="Print a list of groups for each USERNAME or the current user.", ) diff --git a/userland/utilities/hostid.py b/userland/utilities/hostid.py index 0ff05ad..b4edfc5 100644 --- a/userland/utilities/hostid.py +++ b/userland/utilities/hostid.py @@ -1,7 +1,7 @@ from .. import core -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog",), description="Print a 32-bit numeric host machine identifier.", epilog="This implementation gives an all-zero identifier.", @@ -10,8 +10,7 @@ @core.command(parser) def python_userland_hostid(_, args): - if args: - parser.error(f"extra operand '{args[0]}'") + 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..13db603 100644 --- a/userland/utilities/id.py +++ b/userland/utilities/id.py @@ -6,7 +6,7 @@ from .. import core -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog [OPTION]... [USER]...",), description="Print user and group information for each USER or the current user.", ) diff --git a/userland/utilities/logname.py b/userland/utilities/logname.py index a63afe1..11c368f 100644 --- a/userland/utilities/logname.py +++ b/userland/utilities/logname.py @@ -3,7 +3,7 @@ from .. import core -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog",), description="Print the current user's login name.", ) @@ -11,8 +11,7 @@ @core.command(parser) def python_userland_logname(_, args): - if args: - parser.error(f"extra operand '{args[0]}'") + parser.expect_nargs(args, 0) print(os.getlogin()) diff --git a/userland/utilities/nologin.py b/userland/utilities/nologin.py index fdaac77..30d5344 100644 --- a/userland/utilities/nologin.py +++ b/userland/utilities/nologin.py @@ -1,7 +1,7 @@ from .. import core -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog",), description="Politely refuse a login.", ) diff --git a/userland/utilities/nproc.py b/userland/utilities/nproc.py index 8ff0fa7..b347043 100644 --- a/userland/utilities/nproc.py +++ b/userland/utilities/nproc.py @@ -3,7 +3,7 @@ from .. import core -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=(" %prog [OPTION]...",), description="Print the number of processing units available to the process.", ) @@ -25,8 +25,7 @@ @core.command(parser) def python_userland_nproc(opts, args): - if args: - parser.error(f"extra operand '{args[0]}'") + 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..77288c3 100644 --- a/userland/utilities/printenv.py +++ b/userland/utilities/printenv.py @@ -3,7 +3,7 @@ from .. import core -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=(" %prog [OPTION] [VARIABLE]...",), description="Print VARIABLE(s) or all environment variables, and their values.", ) diff --git a/userland/utilities/pwd.py b/userland/utilities/pwd.py index b40ddae..2e68c2b 100644 --- a/userland/utilities/pwd.py +++ b/userland/utilities/pwd.py @@ -3,7 +3,7 @@ from .. import core -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog [OPTION]",), description="Print the path to the current working directory.", ) diff --git a/userland/utilities/readlink.py b/userland/utilities/readlink.py index 5d1e87a..22fd258 100644 --- a/userland/utilities/readlink.py +++ b/userland/utilities/readlink.py @@ -19,7 +19,7 @@ def readlink_function(can_mode: str | None) -> Callable[[Path], str]: return lambda path: path.resolve(strict=can_mode == "e") -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog [OPTION]... FILE...",), description="Print the target of each symbolic link FILE.", ) @@ -83,8 +83,7 @@ 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") + parser.expect_nargs(args, (1,)) if opts.no_newline and len(args) > 1: print("ignoring --no-newline with multiple arguments", file=sys.stderr) diff --git a/userland/utilities/realpath.py b/userland/utilities/realpath.py index f2cef00..3ff916a 100644 --- a/userland/utilities/realpath.py +++ b/userland/utilities/realpath.py @@ -29,7 +29,7 @@ def resolve_filename(opts, name: str) -> str: return name -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog [OPTION]... FILE...",), description="Print the resolved path of each FILE.", ) @@ -100,8 +100,7 @@ def resolve_filename(opts, name: str) -> str: @core.command(parser) def python_userland_realpath(opts, args): - if not args: - parser.error("missing operand") + parser.expect_nargs(args, (1,)) endchar = "\0" if opts.zero else "\n" diff --git a/userland/utilities/reset.py b/userland/utilities/reset.py index 2b14575..3b831e8 100644 --- a/userland/utilities/reset.py +++ b/userland/utilities/reset.py @@ -6,7 +6,7 @@ # reset(1), roughly modelled off the ncurses implementation. -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog [OPTION]... [IGNORED]...",), description="Initialize or reset the terminal state.", ) diff --git a/userland/utilities/seq.py b/userland/utilities/seq.py index 502a304..52de77a 100644 --- a/userland/utilities/seq.py +++ b/userland/utilities/seq.py @@ -4,7 +4,7 @@ from .. import core -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=( "%prog [OPTION]... LAST", "%prog [OPTION]... FIRST LAST", @@ -36,8 +36,7 @@ @core.command(parser) def python_userland_seq(opts, args): - if not args: - parser.error("missing operand") + parser.expect_nargs(args, (1, 3)) if opts.format and opts.equal_width: parser.error("--format and --equal-width are mutually exclusive") diff --git a/userland/utilities/sleep.py b/userland/utilities/sleep.py index b531b98..c87e373 100644 --- a/userland/utilities/sleep.py +++ b/userland/utilities/sleep.py @@ -7,7 +7,7 @@ SUFFIXES = {"s": 1, "m": 60, "h": 60 * 60, "d": 24 * 60 * 60} -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog DURATION[SUFFIX]...",), description=( "Delay for the sum of each DURATION." diff --git a/userland/utilities/sum.py b/userland/utilities/sum.py index 4a62946..3fe9b72 100644 --- a/userland/utilities/sum.py +++ b/userland/utilities/sum.py @@ -25,7 +25,7 @@ def sum_sysv(data: bytes) -> int: SUM_ALGORITHMS = {"bsd": sum_bsd, "sysv": sum_sysv} -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog [OPTION] [FILE]...",), ) diff --git a/userland/utilities/sync.py b/userland/utilities/sync.py index a7b79f4..7f9feff 100644 --- a/userland/utilities/sync.py +++ b/userland/utilities/sync.py @@ -6,7 +6,7 @@ from .. import core -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog [OPTION] [FILE]...",), description="Sync the filesystem or write each FILE's blocks to disk.", ) diff --git a/userland/utilities/truncate.py b/userland/utilities/truncate.py index dab1687..ab90cfa 100644 --- a/userland/utilities/truncate.py +++ b/userland/utilities/truncate.py @@ -7,7 +7,7 @@ from .. import core -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=( "%prog [OPTION]... -s SIZE FILE...", "%prog [OPTION]... -r RFILE FILE...", diff --git a/userland/utilities/tty.py b/userland/utilities/tty.py index dba4f71..ec38867 100644 --- a/userland/utilities/tty.py +++ b/userland/utilities/tty.py @@ -4,7 +4,7 @@ from .. import core -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog [OPTION]",), description="Print the path to the terminal connected to standard input.", ) @@ -20,8 +20,7 @@ @core.command(parser) def python_userland_tty(opts, args): - if args: - parser.error(f"extra operand '{args[0]}'") + 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..919e3aa 100644 --- a/userland/utilities/uname.py +++ b/userland/utilities/uname.py @@ -13,7 +13,7 @@ } -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog [OPTION]...",), description="Print system information.", ) @@ -76,8 +76,7 @@ @core.command(parser) def python_userland_uname(opts, args): - if args: - parser.error(f"extra operand '{args[0]}'") + parser.expect_nargs(args, 0) extras: list[str] = [] diff --git a/userland/utilities/whoami.py b/userland/utilities/whoami.py index a94c9b7..914f165 100644 --- a/userland/utilities/whoami.py +++ b/userland/utilities/whoami.py @@ -3,7 +3,7 @@ from .. import core -parser = core.create_parser( +parser = core.ExtendedOptionParser( usage=("%prog",), description="Print the current username. Same as `id -un`.", ) @@ -11,8 +11,7 @@ @core.command(parser) def python_userland_whoami(_, args): - if args: - parser.error(f"extra operand '{args[0]}'") + parser.expect_nargs(args, 0) print(os.getlogin()) diff --git a/userland/utilities/yes.py b/userland/utilities/yes.py index 802681a..d27e8cd 100644 --- a/userland/utilities/yes.py +++ b/userland/utilities/yes.py @@ -1,7 +1,7 @@ from .. import core -parser = core.create_parser( +parser = core.ExtendedOptionParser( ("%prog [STRING]...",), description="Repeatedly output a line with STRING(s) (or 'y' by default).", ) From fe96e5a970a59cf72151972ad3c08457739c326b Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Mon, 14 Apr 2025 01:55:26 +0000 Subject: [PATCH 16/33] Standardize error handling - Added `core.perror()` for displaying exceptions and error messages in the proper format. - Added `core.safe_open()` for catching exceptions when opening files. --- userland/core/io.py | 21 ++++++++++++++++++++- userland/utilities/cat.py | 18 +++++++++++++----- userland/utilities/chgrp.py | 17 +++++++++-------- userland/utilities/chown.py | 33 ++++++++++++++++----------------- userland/utilities/env.py | 5 ++--- userland/utilities/factor.py | 3 +-- userland/utilities/groups.py | 3 +-- userland/utilities/id.py | 3 +-- userland/utilities/readlink.py | 5 ++--- userland/utilities/realpath.py | 3 +-- userland/utilities/reset.py | 3 +-- userland/utilities/sum.py | 10 ++++++++-- userland/utilities/sync.py | 27 ++++++++++++--------------- userland/utilities/truncate.py | 25 +++++++++++++------------ 14 files changed, 100 insertions(+), 76 deletions(-) diff --git a/userland/core/io.py b/userland/core/io.py index f4321bf..57db826 100644 --- a/userland/core/io.py +++ b/userland/core/io.py @@ -1,5 +1,24 @@ +import contextlib +import os import sys -from typing import Generator +from typing import Any, Generator + + +def perror(*errors: Any) -> None: + print( + f"{os.path.basename(sys.argv[0])}: {"\n".join(map(str, errors))}", + file=sys.stderr, + ) + + +@contextlib.contextmanager +def safe_open(*args, **kwargs): + try: + with open(*args, **kwargs) as io: + yield io + except OSError as e: + perror(e) + yield None def readlines_stdin() -> Generator[str]: diff --git a/userland/utilities/cat.py b/userland/utilities/cat.py index edfc19f..6622df0 100644 --- a/userland/utilities/cat.py +++ b/userland/utilities/cat.py @@ -146,10 +146,18 @@ 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 ["-"] - ] + generators: list[Generator[bytes]] = [] + failed = False + + for name in args or ["-"]: + if name == "-": + generators.append(core.readlines_stdin_raw()) + else: + io = core.safe_open(name, "rb") + if io: + generators.append(io) + else: + failed = True try: cat_io(opts, itertools.chain(*generators)) @@ -162,4 +170,4 @@ def python_userland_cat(opts, args): if isinstance(gen, BufferedReader): gen.close() - return 0 + return int(failed) diff --git a/userland/utilities/chgrp.py b/userland/utilities/chgrp.py index 9e70e7e..b1006ab 100644 --- a/userland/utilities/chgrp.py +++ b/userland/utilities/chgrp.py @@ -141,7 +141,7 @@ def python_userland_chgrp(opts, args): try: gid = Path(opts.reference).stat(follow_symlinks=True).st_gid except OSError as e: - print(e, file=sys.stderr) + core.perror(e) return 1 else: parser.expect_nargs(args, (2,)) @@ -171,11 +171,12 @@ def python_userland_chgrp(opts, args): except OSError as e: failed = True if opts.verbosity: - print(e, file=sys.stderr) - print( - f"failed to change group of '{file}' to {gname or gid}", - file=sys.stderr, - ) + core.perror(e) + if opts.verbosity > 2: + print( + f"failed to change group of '{file}' to {gname or gid}", + file=sys.stderr, + ) continue prev_gname = core.group_display_name_from_id(prev_gid) @@ -196,8 +197,8 @@ def python_userland_chgrp(opts, args): except OSError as e: failed = True if opts.verbosity: - print(e, file=sys.stderr) - if opts.verbosity: + core.perror(e) + if opts.verbosity > 2: print( f"failed to change group of '{file}' to {gname or gid}", file=sys.stderr, diff --git a/userland/utilities/chown.py b/userland/utilities/chown.py index 7c6da31..b142400 100644 --- a/userland/utilities/chown.py +++ b/userland/utilities/chown.py @@ -141,7 +141,7 @@ def python_userland_chown(opts, args): try: ref_stat = Path(opts.reference).stat(follow_symlinks=True) except OSError as e: - print(e, file=sys.stderr) + core.perror(e) return 1 chown_args["user"] = ref_stat.st_uid @@ -154,14 +154,10 @@ def python_userland_chown(opts, args): 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 + 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 + parser.parse_group(owner_match.group(3)) if owner_match.group(3) else None ) failed = False @@ -181,11 +177,13 @@ def python_userland_chown(opts, args): prev_gid = stat.st_gid except OSError as e: failed = True - print(e, file=sys.stderr) - print( - f"failed to change ownership of '{file}' to {owner_spec}", - file=sys.stderr, - ) + if opts.verbosity: + core.perror(e) + if opts.verbosity > 2: + print( + f"failed to change ownership of '{file}' to {owner_spec}", + file=sys.stderr, + ) continue prev_uname = core.user_display_name_from_id(prev_uid) @@ -203,11 +201,12 @@ def python_userland_chown(opts, args): except OSError as e: failed = True if opts.verbosity: - print(e, file=sys.stderr) - print( - f"failed to change ownership of '{file}' to {owner_spec}", - file=sys.stderr, - ) + core.perror(e) + if opts.verbosity > 2: + print( + f"failed to change ownership of '{file}' to {owner_spec}", + file=sys.stderr, + ) continue if prev_uid == chown_args["user"] or prev_gid == chown_args["group"]: diff --git a/userland/utilities/env.py b/userland/utilities/env.py index 4a0a4d5..52ca34b 100644 --- a/userland/utilities/env.py +++ b/userland/utilities/env.py @@ -1,6 +1,5 @@ import os import shlex -import sys from .. import core @@ -87,7 +86,7 @@ def python_userland_env(opts, args): try: os.chdir(opts.chdir) except OSError as e: - print(e, file=sys.stderr) + core.perror(e) return 125 prog_args.insert(1, opts.argv0 if opts.argv0 else prog_args[0]) @@ -95,7 +94,7 @@ def python_userland_env(opts, args): try: os.execvpe(prog_args[0], prog_args[1:], env) except OSError as e: - print(e, file=sys.stderr) + core.perror(e) return 126 if isinstance(e, FileNotFoundError) else 127 return 0 diff --git a/userland/utilities/factor.py b/userland/utilities/factor.py index f165c76..d0e5bdc 100644 --- a/userland/utilities/factor.py +++ b/userland/utilities/factor.py @@ -1,5 +1,4 @@ import math -import sys from typing import Generator, Iterable from .. import core @@ -120,7 +119,7 @@ def python_userland_factor(opts, args): raise ValueError except ValueError: failed = True - print(f"'{arg}' is not a valid positive integer", file=sys.stderr) + core.perror(f"'{arg}' is not a valid positive integer") continue if num < 2: diff --git a/userland/utilities/groups.py b/userland/utilities/groups.py index a60c431..6f3ebb3 100644 --- a/userland/utilities/groups.py +++ b/userland/utilities/groups.py @@ -1,7 +1,6 @@ import grp import pwd import os -import sys from .. import core @@ -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/id.py b/userland/utilities/id.py index 13db603..2052f27 100644 --- a/userland/utilities/id.py +++ b/userland/utilities/id.py @@ -1,7 +1,6 @@ import grp import pwd import os -import sys from .. import core @@ -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/readlink.py b/userland/utilities/readlink.py index 22fd258..3b57bdf 100644 --- a/userland/utilities/readlink.py +++ b/userland/utilities/readlink.py @@ -1,4 +1,3 @@ -import sys from pathlib import Path from typing import Callable @@ -86,7 +85,7 @@ def python_userland_readlink(opts, args): 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 @@ -103,6 +102,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 3ff916a..4459d95 100644 --- a/userland/utilities/realpath.py +++ b/userland/utilities/realpath.py @@ -1,5 +1,4 @@ import os -import sys from .. import core @@ -116,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 3b831e8..cf7bfeb 100644 --- a/userland/utilities/reset.py +++ b/userland/utilities/reset.py @@ -1,5 +1,4 @@ import os -import sys from .. import core @@ -36,7 +35,7 @@ def python_userland_reset(opts, args): if opts.q: if not term: - print("unknown terminal type ", file=sys.stderr) + core.perror("unknown terminal type ") try: while True: if term := input("Terminal type? "): diff --git a/userland/utilities/sum.py b/userland/utilities/sum.py index 3fe9b72..1423ccd 100644 --- a/userland/utilities/sum.py +++ b/userland/utilities/sum.py @@ -48,11 +48,17 @@ def sum_sysv(data: bytes) -> int: @core.command(parser) def python_userland_sum(opts, args): + 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: + with core.safe_open(name, "rb") as io: + if not io: + failed = True + continue + print(f"{SUM_ALGORITHMS[opts.algorithm](io.read())} {name}") - return 0 + return int(failed) diff --git a/userland/utilities/sync.py b/userland/utilities/sync.py index 7f9feff..a12ef1f 100644 --- a/userland/utilities/sync.py +++ b/userland/utilities/sync.py @@ -1,5 +1,4 @@ import os -import sys from tqdm import tqdm @@ -28,20 +27,18 @@ @core.command(parser) def python_userland_sync(opts, args): - if args: - failed = False - - for name in ( - tqdm(args, ascii=True, desc="Syncing files") if opts.progress else args - ): - try: - with open(name, "rb+") as io: - os.fsync(io) - except OSError as e: + if not args: + os.sync() + return 0 + + failed = False + + for name in tqdm(args, ascii=True, desc="Syncing files") if opts.progress else args: + with core.safe_open(name, "rb+") as io: + if not io: failed = True - print(e, file=sys.stderr) + continue - return int(failed) + os.fsync(io) - os.sync() - return 0 + return int(failed) diff --git a/userland/utilities/truncate.py b/userland/utilities/truncate.py index ab90cfa..692a185 100644 --- a/userland/utilities/truncate.py +++ b/userland/utilities/truncate.py @@ -1,4 +1,3 @@ -import sys from pathlib import Path from typing import Callable @@ -92,9 +91,11 @@ def python_userland_truncate(opts, args): else None ) except OSError as e: - print(e, file=sys.stderr) + core.perror(e) return 1 + failed = False + for file in map( Path, tqdm(args, ascii=True, desc="Truncating files") if opts.progress else args ): @@ -109,13 +110,13 @@ def python_userland_truncate(opts, args): if new_size == old_size: continue - try: - with file.open("rb+") as io: - io.truncate( - new_size * stat.st_blksize if opts.io_blocks else new_size, - ) - except OSError as e: - print(e, file=sys.stderr) - return 1 - - return 0 + with core.safe_open(file, "rb+") as io: + if not io: + failed = True + continue + + io.truncate( + new_size * stat.st_blksize if opts.io_blocks else new_size, + ) + + return int(failed) From f2f7c8451817b00e3ab12eea6acde58722cc3eab Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Mon, 14 Apr 2025 11:01:35 +0000 Subject: [PATCH 17/33] cat: Fix file opening --- userland/utilities/cat.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/userland/utilities/cat.py b/userland/utilities/cat.py index 6622df0..4be8351 100644 --- a/userland/utilities/cat.py +++ b/userland/utilities/cat.py @@ -153,11 +153,11 @@ def python_userland_cat(opts, args): if name == "-": generators.append(core.readlines_stdin_raw()) else: - io = core.safe_open(name, "rb") - if io: - generators.append(io) - else: + try: + generators.append(open(name, "rb")) + except OSError as e: failed = True + core.perror(e) try: cat_io(opts, itertools.chain(*generators)) From 077e948cc577eead028c88146fedc7fc1367304d Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Tue, 15 Apr 2025 09:31:34 +0000 Subject: [PATCH 18/33] Fix typing woes The codebase is now typechecker-friendly. Also fixed some random bugs here and there that could have been detected with static typechecking in place. --- userland/__init__.py | 13 +++++++----- userland/core/command.py | 22 ++++++++------------ userland/core/io.py | 4 ++-- userland/core/paths.py | 4 ++-- userland/utilities/basename.py | 2 +- userland/utilities/cat.py | 38 +++++++++++++++++----------------- userland/utilities/chgrp.py | 2 +- userland/utilities/chown.py | 10 ++++++++- userland/utilities/clear.py | 4 ++-- userland/utilities/dirname.py | 4 ++-- userland/utilities/echo.py | 4 ++-- userland/utilities/env.py | 4 ++-- userland/utilities/factor.py | 8 +++---- userland/utilities/false.py | 2 +- userland/utilities/groups.py | 2 +- userland/utilities/hostid.py | 2 +- userland/utilities/id.py | 4 ++-- userland/utilities/logname.py | 2 +- userland/utilities/nologin.py | 2 +- userland/utilities/nproc.py | 4 ++-- userland/utilities/printenv.py | 2 +- userland/utilities/pwd.py | 4 ++-- userland/utilities/readlink.py | 10 +++++---- userland/utilities/realpath.py | 6 +++--- userland/utilities/reset.py | 4 ++-- userland/utilities/seq.py | 24 ++++++++++++++++----- userland/utilities/sleep.py | 2 +- userland/utilities/sum.py | 6 +++--- userland/utilities/sync.py | 4 ++-- userland/utilities/true.py | 4 +++- userland/utilities/truncate.py | 35 +++++++++++++++++-------------- userland/utilities/tty.py | 4 ++-- userland/utilities/uname.py | 4 ++-- userland/utilities/whoami.py | 2 +- userland/utilities/yes.py | 2 +- 35 files changed, 139 insertions(+), 111 deletions(-) diff --git a/userland/__init__.py b/userland/__init__.py index f5c864f..038b559 100644 --- a/userland/__init__.py +++ b/userland/__init__.py @@ -9,10 +9,13 @@ 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/command.py b/userland/core/command.py index d6295f5..98ab11a 100644 --- a/userland/core/command.py +++ b/userland/core/command.py @@ -1,14 +1,15 @@ import sys -from optparse import OptionParser +from optparse import OptionParser, Values from typing import Any, Callable from .users import OptionParserUsersMixin class ExtendedOptionParser(OptionParserUsersMixin, OptionParser): - def __init__(self, usage: tuple[str], **kwargs): + def __init__(self, usage: str | tuple[str, ...], **kwargs): super().__init__( - usage="Usage: " + f"\n{7 * " "}".join(usage), + usage="Usage: " + + f"\n{7 * " "}".join(usage if isinstance(usage, tuple) else (usage,)), add_help_option=False, **kwargs, ) @@ -40,17 +41,12 @@ def expect_nargs(self, args: list[str], nargs: int | tuple[int] | tuple[int, int def command(parser: OptionParser | None = None): def create_utility( - func: Callable[[dict[str, Any], list[Any]], int], + func: Callable[[Values, list[Any]], 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(): + sys.exit( + func(*parser.parse_args()) if parser else func(Values(), sys.argv[1:]) + ) return execute_utility diff --git a/userland/core/io.py b/userland/core/io.py index 57db826..9e6bdc1 100644 --- a/userland/core/io.py +++ b/userland/core/io.py @@ -1,7 +1,7 @@ import contextlib import os import sys -from typing import Any, Generator +from typing import Any, Generator, IO def perror(*errors: Any) -> None: @@ -12,7 +12,7 @@ def perror(*errors: Any) -> None: @contextlib.contextmanager -def safe_open(*args, **kwargs): +def safe_open(*args, **kwargs) -> Generator[IO | None]: try: with open(*args, **kwargs) as io: yield io diff --git a/userland/core/paths.py b/userland/core/paths.py index 64b0047..f6935c8 100644 --- a/userland/core/paths.py +++ b/userland/core/paths.py @@ -1,10 +1,10 @@ import sys from pathlib import Path -from typing import Generator, Literal +from typing import Generator, Iterable, Literal def traverse_files( - filenames: list[str], + filenames: Iterable[str], recurse_mode: Literal["L", "H", "P"] | None = None, preserve_root: bool = False, ) -> Generator[Path | None]: diff --git a/userland/utilities/basename.py b/userland/utilities/basename.py index 636ac7b..8ed2556 100644 --- a/userland/utilities/basename.py +++ b/userland/utilities/basename.py @@ -26,7 +26,7 @@ @core.command(parser) -def python_userland_basename(opts, args): +def python_userland_basename(opts, args: list[str]): parser.expect_nargs(args, (1,)) if opts.suffix: diff --git a/userland/utilities/cat.py b/userland/utilities/cat.py index 4be8351..aeb7346 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: @@ -95,7 +95,7 @@ def cat_io(opts, io: BinaryIO) -> None: parser = core.ExtendedOptionParser( - usage=("%prog [OPTION]... [FILE]...",), + 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]): if opts.show_all: opts.show_ends = True opts.show_tabs = True @@ -146,26 +146,26 @@ def python_userland_cat(opts, args): opts.show_tabs = True opts.show_nonprinting = True - generators: list[Generator[bytes]] = [] + streams: list[Iterable[bytes]] = [] failed = False for name in args or ["-"]: if name == "-": - generators.append(core.readlines_stdin_raw()) + streams.append(core.readlines_stdin_raw()) else: try: - generators.append(open(name, "rb")) + 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() diff --git a/userland/utilities/chgrp.py b/userland/utilities/chgrp.py index b1006ab..8efcc8d 100644 --- a/userland/utilities/chgrp.py +++ b/userland/utilities/chgrp.py @@ -125,7 +125,7 @@ @core.command(parser) -def python_userland_chgrp(opts, args): +def python_userland_chgrp(opts, args: list[str]): parser.expect_nargs(args, (1,)) from_uid: int | None = None diff --git a/userland/utilities/chown.py b/userland/utilities/chown.py index b142400..f13c643 100644 --- a/userland/utilities/chown.py +++ b/userland/utilities/chown.py @@ -126,7 +126,7 @@ @core.command(parser) -def python_userland_chown(opts, args): +def python_userland_chown(opts, args: list[str]): parser.expect_nargs(args, (1,)) from_uid: int | None = None @@ -137,6 +137,8 @@ def python_userland_chown(opts, args): chown_args = {"follow_symlinks": opts.dereference} + owner_spec: str + if opts.reference: try: ref_stat = Path(opts.reference).stat(follow_symlinks=True) @@ -146,6 +148,12 @@ def python_userland_chown(opts, args): chown_args["user"] = ref_stat.st_uid chown_args["group"] = ref_stat.st_gid + + owner_spec = ( + core.user_display_name_from_id(ref_stat.st_uid) + + ":" + + core.group_display_name_from_id(ref_stat.st_gid) + ) else: parser.expect_nargs(args, (2,)) owner_spec = args.pop(0) diff --git a/userland/utilities/clear.py b/userland/utilities/clear.py index bcb2996..aaeccf2 100644 --- a/userland/utilities/clear.py +++ b/userland/utilities/clear.py @@ -4,7 +4,7 @@ # clear(1), roughly modelled off the ncurses implementation. parser = core.ExtendedOptionParser( - usage=("%prog [OPTION]...",), + usage="%prog [OPTION]...", description="Clear the terminal screen.", ) @@ -14,7 +14,7 @@ @core.command(parser) -def python_userland_clear(opts, args): +def python_userland_clear(opts, args: list[str]): if args: return 1 diff --git a/userland/utilities/dirname.py b/userland/utilities/dirname.py index 18e4715..ed24626 100644 --- a/userland/utilities/dirname.py +++ b/userland/utilities/dirname.py @@ -4,7 +4,7 @@ parser = core.ExtendedOptionParser( - usage=("%prog [OPTION]... NAME...",), + usage="%prog [OPTION]... NAME...", description=( "Print each path NAME with the last component removed," " or '.' if NAME is the only component." @@ -20,7 +20,7 @@ @core.command(parser) -def python_userland_dirname(opts, args): +def python_userland_dirname(opts, args: list[str]): parser.expect_nargs(args, (1,)) for path in map(PurePath, args): diff --git a/userland/utilities/echo.py b/userland/utilities/echo.py index 7968b2f..462bdc5 100644 --- a/userland/utilities/echo.py +++ b/userland/utilities/echo.py @@ -35,7 +35,7 @@ def _process_args(self, largs, rargs, values): parser = PassthroughOptionParser( - usage=("%prog [OPTION]... [STRING]...",), + usage="%prog [OPTION]... [STRING]...", description="Print STRING(s) to standard output.", ) parser.disable_interspersed_args() @@ -57,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]): string = " ".join(args) if opts.escapes: diff --git a/userland/utilities/env.py b/userland/utilities/env.py index 52ca34b..441ba77 100644 --- a/userland/utilities/env.py +++ b/userland/utilities/env.py @@ -5,7 +5,7 @@ parser = core.ExtendedOptionParser( - usage=("%prog [OPTION]... [-] [NAME=VALUE]... [COMMAND [ARG]...]",), + usage="%prog [OPTION]... [-] [NAME=VALUE]... [COMMAND [ARG]...]", description="Run a program in a modified environment or print environment variables.", ) parser.disable_interspersed_args() @@ -50,7 +50,7 @@ @core.command(parser) -def python_userland_env(opts, args): +def python_userland_env(opts, args: list[str]): if args and args[0] == "-": opts.ignore_environment = True del args[0] diff --git a/userland/utilities/factor.py b/userland/utilities/factor.py index d0e5bdc..2158d35 100644 --- a/userland/utilities/factor.py +++ b/userland/utilities/factor.py @@ -1,5 +1,5 @@ import math -from typing import Generator, Iterable +from typing import Generator, Iterable, cast from .. import core @@ -76,7 +76,7 @@ def factorize( if factor == n: break - n //= factor + n //= cast(int, factor) def format_exponents(factors: Iterable[int]) -> str: @@ -100,7 +100,7 @@ def format_exponents(factors: Iterable[int]) -> str: parser = core.ExtendedOptionParser( - usage=("%prog [OPTION] [NUMBER]...",), + usage="%prog [OPTION] [NUMBER]...", description="Compute and print the prime factors of each positive integer NUMBER.", ) @@ -108,7 +108,7 @@ def format_exponents(factors: Iterable[int]) -> str: @core.command(parser) -def python_userland_factor(opts, args): +def python_userland_factor(opts, args: list[str]): failed = False try: diff --git a/userland/utilities/false.py b/userland/utilities/false.py index a6bbcaf..9aa5355 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]): if args and args[0] == "--help": print( f"""\ diff --git a/userland/utilities/groups.py b/userland/utilities/groups.py index 6f3ebb3..f9a9116 100644 --- a/userland/utilities/groups.py +++ b/userland/utilities/groups.py @@ -6,7 +6,7 @@ parser = core.ExtendedOptionParser( - usage=("%prog [USERNAME]...",), + usage="%prog [USERNAME]...", description="Print a list of groups for each USERNAME or the current user.", ) diff --git a/userland/utilities/hostid.py b/userland/utilities/hostid.py index b4edfc5..a681290 100644 --- a/userland/utilities/hostid.py +++ b/userland/utilities/hostid.py @@ -2,7 +2,7 @@ parser = core.ExtendedOptionParser( - usage=("%prog",), + usage="%prog", description="Print a 32-bit numeric host machine identifier.", epilog="This implementation gives an all-zero identifier.", ) diff --git a/userland/utilities/id.py b/userland/utilities/id.py index 2052f27..6dcbd6c 100644 --- a/userland/utilities/id.py +++ b/userland/utilities/id.py @@ -6,7 +6,7 @@ parser = core.ExtendedOptionParser( - usage=("%prog [OPTION]... [USER]...",), + usage="%prog [OPTION]... [USER]...", description="Print user and group information for each USER or the current user.", ) @@ -44,7 +44,7 @@ @core.command(parser) -def python_userland_id(opts, args): +def python_userland_id(opts, args: list[str]): if opts.context: parser.error("--context (-Z) is not supported") diff --git a/userland/utilities/logname.py b/userland/utilities/logname.py index 11c368f..3d31b6e 100644 --- a/userland/utilities/logname.py +++ b/userland/utilities/logname.py @@ -4,7 +4,7 @@ parser = core.ExtendedOptionParser( - usage=("%prog",), + usage="%prog", description="Print the current user's login name.", ) diff --git a/userland/utilities/nologin.py b/userland/utilities/nologin.py index 30d5344..bf42348 100644 --- a/userland/utilities/nologin.py +++ b/userland/utilities/nologin.py @@ -2,7 +2,7 @@ parser = core.ExtendedOptionParser( - usage=("%prog",), + usage="%prog", description="Politely refuse a login.", ) diff --git a/userland/utilities/nproc.py b/userland/utilities/nproc.py index b347043..3943d2d 100644 --- a/userland/utilities/nproc.py +++ b/userland/utilities/nproc.py @@ -4,7 +4,7 @@ parser = core.ExtendedOptionParser( - usage=(" %prog [OPTION]...",), + usage=" %prog [OPTION]...", description="Print the number of processing units available to the process.", ) @@ -24,7 +24,7 @@ @core.command(parser) -def python_userland_nproc(opts, args): +def python_userland_nproc(opts, args: list[str]): 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 77288c3..643406f 100644 --- a/userland/utilities/printenv.py +++ b/userland/utilities/printenv.py @@ -4,7 +4,7 @@ parser = core.ExtendedOptionParser( - usage=(" %prog [OPTION] [VARIABLE]...",), + usage=" %prog [OPTION] [VARIABLE]...", description="Print VARIABLE(s) or all environment variables, and their values.", ) diff --git a/userland/utilities/pwd.py b/userland/utilities/pwd.py index 2e68c2b..38b051f 100644 --- a/userland/utilities/pwd.py +++ b/userland/utilities/pwd.py @@ -4,7 +4,7 @@ parser = core.ExtendedOptionParser( - usage=("%prog [OPTION]",), + 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]): if args: parser.error("too many arguments") diff --git a/userland/utilities/readlink.py b/userland/utilities/readlink.py index 3b57bdf..072b479 100644 --- a/userland/utilities/readlink.py +++ b/userland/utilities/readlink.py @@ -1,10 +1,12 @@ 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,7 +21,7 @@ def readlink_function(can_mode: str | None) -> Callable[[Path], str]: parser = core.ExtendedOptionParser( - usage=("%prog [OPTION]... FILE...",), + usage="%prog [OPTION]... FILE...", description="Print the target of each symbolic link FILE.", ) @@ -81,7 +83,7 @@ def readlink_function(can_mode: str | None) -> Callable[[Path], str]: @core.command(parser) -def python_userland_readlink(opts, args): +def python_userland_readlink(opts, args: list[str]): parser.expect_nargs(args, (1,)) if opts.no_newline and len(args) > 1: diff --git a/userland/utilities/realpath.py b/userland/utilities/realpath.py index 4459d95..92ac6e6 100644 --- a/userland/utilities/realpath.py +++ b/userland/utilities/realpath.py @@ -29,7 +29,7 @@ def resolve_filename(opts, name: str) -> str: parser = core.ExtendedOptionParser( - usage=("%prog [OPTION]... FILE...",), + usage="%prog [OPTION]... FILE...", description="Print the resolved path of each FILE.", ) @@ -98,13 +98,13 @@ def resolve_filename(opts, name: str) -> str: @core.command(parser) -def python_userland_realpath(opts, args): +def python_userland_realpath(opts, args: list[str]): 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 diff --git a/userland/utilities/reset.py b/userland/utilities/reset.py index cf7bfeb..0e1aeac 100644 --- a/userland/utilities/reset.py +++ b/userland/utilities/reset.py @@ -6,7 +6,7 @@ # reset(1), roughly modelled off the ncurses implementation. parser = core.ExtendedOptionParser( - usage=("%prog [OPTION]... [IGNORED]...",), + usage="%prog [OPTION]... [IGNORED]...", description="Initialize or reset the terminal state.", ) @@ -26,7 +26,7 @@ @core.command(parser) -def python_userland_reset(opts, args): +def python_userland_reset(opts, args: list[str]): if args and args[0] == "-": opts.q = True del args[0] diff --git a/userland/utilities/seq.py b/userland/utilities/seq.py index 52de77a..9ef896d 100644 --- a/userland/utilities/seq.py +++ b/userland/utilities/seq.py @@ -1,5 +1,6 @@ import math from decimal import Decimal, InvalidOperation +from typing import cast from .. import core @@ -35,7 +36,7 @@ @core.command(parser) -def python_userland_seq(opts, args): +def python_userland_seq(opts, args: list[str]): parser.expect_nargs(args, (1, 3)) if opts.format and opts.equal_width: @@ -43,10 +44,20 @@ def python_userland_seq(opts, args): def arg_to_decimal(arg: str) -> Decimal: try: - return Decimal(arg) + 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: first = Decimal(1) increment = Decimal(1) @@ -54,16 +65,19 @@ def arg_to_decimal(arg: str) -> Decimal: exponent = 0 elif len(args) == 2: first = arg_to_decimal(args[0]) - exponent = first.as_tuple().exponent + 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 = min(first.as_tuple().exponent, increment.as_tuple().exponent) + exponent = cast( + int, min(first.as_tuple().exponent, increment.as_tuple().exponent) + ) last = arg_to_decimal(args[2]).quantize( first - if first.as_tuple().exponent < increment.as_tuple().exponent + if cast(int, first.as_tuple().exponent) + < cast(int, increment.as_tuple().exponent) else increment ) diff --git a/userland/utilities/sleep.py b/userland/utilities/sleep.py index c87e373..ea52c06 100644 --- a/userland/utilities/sleep.py +++ b/userland/utilities/sleep.py @@ -8,7 +8,7 @@ parser = core.ExtendedOptionParser( - usage=("%prog DURATION[SUFFIX]...",), + usage="%prog DURATION[SUFFIX]...", description=( "Delay for the sum of each DURATION." f" SUFFIX may be one of the following: {", ".join(SUFFIXES.keys())}." diff --git a/userland/utilities/sum.py b/userland/utilities/sum.py index 1423ccd..d2effc1 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 @@ -26,7 +26,7 @@ def sum_sysv(data: bytes) -> int: SUM_ALGORITHMS = {"bsd": sum_bsd, "sysv": sum_sysv} parser = core.ExtendedOptionParser( - usage=("%prog [OPTION] [FILE]...",), + usage="%prog [OPTION] [FILE]...", ) parser.add_option( @@ -47,7 +47,7 @@ def sum_sysv(data: bytes) -> int: @core.command(parser) -def python_userland_sum(opts, args): +def python_userland_sum(opts, args: list[str]): failed = False for name in args or ["-"]: diff --git a/userland/utilities/sync.py b/userland/utilities/sync.py index a12ef1f..7b7b937 100644 --- a/userland/utilities/sync.py +++ b/userland/utilities/sync.py @@ -6,7 +6,7 @@ parser = core.ExtendedOptionParser( - usage=("%prog [OPTION] [FILE]...",), + usage="%prog [OPTION] [FILE]...", description="Sync the filesystem or write each FILE's blocks to disk.", ) @@ -26,7 +26,7 @@ @core.command(parser) -def python_userland_sync(opts, args): +def python_userland_sync(opts, args: list[str]): if not args: os.sync() return 0 diff --git a/userland/utilities/true.py b/userland/utilities/true.py index fa85bfb..199e4e0 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]): 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 692a185..f52c4a8 100644 --- a/userland/utilities/truncate.py +++ b/userland/utilities/truncate.py @@ -1,10 +1,18 @@ from pathlib import Path -from typing import Callable +from typing import Callable, cast from tqdm import tqdm from .. import core +PREFIXES: dict[str, Callable[[int, int], int]] = { + "+": lambda old_size, size_num: old_size + size_num, + "-": lambda old_size, size_num: old_size - size_num, + "<": lambda old_size, size_num: min(old_size, size_num), + ">": lambda old_size, size_num: max(old_size, size_num), + "/": lambda old_size, size_num: size_num * (old_size // size_num), + "%": lambda old_size, size_num: size_num * -(old_size // -size_num), +} parser = core.ExtendedOptionParser( usage=( @@ -41,16 +49,16 @@ @core.command(parser) -def python_userland_truncate(opts, args): +def python_userland_truncate(opts, args: list[str]): 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] + size_prefix = cast(str, opts.size[0]) try: size_num = int(opts.size[1:] if size_prefix else opts.size) @@ -65,22 +73,17 @@ 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 ( + get_new_size: Callable[[int], int] + + if size_prefix: + assert size_num is not None + get_new_size = lambda old_size: PREFIXES[size_prefix](old_size, size_num) + else: + get_new_size = ( (lambda _: size_num) if size_num is not None else (lambda old_size: old_size) ) - ) size_attr = "st_blocks" if opts.io_blocks else "st_size" diff --git a/userland/utilities/tty.py b/userland/utilities/tty.py index ec38867..2eb0d6f 100644 --- a/userland/utilities/tty.py +++ b/userland/utilities/tty.py @@ -5,7 +5,7 @@ parser = core.ExtendedOptionParser( - usage=("%prog [OPTION]",), + usage="%prog [OPTION]", description="Print the path to the terminal connected to standard input.", ) @@ -19,7 +19,7 @@ @core.command(parser) -def python_userland_tty(opts, args): +def python_userland_tty(opts, args: list[str]): parser.expect_nargs(args, 0) try: diff --git a/userland/utilities/uname.py b/userland/utilities/uname.py index 919e3aa..6c14288 100644 --- a/userland/utilities/uname.py +++ b/userland/utilities/uname.py @@ -14,7 +14,7 @@ parser = core.ExtendedOptionParser( - usage=("%prog [OPTION]...",), + usage="%prog [OPTION]...", description="Print system information.", ) @@ -75,7 +75,7 @@ @core.command(parser) -def python_userland_uname(opts, args): +def python_userland_uname(opts, args: list[str]): parser.expect_nargs(args, 0) extras: list[str] = [] diff --git a/userland/utilities/whoami.py b/userland/utilities/whoami.py index 914f165..cfafc23 100644 --- a/userland/utilities/whoami.py +++ b/userland/utilities/whoami.py @@ -4,7 +4,7 @@ parser = core.ExtendedOptionParser( - usage=("%prog",), + usage="%prog", description="Print the current username. Same as `id -un`.", ) diff --git a/userland/utilities/yes.py b/userland/utilities/yes.py index d27e8cd..0f8102c 100644 --- a/userland/utilities/yes.py +++ b/userland/utilities/yes.py @@ -2,7 +2,7 @@ parser = core.ExtendedOptionParser( - ("%prog [STRING]...",), + "%prog [STRING]...", description="Repeatedly output a line with STRING(s) (or 'y' by default).", ) From cf3f0f9239b0904bd8bfaeecaccb8e61cf1efe36 Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Tue, 15 Apr 2025 10:02:36 +0000 Subject: [PATCH 19/33] seq: Fix handling of 0's and invalid numbers Also added some corresponding testcases. --- tests/seq.sh | 3 +++ userland/utilities/seq.py | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/seq.sh b/tests/seq.sh index 0a6787b..0b906e5 100755 --- a/tests/seq.sh +++ b/tests/seq.sh @@ -3,6 +3,9 @@ set -eux set -- \ + '0' \ + '0 0' \ + '0 0 0' \ '10' \ '5 10' \ '0 2 10' \ diff --git a/userland/utilities/seq.py b/userland/utilities/seq.py index 9ef896d..8548b8f 100644 --- a/userland/utilities/seq.py +++ b/userland/utilities/seq.py @@ -59,9 +59,12 @@ def arg_to_decimal(arg: str) -> Decimal: exponent: int if len(args) == 1: + last = arg_to_decimal(args[0]) + if not last: + return 0 + first = Decimal(1) increment = Decimal(1) - last = arg_to_decimal(args[0]) exponent = 0 elif len(args) == 2: first = arg_to_decimal(args[0]) @@ -81,6 +84,9 @@ def arg_to_decimal(arg: str) -> Decimal: else increment ) + if not increment: + parser.error(f"invalid zero increment value: '{increment}'") + formatstr: str if opts.equal_width: From cebefc24a2b1c91a37a60331409cf1ae71d9f142 Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Tue, 15 Apr 2025 10:08:07 +0000 Subject: [PATCH 20/33] tests: Fix old test scripts --- tests/echo.sh | 18 +++++++++--------- tests/factor.sh | 10 ++-------- tests/truncate.sh | 18 +++++++++--------- 3 files changed, 20 insertions(+), 26 deletions(-) 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/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 From 4e9294bffbe8370a8bdc86f7b5557aa5c83612b7 Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Tue, 15 Apr 2025 11:13:47 +0000 Subject: [PATCH 21/33] truncate: Refactor to reduce redundancy and branches --- userland/utilities/truncate.py | 41 +++++++++++++++++----------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/userland/utilities/truncate.py b/userland/utilities/truncate.py index f52c4a8..309e94a 100644 --- a/userland/utilities/truncate.py +++ b/userland/utilities/truncate.py @@ -1,15 +1,16 @@ +import operator from pathlib import Path -from typing import Callable, cast +from typing import Callable from tqdm import tqdm from .. import core PREFIXES: dict[str, Callable[[int, int], int]] = { - "+": lambda old_size, size_num: old_size + size_num, - "-": lambda old_size, size_num: old_size - size_num, - "<": lambda old_size, size_num: min(old_size, size_num), - ">": lambda old_size, size_num: max(old_size, size_num), + "+": 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), } @@ -48,6 +49,19 @@ 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: list[str]): if opts.reference: @@ -57,11 +71,8 @@ def python_userland_truncate(opts, args: list[str]): size_num: int | None = None if opts.size: - if opts.size[0] in frozenset("+-<>/%"): - size_prefix = cast(str, 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}'") @@ -73,17 +84,7 @@ def python_userland_truncate(opts, args: list[str]): if not args: parser.error("missing file operand") - get_new_size: Callable[[int], int] - - if size_prefix: - assert size_num is not None - get_new_size = lambda old_size: PREFIXES[size_prefix](old_size, size_num) - else: - get_new_size = ( - (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" From 03245dd97bc7a8353800f86dc7e2edc70a985ede Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Tue, 15 Apr 2025 11:15:38 +0000 Subject: [PATCH 22/33] pylint: Fix pylint workflow --- .github/workflows/pylint.yml | 4 ++-- userland/utilities/__init__.py | 0 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 userland/utilities/__init__.py diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 3ca7b11..2709feb 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -17,7 +17,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pylint + pip install pylint tqdm - name: Analysing the code with pylint run: | - pylint $(git ls-files '*.py') + pylint ./userland diff --git a/userland/utilities/__init__.py b/userland/utilities/__init__.py new file mode 100644 index 0000000..e69de29 From 70af03e7a52c3b862dce06bf440b03f8a4e7301b Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Wed, 16 Apr 2025 00:43:07 +0000 Subject: [PATCH 23/33] Fix all pylint warnings --- pylintrc | 2 - userland/core/io.py | 1 + userland/core/users.py | 2 + userland/utilities/cat.py | 1 + userland/utilities/chgrp.py | 56 +++++++++++------------ userland/utilities/chown.py | 90 +++++++++++++++++++------------------ userland/utilities/env.py | 25 ++++++----- userland/utilities/id.py | 6 +-- 8 files changed, 96 insertions(+), 87 deletions(-) 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/userland/core/io.py b/userland/core/io.py index 9e6bdc1..c19633e 100644 --- a/userland/core/io.py +++ b/userland/core/io.py @@ -14,6 +14,7 @@ def perror(*errors: Any) -> None: @contextlib.contextmanager def safe_open(*args, **kwargs) -> Generator[IO | None]: try: + # pylint: disable=unspecified-encoding with open(*args, **kwargs) as io: yield io except OSError as e: diff --git a/userland/core/users.py b/userland/core/users.py index 2ab5f44..550ff36 100644 --- a/userland/core/users.py +++ b/userland/core/users.py @@ -25,6 +25,7 @@ def parse_owner_spec(self, owner_spec: str) -> tuple[int | None, int | None]: 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. @@ -38,6 +39,7 @@ def parse_user(self, user: str) -> int: 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. diff --git a/userland/utilities/cat.py b/userland/utilities/cat.py index aeb7346..dfd960e 100644 --- a/userland/utilities/cat.py +++ b/userland/utilities/cat.py @@ -154,6 +154,7 @@ def python_userland_cat(opts, args: list[str]): streams.append(core.readlines_stdin_raw()) else: try: + # pylint: disable=consider-using-with streams.append(open(name, "rb")) except OSError as e: failed = True diff --git a/userland/utilities/chgrp.py b/userland/utilities/chgrp.py index 8efcc8d..46153d0 100644 --- a/userland/utilities/chgrp.py +++ b/userland/utilities/chgrp.py @@ -124,6 +124,18 @@ ) +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]): parser.expect_nargs(args, (1,)) @@ -134,22 +146,22 @@ def python_userland_chgrp(opts, args: list[str]): if opts.from_spec: from_uid, from_gid = parser.parse_owner_spec(opts.from_spec) - gid: int - gname: str | None = None + try: + gid, gname = get_new_group(opts, args) + except OSError as e: + core.perror(e) + return 1 - if opts.reference: - try: - gid = Path(opts.reference).stat(follow_symlinks=True).st_gid - except OSError as e: - core.perror(e) - return 1 - else: - parser.expect_nargs(args, (2,)) - gname = args.pop(0) + failed = False - gid = parser.parse_group(gname) + def handle_error(err: Exception, level: int, msg: str) -> None: + nonlocal failed + failed = True - failed = False + if opts.verbosity: + core.perror(err) + if opts.verbosity > level: + print(msg, file=sys.stderr) for file in core.traverse_files( ( @@ -169,14 +181,7 @@ def python_userland_chgrp(opts, args: list[str]): prev_uid = stat.st_uid prev_gid = stat.st_gid except OSError as e: - failed = True - if opts.verbosity: - core.perror(e) - if opts.verbosity > 2: - print( - f"failed to change group of '{file}' to {gname or gid}", - file=sys.stderr, - ) + 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) @@ -195,14 +200,7 @@ def python_userland_chgrp(opts, args: list[str]): try: shutil.chown(file, group=gid, follow_symlinks=opts.dereference) except OSError as e: - failed = True - if opts.verbosity: - core.perror(e) - if opts.verbosity > 2: - print( - f"failed to change group of '{file}' to {gname or gid}", - file=sys.stderr, - ) + handle_error(e, 2, f"failed to change group of '{file}' to {gname or gid}") continue if prev_gid == gid: diff --git a/userland/utilities/chown.py b/userland/utilities/chown.py index f13c643..90d07d2 100644 --- a/userland/utilities/chown.py +++ b/userland/utilities/chown.py @@ -125,51 +125,65 @@ ) -@core.command(parser) -def python_userland_chown(opts, args: list[str]): - 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} - - owner_spec: str - +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 1 + return None chown_args["user"] = ref_stat.st_uid chown_args["group"] = ref_stat.st_gid - owner_spec = ( + return ( core.user_display_name_from_id(ref_stat.st_uid) + ":" + core.group_display_name_from_id(ref_stat.st_gid) ) - else: - 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}") + parser.expect_nargs(args, (2,)) + owner_spec = args.pop(0) - 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 - ) + 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]): + 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, @@ -184,14 +198,9 @@ def python_userland_chown(opts, args: list[str]): prev_uid = stat.st_uid prev_gid = stat.st_gid except OSError as e: - failed = True - if opts.verbosity: - core.perror(e) - if opts.verbosity > 2: - print( - f"failed to change ownership of '{file}' to {owner_spec}", - file=sys.stderr, - ) + 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) @@ -207,14 +216,9 @@ def python_userland_chown(opts, args: list[str]): try: shutil.chown(file, **chown_args) except OSError as e: - failed = True - if opts.verbosity: - core.perror(e) - if opts.verbosity > 2: - print( - f"failed to change ownership of '{file}' to {owner_spec}", - file=sys.stderr, - ) + 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"]: diff --git a/userland/utilities/env.py b/userland/utilities/env.py index 441ba77..2fb089e 100644 --- a/userland/utilities/env.py +++ b/userland/utilities/env.py @@ -49,12 +49,26 @@ ) +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]): if args and args[0] == "-": opts.ignore_environment = True del args[0] + env: dict[str, str] + if opts.ignore_environment: env = {} elif opts.unset: @@ -65,14 +79,7 @@ def python_userland_env(opts, args: list[str]): env = os.environ.copy() prog_args = [] - - 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 + parse_env_args(args, env, prog_args) if opts.split_string: prog_args = shlex.split(opts.split_string) + prog_args @@ -96,5 +103,3 @@ def python_userland_env(opts, args: list[str]): except OSError as e: core.perror(e) return 126 if isinstance(e, FileNotFoundError) else 127 - - return 0 diff --git a/userland/utilities/id.py b/userland/utilities/id.py index 6dcbd6c..1b84896 100644 --- a/userland/utilities/id.py +++ b/userland/utilities/id.py @@ -48,13 +48,13 @@ def python_userland_id(opts, args: list[str]): 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() From 6537f38a82a7628e388789549aa4a9a42d064a43 Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Wed, 16 Apr 2025 03:24:10 +0000 Subject: [PATCH 24/33] Annotate all command function return values --- userland/utilities/basename.py | 2 +- userland/utilities/cat.py | 2 +- userland/utilities/chgrp.py | 2 +- userland/utilities/chown.py | 2 +- userland/utilities/clear.py | 2 +- userland/utilities/dirname.py | 2 +- userland/utilities/echo.py | 2 +- userland/utilities/env.py | 2 +- userland/utilities/factor.py | 2 +- userland/utilities/false.py | 2 +- userland/utilities/groups.py | 2 +- userland/utilities/hostid.py | 2 +- userland/utilities/id.py | 2 +- userland/utilities/logname.py | 2 +- userland/utilities/nologin.py | 2 +- userland/utilities/nproc.py | 2 +- userland/utilities/printenv.py | 2 +- userland/utilities/pwd.py | 2 +- userland/utilities/readlink.py | 2 +- userland/utilities/realpath.py | 2 +- userland/utilities/reset.py | 2 +- userland/utilities/seq.py | 2 +- userland/utilities/sleep.py | 2 +- userland/utilities/sum.py | 2 +- userland/utilities/sync.py | 2 +- userland/utilities/true.py | 2 +- userland/utilities/truncate.py | 2 +- userland/utilities/tty.py | 2 +- userland/utilities/uname.py | 2 +- userland/utilities/whoami.py | 2 +- userland/utilities/yes.py | 2 +- 31 files changed, 31 insertions(+), 31 deletions(-) diff --git a/userland/utilities/basename.py b/userland/utilities/basename.py index 8ed2556..00731d8 100644 --- a/userland/utilities/basename.py +++ b/userland/utilities/basename.py @@ -26,7 +26,7 @@ @core.command(parser) -def python_userland_basename(opts, args: list[str]): +def python_userland_basename(opts, args: list[str]) -> int: parser.expect_nargs(args, (1,)) if opts.suffix: diff --git a/userland/utilities/cat.py b/userland/utilities/cat.py index dfd960e..ef566d0 100644 --- a/userland/utilities/cat.py +++ b/userland/utilities/cat.py @@ -134,7 +134,7 @@ def cat_io(opts, stream: Iterable[bytes]) -> None: @core.command(parser) -def python_userland_cat(opts, args: list[str]): +def python_userland_cat(opts, args: list[str]) -> int: if opts.show_all: opts.show_ends = True opts.show_tabs = True diff --git a/userland/utilities/chgrp.py b/userland/utilities/chgrp.py index 46153d0..d2ce7ed 100644 --- a/userland/utilities/chgrp.py +++ b/userland/utilities/chgrp.py @@ -137,7 +137,7 @@ def get_new_group(opts, args: list[str]) -> tuple[int, str]: @core.command(parser) -def python_userland_chgrp(opts, args: list[str]): +def python_userland_chgrp(opts, args: list[str]) -> int: parser.expect_nargs(args, (1,)) from_uid: int | None = None diff --git a/userland/utilities/chown.py b/userland/utilities/chown.py index 90d07d2..a193c08 100644 --- a/userland/utilities/chown.py +++ b/userland/utilities/chown.py @@ -159,7 +159,7 @@ def get_new_owner(opts, args: list[str], chown_args: dict) -> str | None: @core.command(parser) -def python_userland_chown(opts, args: list[str]): +def python_userland_chown(opts, args: list[str]) -> int: parser.expect_nargs(args, (1,)) from_uid: int | None = None diff --git a/userland/utilities/clear.py b/userland/utilities/clear.py index aaeccf2..162cae4 100644 --- a/userland/utilities/clear.py +++ b/userland/utilities/clear.py @@ -14,7 +14,7 @@ @core.command(parser) -def python_userland_clear(opts, args: list[str]): +def python_userland_clear(opts, args: list[str]) -> int: if args: return 1 diff --git a/userland/utilities/dirname.py b/userland/utilities/dirname.py index ed24626..73876d8 100644 --- a/userland/utilities/dirname.py +++ b/userland/utilities/dirname.py @@ -20,7 +20,7 @@ @core.command(parser) -def python_userland_dirname(opts, args: list[str]): +def python_userland_dirname(opts, args: list[str]) -> int: parser.expect_nargs(args, (1,)) for path in map(PurePath, args): diff --git a/userland/utilities/echo.py b/userland/utilities/echo.py index 462bdc5..18031fb 100644 --- a/userland/utilities/echo.py +++ b/userland/utilities/echo.py @@ -57,7 +57,7 @@ def _process_args(self, largs, rargs, values): @core.command(parser) -def python_userland_echo(opts, args: list[str]): +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 index 2fb089e..c21ff5b 100644 --- a/userland/utilities/env.py +++ b/userland/utilities/env.py @@ -62,7 +62,7 @@ def parse_env_args(args: list[str], env: dict[str, str], prog_args: list[str]) - @core.command(parser) # pylint: disable=inconsistent-return-statements -def python_userland_env(opts, args: list[str]): +def python_userland_env(opts, args: list[str]) -> int: if args and args[0] == "-": opts.ignore_environment = True del args[0] diff --git a/userland/utilities/factor.py b/userland/utilities/factor.py index 2158d35..3f1bd8c 100644 --- a/userland/utilities/factor.py +++ b/userland/utilities/factor.py @@ -108,7 +108,7 @@ def format_exponents(factors: Iterable[int]) -> str: @core.command(parser) -def python_userland_factor(opts, args: list[str]): +def python_userland_factor(opts, args: list[str]) -> int: failed = False try: diff --git a/userland/utilities/false.py b/userland/utilities/false.py index 9aa5355..dadad57 100644 --- a/userland/utilities/false.py +++ b/userland/utilities/false.py @@ -5,7 +5,7 @@ @core.command() -def python_userland_false(_, args: list[str]): +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 f9a9116..8f6509c 100644 --- a/userland/utilities/groups.py +++ b/userland/utilities/groups.py @@ -12,7 +12,7 @@ @core.command(parser) -def python_userland_groups(_, args): +def python_userland_groups(_, args) -> int: failed = False for user in args or [os.getlogin()]: diff --git a/userland/utilities/hostid.py b/userland/utilities/hostid.py index a681290..4af52f6 100644 --- a/userland/utilities/hostid.py +++ b/userland/utilities/hostid.py @@ -9,7 +9,7 @@ @core.command(parser) -def python_userland_hostid(_, args): +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) diff --git a/userland/utilities/id.py b/userland/utilities/id.py index 1b84896..db4af4c 100644 --- a/userland/utilities/id.py +++ b/userland/utilities/id.py @@ -44,7 +44,7 @@ @core.command(parser) -def python_userland_id(opts, args: list[str]): +def python_userland_id(opts, args: list[str]) -> int: if opts.context: parser.error("--context (-Z) is not supported") diff --git a/userland/utilities/logname.py b/userland/utilities/logname.py index 3d31b6e..927f19e 100644 --- a/userland/utilities/logname.py +++ b/userland/utilities/logname.py @@ -10,7 +10,7 @@ @core.command(parser) -def python_userland_logname(_, args): +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 bf42348..7f940cf 100644 --- a/userland/utilities/nologin.py +++ b/userland/utilities/nologin.py @@ -8,6 +8,6 @@ @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 3943d2d..a6f1f75 100644 --- a/userland/utilities/nproc.py +++ b/userland/utilities/nproc.py @@ -24,7 +24,7 @@ @core.command(parser) -def python_userland_nproc(opts, args: list[str]): +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 643406f..c7df6a6 100644 --- a/userland/utilities/printenv.py +++ b/userland/utilities/printenv.py @@ -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 38b051f..8a42ed5 100644 --- a/userland/utilities/pwd.py +++ b/userland/utilities/pwd.py @@ -26,7 +26,7 @@ @core.command(parser) -def python_userland_pwd(opts, args: list[str]): +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 072b479..0136a25 100644 --- a/userland/utilities/readlink.py +++ b/userland/utilities/readlink.py @@ -83,7 +83,7 @@ def readlink_function( @core.command(parser) -def python_userland_readlink(opts, args: list[str]): +def python_userland_readlink(opts, args: list[str]) -> int: parser.expect_nargs(args, (1,)) if opts.no_newline and len(args) > 1: diff --git a/userland/utilities/realpath.py b/userland/utilities/realpath.py index 92ac6e6..3e58c8c 100644 --- a/userland/utilities/realpath.py +++ b/userland/utilities/realpath.py @@ -98,7 +98,7 @@ def resolve_filename(opts, name: str) -> str: @core.command(parser) -def python_userland_realpath(opts, args: list[str]): +def python_userland_realpath(opts, args: list[str]) -> int: parser.expect_nargs(args, (1,)) endchar = "\0" if opts.zero else "\n" diff --git a/userland/utilities/reset.py b/userland/utilities/reset.py index 0e1aeac..4f89d10 100644 --- a/userland/utilities/reset.py +++ b/userland/utilities/reset.py @@ -26,7 +26,7 @@ @core.command(parser) -def python_userland_reset(opts, args: list[str]): +def python_userland_reset(opts, args: list[str]) -> int: if args and args[0] == "-": opts.q = True del args[0] diff --git a/userland/utilities/seq.py b/userland/utilities/seq.py index 8548b8f..11f0edd 100644 --- a/userland/utilities/seq.py +++ b/userland/utilities/seq.py @@ -36,7 +36,7 @@ @core.command(parser) -def python_userland_seq(opts, args: list[str]): +def python_userland_seq(opts, args: list[str]) -> int: parser.expect_nargs(args, (1, 3)) if opts.format and opts.equal_width: diff --git a/userland/utilities/sleep.py b/userland/utilities/sleep.py index ea52c06..bc0b07e 100644 --- a/userland/utilities/sleep.py +++ b/userland/utilities/sleep.py @@ -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: diff --git a/userland/utilities/sum.py b/userland/utilities/sum.py index d2effc1..e3692c0 100644 --- a/userland/utilities/sum.py +++ b/userland/utilities/sum.py @@ -47,7 +47,7 @@ def sum_sysv(data: bytes) -> str: @core.command(parser) -def python_userland_sum(opts, args: list[str]): +def python_userland_sum(opts, args: list[str]) -> int: failed = False for name in args or ["-"]: diff --git a/userland/utilities/sync.py b/userland/utilities/sync.py index 7b7b937..bc414f6 100644 --- a/userland/utilities/sync.py +++ b/userland/utilities/sync.py @@ -26,7 +26,7 @@ @core.command(parser) -def python_userland_sync(opts, args: list[str]): +def python_userland_sync(opts, args: list[str]) -> int: if not args: os.sync() return 0 diff --git a/userland/utilities/true.py b/userland/utilities/true.py index 199e4e0..118a193 100644 --- a/userland/utilities/true.py +++ b/userland/utilities/true.py @@ -5,7 +5,7 @@ @core.command() -def python_userland_true(_, args: list[str]): +def python_userland_true(_, args: list[str]) -> int: if args and args[0] == "--help": print( f"""\ diff --git a/userland/utilities/truncate.py b/userland/utilities/truncate.py index 309e94a..a0d96a2 100644 --- a/userland/utilities/truncate.py +++ b/userland/utilities/truncate.py @@ -63,7 +63,7 @@ def get_size_changer(prefix: str | None, num: int | None) -> Callable[[int], int @core.command(parser) -def python_userland_truncate(opts, args: list[str]): +def python_userland_truncate(opts, args: list[str]) -> int: if opts.reference: opts.reference = Path(opts.reference) diff --git a/userland/utilities/tty.py b/userland/utilities/tty.py index 2eb0d6f..fe51a6a 100644 --- a/userland/utilities/tty.py +++ b/userland/utilities/tty.py @@ -19,7 +19,7 @@ @core.command(parser) -def python_userland_tty(opts, args: list[str]): +def python_userland_tty(opts, args: list[str]) -> int: parser.expect_nargs(args, 0) try: diff --git a/userland/utilities/uname.py b/userland/utilities/uname.py index 6c14288..f023f0d 100644 --- a/userland/utilities/uname.py +++ b/userland/utilities/uname.py @@ -75,7 +75,7 @@ @core.command(parser) -def python_userland_uname(opts, args: list[str]): +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 cfafc23..8feadad 100644 --- a/userland/utilities/whoami.py +++ b/userland/utilities/whoami.py @@ -10,7 +10,7 @@ @core.command(parser) -def python_userland_whoami(_, args): +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 0f8102c..38b607e 100644 --- a/userland/utilities/yes.py +++ b/userland/utilities/yes.py @@ -8,7 +8,7 @@ @core.command(parser) -def python_userland_yes(_, args): +def python_userland_yes(_, args) -> int: try: string = " ".join(args or ["y"]) while True: From c9393315f15a711a1488ec9868eb4aeacfcb6f9c Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Wed, 16 Apr 2025 05:58:16 +0000 Subject: [PATCH 25/33] factor: Avoid using `not` for checking zero --- userland/utilities/factor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/userland/utilities/factor.py b/userland/utilities/factor.py index 3f1bd8c..b20372c 100644 --- a/userland/utilities/factor.py +++ b/userland/utilities/factor.py @@ -15,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 @@ -59,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 From 7a8907cb4ffc77a68362e32560d6f3d4c9ecb627 Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Wed, 16 Apr 2025 16:31:48 +0000 Subject: [PATCH 26/33] Centralize `KeyboardInterrupt` handling Utilities will now automatically exit with status 130 (without displaying a stack trace) upon `KeyboardInterrupt` exception, unless a `try...except` for `KeyboardInterrupt` is registered by a specific utility (e.g. for performing any necessary cleanup functions). --- userland/core/command.py | 6 ++++- userland/utilities/factor.py | 44 ++++++++++++++++-------------------- userland/utilities/reset.py | 10 +++----- userland/utilities/sleep.py | 6 +---- userland/utilities/yes.py | 1 + 5 files changed, 30 insertions(+), 37 deletions(-) diff --git a/userland/core/command.py b/userland/core/command.py index 98ab11a..4372356 100644 --- a/userland/core/command.py +++ b/userland/core/command.py @@ -44,9 +44,13 @@ def create_utility( func: Callable[[Values, list[Any]], int], ) -> Callable[[], None]: def execute_utility(): - sys.exit( + 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/utilities/factor.py b/userland/utilities/factor.py index b20372c..c6e1655 100644 --- a/userland/utilities/factor.py +++ b/userland/utilities/factor.py @@ -111,29 +111,25 @@ def format_exponents(factors: Iterable[int]) -> str: 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 - 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))}" - ) - 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/reset.py b/userland/utilities/reset.py index 4f89d10..8a4f7f4 100644 --- a/userland/utilities/reset.py +++ b/userland/utilities/reset.py @@ -36,13 +36,9 @@ def python_userland_reset(opts, args: list[str]) -> int: if opts.q: if not term: core.perror("unknown terminal type ") - try: - while True: - if term := input("Terminal type? "): - break - except KeyboardInterrupt: - print() - return 130 + while True: + if term := input("Terminal type? "): + break print(term) return 0 diff --git a/userland/utilities/sleep.py b/userland/utilities/sleep.py index bc0b07e..c0b006c 100644 --- a/userland/utilities/sleep.py +++ b/userland/utilities/sleep.py @@ -28,10 +28,6 @@ def python_userland_sleep(_, args) -> int: 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/yes.py b/userland/utilities/yes.py index 38b607e..fd31867 100644 --- a/userland/utilities/yes.py +++ b/userland/utilities/yes.py @@ -14,4 +14,5 @@ def python_userland_yes(_, args) -> int: while True: print(string) except KeyboardInterrupt: + # Do not emit a trailing newline on keyboard interrupt. return 130 From 034516dae7559bc7b56714f8fae962e2d2733f2a Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Fri, 18 Apr 2025 08:12:33 +0000 Subject: [PATCH 27/33] Fix __init__.py showing in applet list --- userland/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/userland/__init__.py b/userland/__init__.py index 038b559..5e44216 100644 --- a/userland/__init__.py +++ b/userland/__init__.py @@ -5,7 +5,7 @@ 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: From a1115f5d699866dd0c45e27c7f4f4e4c53c698a6 Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Fri, 18 Apr 2025 08:17:14 +0000 Subject: [PATCH 28/33] Fix incorrect type annotation in `core.command()` definition --- userland/core/command.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/userland/core/command.py b/userland/core/command.py index 4372356..3743fd6 100644 --- a/userland/core/command.py +++ b/userland/core/command.py @@ -1,6 +1,6 @@ import sys from optparse import OptionParser, Values -from typing import Any, Callable +from typing import Callable from .users import OptionParserUsersMixin @@ -41,7 +41,7 @@ def expect_nargs(self, args: list[str], nargs: int | tuple[int] | tuple[int, int def command(parser: OptionParser | None = None): def create_utility( - func: Callable[[Values, list[Any]], int], + func: Callable[[Values, list[str]], int], ) -> Callable[[], None]: def execute_utility(): try: From e70f2a421594d20f83f4434d8aa6e2045958ea6d Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Fri, 18 Apr 2025 08:18:27 +0000 Subject: [PATCH 29/33] Remove `core.safeopen()` Questionable utility. Complicates typechecking. ("Is the file opened in text or binary mode?") --- userland/core/io.py | 14 +------------- userland/utilities/sum.py | 12 ++++++------ userland/utilities/sync.py | 12 ++++++------ userland/utilities/truncate.py | 16 ++++++++-------- 4 files changed, 21 insertions(+), 33 deletions(-) diff --git a/userland/core/io.py b/userland/core/io.py index c19633e..8c0b465 100644 --- a/userland/core/io.py +++ b/userland/core/io.py @@ -1,7 +1,6 @@ -import contextlib import os import sys -from typing import Any, Generator, IO +from typing import Any, Generator def perror(*errors: Any) -> None: @@ -11,17 +10,6 @@ def perror(*errors: Any) -> None: ) -@contextlib.contextmanager -def safe_open(*args, **kwargs) -> Generator[IO | None]: - try: - # pylint: disable=unspecified-encoding - with open(*args, **kwargs) as io: - yield io - except OSError as e: - perror(e) - yield None - - def readlines_stdin() -> Generator[str]: while line := sys.stdin.readline(): yield line diff --git a/userland/utilities/sum.py b/userland/utilities/sum.py index e3692c0..54ec2ef 100644 --- a/userland/utilities/sum.py +++ b/userland/utilities/sum.py @@ -54,11 +54,11 @@ def python_userland_sum(opts, args: list[str]) -> int: if name == "-": print(SUM_ALGORITHMS[opts.algorithm](sys.stdin.buffer.read())) else: - with core.safe_open(name, "rb") as io: - if not io: - failed = True - continue - - 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 int(failed) diff --git a/userland/utilities/sync.py b/userland/utilities/sync.py index bc414f6..cfadf8f 100644 --- a/userland/utilities/sync.py +++ b/userland/utilities/sync.py @@ -34,11 +34,11 @@ def python_userland_sync(opts, args: list[str]) -> int: failed = False for name in tqdm(args, ascii=True, desc="Syncing files") if opts.progress else args: - with core.safe_open(name, "rb+") as io: - if not io: - failed = True - continue - - os.fsync(io) + 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/truncate.py b/userland/utilities/truncate.py index a0d96a2..d29d193 100644 --- a/userland/utilities/truncate.py +++ b/userland/utilities/truncate.py @@ -114,13 +114,13 @@ def python_userland_truncate(opts, args: list[str]) -> int: if new_size == old_size: continue - with core.safe_open(file, "rb+") as io: - if not io: - failed = True - continue - - io.truncate( - new_size * stat.st_blksize if opts.io_blocks else new_size, - ) + try: + with open(file, "rb+") as f: + f.truncate( + new_size * stat.st_blksize if opts.io_blocks else new_size, + ) + except OSError as e: + failed = True + core.perror(e) return int(failed) From 393752c0be73bda20620bac1a766ec9e92573c0e Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Fri, 18 Apr 2025 09:02:59 +0000 Subject: [PATCH 30/33] Remove `core.readlines_stdin()` and `core.readlines_stdin_raw()` Turns out, they're useless when you can simply iterate over `sys.stdin` and `sys.stdin.buffer`. --- userland/core/io.py | 14 ++------------ userland/utilities/cat.py | 2 +- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/userland/core/io.py b/userland/core/io.py index 8c0b465..d21a17d 100644 --- a/userland/core/io.py +++ b/userland/core/io.py @@ -10,21 +10,11 @@ def perror(*errors: Any) -> None: ) -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 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() diff --git a/userland/utilities/cat.py b/userland/utilities/cat.py index ef566d0..00dfa48 100644 --- a/userland/utilities/cat.py +++ b/userland/utilities/cat.py @@ -151,7 +151,7 @@ def python_userland_cat(opts, args: list[str]) -> int: for name in args or ["-"]: if name == "-": - streams.append(core.readlines_stdin_raw()) + streams.append(sys.stdin.buffer) else: try: # pylint: disable=consider-using-with From 9420773c3b40a5ca7be724d1dfba83143bbda2d4 Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Fri, 18 Apr 2025 10:35:04 +0000 Subject: [PATCH 31/33] cut: Add cut.py and test script --- tests/cut.sh | 56 ++++++++++ userland/core/io.py | 20 +++- userland/utilities/cut.py | 221 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 296 insertions(+), 1 deletion(-) create mode 100755 tests/cut.sh create mode 100644 userland/utilities/cut.py 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/userland/core/io.py b/userland/core/io.py index d21a17d..e891a74 100644 --- a/userland/core/io.py +++ b/userland/core/io.py @@ -1,6 +1,6 @@ import os import sys -from typing import Any, Generator +from typing import Any, Generator, IO def perror(*errors: Any) -> None: @@ -18,3 +18,21 @@ def readwords_stdin() -> Generator[str]: def readwords_stdin_raw() -> Generator[bytes]: 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/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) From 20b878ba9bba8c287e321c33aaecdeda34a21578 Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Fri, 18 Apr 2025 10:38:41 +0000 Subject: [PATCH 32/33] seq: Fix `seq -w 0 0` --- userland/utilities/seq.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/userland/utilities/seq.py b/userland/utilities/seq.py index 11f0edd..40e4483 100644 --- a/userland/utilities/seq.py +++ b/userland/utilities/seq.py @@ -60,7 +60,7 @@ def arg_to_decimal(arg: str) -> Decimal: if len(args) == 1: last = arg_to_decimal(args[0]) - if not last: + if last == 0: return 0 first = Decimal(1) @@ -90,7 +90,7 @@ def arg_to_decimal(arg: str) -> Decimal: formatstr: str if opts.equal_width: - padding = math.floor(math.log10(last)) + 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 From 040bfe509fc81c67342e40c38842ebd24684a585 Mon Sep 17 00:00:00 2001 From: Expertcoderz Date: Fri, 18 Apr 2025 11:06:00 +0000 Subject: [PATCH 33/33] pyproject.toml: Bump version to 0.1.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2078fef..398c3cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ 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"