From d7c1777b1b76b48a4b582c37c90575435ef04af5 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 18 May 2026 16:49:15 +0200 Subject: [PATCH 1/2] chore: require Python 3.11+, drop tomli compat shim The host CLI now explicitly picks python3.11+ from PATH (preferring the version-named binaries Homebrew installs) and exits with a brew hint if nothing suitable is found. Apple's CLT python3 (3.9) is silently skipped instead of crashing later on a missing tomllib or a 3.10+ f-string. All call sites switch to a bare 'import tomllib' and the vendored tomli + _compat shim are removed. --- README.md | 6 +- lib/stack/_compat.py | 10 - lib/stack/_vendor/__init__.py | 0 lib/stack/_vendor/tomli/LICENSE | 21 - lib/stack/_vendor/tomli/__init__.py | 12 - lib/stack/_vendor/tomli/_parser.py | 793 ---------------------------- lib/stack/_vendor/tomli/_re.py | 119 ----- lib/stack/_vendor/tomli/_types.py | 10 - lib/stack/cli.py | 2 +- lib/stack/installer.py | 4 +- lib/stack/secrets.py | 2 +- lib/stack/stack.py | 2 +- lib/stack/users.py | 2 +- pyproject.toml | 6 +- stack | 44 +- stacklets/core/bot-runner/main.py | 7 +- stacklets/docs/bot/cli/_mirror.py | 5 +- stacklets/docs/bot/cli/tags.py | 5 +- stacklets/docs/seed.py | 5 +- stacklets/messages/cli/_matrix.py | 5 +- 20 files changed, 59 insertions(+), 1001 deletions(-) delete mode 100644 lib/stack/_compat.py delete mode 100644 lib/stack/_vendor/__init__.py delete mode 100644 lib/stack/_vendor/tomli/LICENSE delete mode 100644 lib/stack/_vendor/tomli/__init__.py delete mode 100644 lib/stack/_vendor/tomli/_parser.py delete mode 100644 lib/stack/_vendor/tomli/_re.py delete mode 100644 lib/stack/_vendor/tomli/_types.py diff --git a/README.md b/README.md index 90be5cf..480560c 100644 --- a/README.md +++ b/README.md @@ -113,11 +113,11 @@ But it's not just archiving. The goal is a family operating system: something th ### Requirements - macOS on Apple Silicon (M1+) +- [Homebrew](https://brew.sh) — used to install Python 3.11+, OrbStack, and managed AI - [OrbStack](https://orbstack.dev) (recommended) or Docker Desktop -- [Homebrew](https://brew.sh) (Optional: Only for managed AI right now) - -OrbStack has its own installer at [orbstack.dev](https://orbstack.dev). +The installer will guide you through what's missing. If your `python3` is +Apple's Command Line Tools build (3.9), run `brew install python` first. ### Install diff --git a/lib/stack/_compat.py b/lib/stack/_compat.py deleted file mode 100644 index 56335cc..0000000 --- a/lib/stack/_compat.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Python version compatibility shims.""" - -import sys - -if sys.version_info >= (3, 11): - import tomllib -else: - from ._vendor import tomli as tomllib # noqa: F401 - -__all__ = ["tomllib"] diff --git a/lib/stack/_vendor/__init__.py b/lib/stack/_vendor/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/lib/stack/_vendor/tomli/LICENSE b/lib/stack/_vendor/tomli/LICENSE deleted file mode 100644 index e859590..0000000 --- a/lib/stack/_vendor/tomli/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2021 Taneli Hukkinen - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/lib/stack/_vendor/tomli/__init__.py b/lib/stack/_vendor/tomli/__init__.py deleted file mode 100644 index 18a77c3..0000000 --- a/lib/stack/_vendor/tomli/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -# SPDX-License-Identifier: MIT -# SPDX-FileCopyrightText: 2021 Taneli Hukkinen -# Licensed to PSF under a Contributor Agreement. -# -# Vendored from tomli 2.4.1 — https://github.com/hukkin/tomli -# tomli is the backport of tomllib (Python 3.11+ stdlib). -# Vendored here so famstack runs on macOS system Python (3.9). - -__all__ = ("loads", "load", "TOMLDecodeError") -__version__ = "2.4.1" - -from ._parser import TOMLDecodeError, load, loads diff --git a/lib/stack/_vendor/tomli/_parser.py b/lib/stack/_vendor/tomli/_parser.py deleted file mode 100644 index 41b0641..0000000 --- a/lib/stack/_vendor/tomli/_parser.py +++ /dev/null @@ -1,793 +0,0 @@ -# SPDX-License-Identifier: MIT -# SPDX-FileCopyrightText: 2021 Taneli Hukkinen -# Licensed to PSF under a Contributor Agreement. - -from __future__ import annotations - -import sys -from types import MappingProxyType - -from ._re import ( - RE_DATETIME, - RE_LOCALTIME, - RE_NUMBER, - match_to_datetime, - match_to_localtime, - match_to_number, -) - -TYPE_CHECKING = False -if TYPE_CHECKING: - from collections.abc import Iterable - from typing import IO, Any, Final - - from ._types import Key, ParseFloat, Pos - -# Inline tables/arrays are implemented using recursion. Pathologically -# nested documents cause pure Python to raise RecursionError (which is OK), -# but mypyc binary wheels will crash unrecoverably (not OK). According to -# mypyc docs this will be fixed in the future: -# https://mypyc.readthedocs.io/en/latest/differences_from_python.html#stack-overflows -# Before mypyc's fix is in, recursion needs to be limited by this library. -# Choosing `sys.getrecursionlimit()` as maximum inline table/array nesting -# level, as it allows more nesting than pure Python, but still seems a far -# lower number than where mypyc binaries crash. -MAX_INLINE_NESTING: Final = sys.getrecursionlimit() - -# Pathologically excessive number of parts in a key runs into quadratic -# behavior (e.g. in Flags.is_). -# Even if keys aren't currently parsed using recursion, they name a -# recursive structure, so it makes sense to limit it using getrecursionlimit() -# and RecursionError. -MAX_KEY_PARTS: Final = sys.getrecursionlimit() - -ASCII_CTRL: Final = frozenset(chr(i) for i in range(32)) | frozenset(chr(127)) - -# Neither of these sets include quotation mark or backslash. They are -# currently handled as separate cases in the parser functions. -ILLEGAL_BASIC_STR_CHARS: Final = ASCII_CTRL - frozenset("\t") -ILLEGAL_MULTILINE_BASIC_STR_CHARS: Final = ASCII_CTRL - frozenset("\t\n") - -ILLEGAL_LITERAL_STR_CHARS: Final = ILLEGAL_BASIC_STR_CHARS -ILLEGAL_MULTILINE_LITERAL_STR_CHARS: Final = ILLEGAL_MULTILINE_BASIC_STR_CHARS - -ILLEGAL_COMMENT_CHARS: Final = ILLEGAL_BASIC_STR_CHARS - -TOML_WS: Final = frozenset(" \t") -TOML_WS_AND_NEWLINE: Final = TOML_WS | frozenset("\n") -BARE_KEY_CHARS: Final = frozenset( - "abcdefghijklmnopqrstuvwxyz" "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "0123456789" "-_" -) -KEY_INITIAL_CHARS: Final = BARE_KEY_CHARS | frozenset("\"'") -HEXDIGIT_CHARS: Final = frozenset("abcdef" "ABCDEF" "0123456789") - -BASIC_STR_ESCAPE_REPLACEMENTS: Final = MappingProxyType( - { - "\\b": "\u0008", # backspace - "\\t": "\u0009", # tab - "\\n": "\u000a", # linefeed - "\\f": "\u000c", # form feed - "\\r": "\u000d", # carriage return - "\\e": "\u001b", # escape - '\\"': "\u0022", # quote - "\\\\": "\u005c", # backslash - } -) - - -class DEPRECATED_DEFAULT: - """Sentinel to be used as default arg during deprecation - period of TOMLDecodeError's free-form arguments.""" - - -class TOMLDecodeError(ValueError): - """An error raised if a document is not valid TOML. - - Adds the following attributes to ValueError: - msg: The unformatted error message - doc: The TOML document being parsed - pos: The index of doc where parsing failed - lineno: The line corresponding to pos - colno: The column corresponding to pos - """ - - def __init__( - self, - msg: str | type[DEPRECATED_DEFAULT] = DEPRECATED_DEFAULT, - doc: str | type[DEPRECATED_DEFAULT] = DEPRECATED_DEFAULT, - pos: Pos | type[DEPRECATED_DEFAULT] = DEPRECATED_DEFAULT, - *args: Any, - ): - if ( - args - or not isinstance(msg, str) - or not isinstance(doc, str) - or not isinstance(pos, int) - ): - import warnings - - warnings.warn( - "Free-form arguments for TOMLDecodeError are deprecated. " - "Please set 'msg' (str), 'doc' (str) and 'pos' (int) arguments only.", - DeprecationWarning, - stacklevel=2, - ) - if pos is not DEPRECATED_DEFAULT: - args = pos, *args - if doc is not DEPRECATED_DEFAULT: - args = doc, *args - if msg is not DEPRECATED_DEFAULT: - args = msg, *args - ValueError.__init__(self, *args) - return - - lineno = doc.count("\n", 0, pos) + 1 - if lineno == 1: - colno = pos + 1 - else: - colno = pos - doc.rindex("\n", 0, pos) - - if pos >= len(doc): - coord_repr = "end of document" - else: - coord_repr = f"line {lineno}, column {colno}" - errmsg = f"{msg} (at {coord_repr})" - ValueError.__init__(self, errmsg) - - self.msg = msg - self.doc = doc - self.pos = pos - self.lineno = lineno - self.colno = colno - - -def load(__fp: IO[bytes], *, parse_float: ParseFloat = float) -> dict[str, Any]: - """Parse TOML from a binary file object.""" - b = __fp.read() - try: - s = b.decode() - except AttributeError: - raise TypeError( - "File must be opened in binary mode, e.g. use `open('foo.toml', 'rb')`" - ) from None - return loads(s, parse_float=parse_float) - - -def loads(__s: str, *, parse_float: ParseFloat = float) -> dict[str, Any]: - """Parse TOML from a string.""" - - # The spec allows converting "\r\n" to "\n", even in string - # literals. Let's do so to simplify parsing. - try: - src = __s.replace("\r\n", "\n") - except (AttributeError, TypeError): - raise TypeError( - f"Expected str object, not '{type(__s).__qualname__}'" - ) from None - pos = 0 - out = Output() - header: Key = () - parse_float = make_safe_parse_float(parse_float) - - # Parse one statement at a time - # (typically means one line in TOML source) - while True: - # 1. Skip line leading whitespace - pos = skip_chars(src, pos, TOML_WS) - - # 2. Parse rules. Expect one of the following: - # - end of file - # - end of line - # - comment - # - key/value pair - # - append dict to list (and move to its namespace) - # - create dict (and move to its namespace) - # Skip trailing whitespace when applicable. - try: - char = src[pos] - except IndexError: - break - if char == "\n": - pos += 1 - continue - if char in KEY_INITIAL_CHARS: - pos = key_value_rule(src, pos, out, header, parse_float) - pos = skip_chars(src, pos, TOML_WS) - elif char == "[": - try: - second_char: str | None = src[pos + 1] - except IndexError: - second_char = None - out.flags.finalize_pending() - if second_char == "[": - pos, header = create_list_rule(src, pos, out) - else: - pos, header = create_dict_rule(src, pos, out) - pos = skip_chars(src, pos, TOML_WS) - elif char != "#": - raise TOMLDecodeError("Invalid statement", src, pos) - - # 3. Skip comment - pos = skip_comment(src, pos) - - # 4. Expect end of line or end of file - try: - char = src[pos] - except IndexError: - break - if char != "\n": - raise TOMLDecodeError( - "Expected newline or end of document after a statement", src, pos - ) - pos += 1 - - return out.data.dict - - -class Flags: - """Flags that map to parsed keys/namespaces.""" - - # Marks an immutable namespace (inline array or inline table). - FROZEN: Final = 0 - # Marks a nest that has been explicitly created and can no longer - # be opened using the "[table]" syntax. - EXPLICIT_NEST: Final = 1 - - def __init__(self) -> None: - self._flags: dict[str, dict[Any, Any]] = {} - self._pending_flags: set[tuple[Key, int]] = set() - - def add_pending(self, key: Key, flag: int) -> None: - self._pending_flags.add((key, flag)) - - def finalize_pending(self) -> None: - for key, flag in self._pending_flags: - self.set(key, flag, recursive=False) - self._pending_flags.clear() - - def unset_all(self, key: Key) -> None: - cont = self._flags - for k in key[:-1]: - if k not in cont: - return - cont = cont[k]["nested"] - cont.pop(key[-1], None) - - def set(self, key: Key, flag: int, *, recursive: bool) -> None: # noqa: A003 - cont = self._flags - key_parent, key_stem = key[:-1], key[-1] - for k in key_parent: - if k not in cont: - cont[k] = {"flags": set(), "recursive_flags": set(), "nested": {}} - cont = cont[k]["nested"] - if key_stem not in cont: - cont[key_stem] = {"flags": set(), "recursive_flags": set(), "nested": {}} - cont[key_stem]["recursive_flags" if recursive else "flags"].add(flag) - - def is_(self, key: Key, flag: int) -> bool: - if not key: - return False # document root has no flags - cont = self._flags - for k in key[:-1]: - if k not in cont: - return False - inner_cont = cont[k] - if flag in inner_cont["recursive_flags"]: - return True - cont = inner_cont["nested"] - key_stem = key[-1] - if key_stem in cont: - inner_cont = cont[key_stem] - return flag in inner_cont["flags"] or flag in inner_cont["recursive_flags"] - return False - - -class NestedDict: - def __init__(self) -> None: - # The parsed content of the TOML document - self.dict: dict[str, Any] = {} - - def get_or_create_nest( - self, - key: Key, - *, - access_lists: bool = True, - ) -> dict[str, Any]: - cont: Any = self.dict - for k in key: - if k not in cont: - cont[k] = {} - cont = cont[k] - if access_lists and isinstance(cont, list): - cont = cont[-1] - if not isinstance(cont, dict): - raise KeyError("There is no nest behind this key") - return cont # type: ignore[no-any-return] - - def append_nest_to_list(self, key: Key) -> None: - cont = self.get_or_create_nest(key[:-1]) - last_key = key[-1] - if last_key in cont: - list_ = cont[last_key] - if not isinstance(list_, list): - raise KeyError("An object other than list found behind this key") - list_.append({}) - else: - cont[last_key] = [{}] - - -class Output: - def __init__(self) -> None: - self.data = NestedDict() - self.flags = Flags() - - -def skip_chars(src: str, pos: Pos, chars: Iterable[str]) -> Pos: - try: - while src[pos] in chars: - pos += 1 - except IndexError: - pass - return pos - - -def skip_until( - src: str, - pos: Pos, - expect: str, - *, - error_on: frozenset[str], - error_on_eof: bool, -) -> Pos: - try: - new_pos = src.index(expect, pos) - except ValueError: - new_pos = len(src) - if error_on_eof: - raise TOMLDecodeError(f"Expected {expect!r}", src, new_pos) from None - - if not error_on.isdisjoint(src[pos:new_pos]): - while src[pos] not in error_on: - pos += 1 - raise TOMLDecodeError(f"Found invalid character {src[pos]!r}", src, pos) - return new_pos - - -def skip_comment(src: str, pos: Pos) -> Pos: - try: - char: str | None = src[pos] - except IndexError: - char = None - if char == "#": - return skip_until( - src, pos + 1, "\n", error_on=ILLEGAL_COMMENT_CHARS, error_on_eof=False - ) - return pos - - -def skip_comments_and_array_ws(src: str, pos: Pos) -> Pos: - while True: - pos_before_skip = pos - pos = skip_chars(src, pos, TOML_WS_AND_NEWLINE) - pos = skip_comment(src, pos) - if pos == pos_before_skip: - return pos - - -def create_dict_rule(src: str, pos: Pos, out: Output) -> tuple[Pos, Key]: - pos += 1 # Skip "[" - pos = skip_chars(src, pos, TOML_WS) - pos, key = parse_key(src, pos) - - if out.flags.is_(key, Flags.EXPLICIT_NEST) or out.flags.is_(key, Flags.FROZEN): - raise TOMLDecodeError(f"Cannot declare {key} twice", src, pos) - out.flags.set(key, Flags.EXPLICIT_NEST, recursive=False) - try: - out.data.get_or_create_nest(key) - except KeyError: - raise TOMLDecodeError("Cannot overwrite a value", src, pos) from None - - if not src.startswith("]", pos): - raise TOMLDecodeError( - "Expected ']' at the end of a table declaration", src, pos - ) - return pos + 1, key - - -def create_list_rule(src: str, pos: Pos, out: Output) -> tuple[Pos, Key]: - pos += 2 # Skip "[[" - pos = skip_chars(src, pos, TOML_WS) - pos, key = parse_key(src, pos) - - if out.flags.is_(key, Flags.FROZEN): - raise TOMLDecodeError(f"Cannot mutate immutable namespace {key}", src, pos) - # Free the namespace now that it points to another empty list item... - out.flags.unset_all(key) - # ...but this key precisely is still prohibited from table declaration - out.flags.set(key, Flags.EXPLICIT_NEST, recursive=False) - try: - out.data.append_nest_to_list(key) - except KeyError: - raise TOMLDecodeError("Cannot overwrite a value", src, pos) from None - - if not src.startswith("]]", pos): - raise TOMLDecodeError( - "Expected ']]' at the end of an array declaration", src, pos - ) - return pos + 2, key - - -def key_value_rule( - src: str, pos: Pos, out: Output, header: Key, parse_float: ParseFloat -) -> Pos: - pos, key, value = parse_key_value_pair(src, pos, parse_float, nest_lvl=0) - key_parent, key_stem = key[:-1], key[-1] - abs_key_parent = header + key_parent - - relative_path_cont_keys = (header + key[:i] for i in range(1, len(key))) - for cont_key in relative_path_cont_keys: - # Check that dotted key syntax does not redefine an existing table - if out.flags.is_(cont_key, Flags.EXPLICIT_NEST): - raise TOMLDecodeError(f"Cannot redefine namespace {cont_key}", src, pos) - # Containers in the relative path can't be opened with the table syntax or - # dotted key/value syntax in following table sections. - out.flags.add_pending(cont_key, Flags.EXPLICIT_NEST) - - if out.flags.is_(abs_key_parent, Flags.FROZEN): - raise TOMLDecodeError( - f"Cannot mutate immutable namespace {abs_key_parent}", src, pos - ) - - try: - nest = out.data.get_or_create_nest(abs_key_parent) - except KeyError: - raise TOMLDecodeError("Cannot overwrite a value", src, pos) from None - if key_stem in nest: - raise TOMLDecodeError("Cannot overwrite a value", src, pos) - # Mark inline table and array namespaces recursively immutable - if isinstance(value, (dict, list)): - out.flags.set(header + key, Flags.FROZEN, recursive=True) - nest[key_stem] = value - return pos - - -def parse_key_value_pair( - src: str, pos: Pos, parse_float: ParseFloat, nest_lvl: int -) -> tuple[Pos, Key, Any]: - pos, key = parse_key(src, pos) - try: - char: str | None = src[pos] - except IndexError: - char = None - if char != "=": - raise TOMLDecodeError("Expected '=' after a key in a key/value pair", src, pos) - pos += 1 - pos = skip_chars(src, pos, TOML_WS) - pos, value = parse_value(src, pos, parse_float, nest_lvl) - return pos, key, value - - -def parse_key(src: str, pos: Pos) -> tuple[Pos, Key]: - pos, key_part = parse_key_part(src, pos) - key: Key = (key_part,) - pos = skip_chars(src, pos, TOML_WS) - while True: - try: - char: str | None = src[pos] - except IndexError: - char = None - if char != ".": - return pos, key - pos += 1 - pos = skip_chars(src, pos, TOML_WS) - pos, key_part = parse_key_part(src, pos) - key += (key_part,) - if len(key) > MAX_KEY_PARTS: - raise RecursionError( - f"TOML key has more than the allowed {MAX_KEY_PARTS} parts" - ) - pos = skip_chars(src, pos, TOML_WS) - - -def parse_key_part(src: str, pos: Pos) -> tuple[Pos, str]: - try: - char: str | None = src[pos] - except IndexError: - char = None - if char in BARE_KEY_CHARS: - start_pos = pos - pos = skip_chars(src, pos, BARE_KEY_CHARS) - return pos, src[start_pos:pos] - if char == "'": - return parse_literal_str(src, pos) - if char == '"': - return parse_one_line_basic_str(src, pos) - raise TOMLDecodeError("Invalid initial character for a key part", src, pos) - - -def parse_one_line_basic_str(src: str, pos: Pos) -> tuple[Pos, str]: - pos += 1 - return parse_basic_str(src, pos, multiline=False) - - -def parse_array( - src: str, pos: Pos, parse_float: ParseFloat, nest_lvl: int -) -> tuple[Pos, list[Any]]: - pos += 1 - array: list[Any] = [] - - pos = skip_comments_and_array_ws(src, pos) - if src.startswith("]", pos): - return pos + 1, array - while True: - pos, val = parse_value(src, pos, parse_float, nest_lvl) - array.append(val) - pos = skip_comments_and_array_ws(src, pos) - - c = src[pos : pos + 1] - if c == "]": - return pos + 1, array - if c != ",": - raise TOMLDecodeError("Unclosed array", src, pos) - pos += 1 - - pos = skip_comments_and_array_ws(src, pos) - if src.startswith("]", pos): - return pos + 1, array - - -def parse_inline_table( - src: str, pos: Pos, parse_float: ParseFloat, nest_lvl: int -) -> tuple[Pos, dict[str, Any]]: - pos += 1 - nested_dict = NestedDict() - flags = Flags() - - pos = skip_comments_and_array_ws(src, pos) - if src.startswith("}", pos): - return pos + 1, nested_dict.dict - while True: - pos, key, value = parse_key_value_pair(src, pos, parse_float, nest_lvl) - key_parent, key_stem = key[:-1], key[-1] - if flags.is_(key, Flags.FROZEN): - raise TOMLDecodeError(f"Cannot mutate immutable namespace {key}", src, pos) - try: - nest = nested_dict.get_or_create_nest(key_parent, access_lists=False) - except KeyError: - raise TOMLDecodeError("Cannot overwrite a value", src, pos) from None - if key_stem in nest: - raise TOMLDecodeError(f"Duplicate inline table key {key_stem!r}", src, pos) - nest[key_stem] = value - pos = skip_comments_and_array_ws(src, pos) - c = src[pos : pos + 1] - if c == "}": - return pos + 1, nested_dict.dict - if c != ",": - raise TOMLDecodeError("Unclosed inline table", src, pos) - pos += 1 - pos = skip_comments_and_array_ws(src, pos) - if src.startswith("}", pos): - return pos + 1, nested_dict.dict - if isinstance(value, (dict, list)): - flags.set(key, Flags.FROZEN, recursive=True) - - -def parse_basic_str_escape( - src: str, pos: Pos, *, multiline: bool = False -) -> tuple[Pos, str]: - escape_id = src[pos : pos + 2] - pos += 2 - if multiline and escape_id in {"\\ ", "\\\t", "\\\n"}: - # Skip whitespace until next non-whitespace character or end of - # the doc. Error if non-whitespace is found before newline. - if escape_id != "\\\n": - pos = skip_chars(src, pos, TOML_WS) - try: - char = src[pos] - except IndexError: - return pos, "" - if char != "\n": - raise TOMLDecodeError("Unescaped '\\' in a string", src, pos) - pos += 1 - pos = skip_chars(src, pos, TOML_WS_AND_NEWLINE) - return pos, "" - if escape_id == "\\x": - return parse_hex_char(src, pos, 2) - if escape_id == "\\u": - return parse_hex_char(src, pos, 4) - if escape_id == "\\U": - return parse_hex_char(src, pos, 8) - try: - return pos, BASIC_STR_ESCAPE_REPLACEMENTS[escape_id] - except KeyError: - raise TOMLDecodeError("Unescaped '\\' in a string", src, pos) from None - - -def parse_basic_str_escape_multiline(src: str, pos: Pos) -> tuple[Pos, str]: - return parse_basic_str_escape(src, pos, multiline=True) - - -def parse_hex_char(src: str, pos: Pos, hex_len: int) -> tuple[Pos, str]: - hex_str = src[pos : pos + hex_len] - if len(hex_str) != hex_len or not HEXDIGIT_CHARS.issuperset(hex_str): - raise TOMLDecodeError("Invalid hex value", src, pos) - pos += hex_len - hex_int = int(hex_str, 16) - if not is_unicode_scalar_value(hex_int): - raise TOMLDecodeError( - "Escaped character is not a Unicode scalar value", src, pos - ) - return pos, chr(hex_int) - - -def parse_literal_str(src: str, pos: Pos) -> tuple[Pos, str]: - pos += 1 # Skip starting apostrophe - start_pos = pos - pos = skip_until( - src, pos, "'", error_on=ILLEGAL_LITERAL_STR_CHARS, error_on_eof=True - ) - return pos + 1, src[start_pos:pos] # Skip ending apostrophe - - -def parse_multiline_str(src: str, pos: Pos, *, literal: bool) -> tuple[Pos, str]: - pos += 3 - if src.startswith("\n", pos): - pos += 1 - - if literal: - delim = "'" - end_pos = skip_until( - src, - pos, - "'''", - error_on=ILLEGAL_MULTILINE_LITERAL_STR_CHARS, - error_on_eof=True, - ) - result = src[pos:end_pos] - pos = end_pos + 3 - else: - delim = '"' - pos, result = parse_basic_str(src, pos, multiline=True) - - # Add at maximum two extra apostrophes/quotes if the end sequence - # is 4 or 5 chars long instead of just 3. - if not src.startswith(delim, pos): - return pos, result - pos += 1 - if not src.startswith(delim, pos): - return pos, result + delim - pos += 1 - return pos, result + (delim * 2) - - -def parse_basic_str(src: str, pos: Pos, *, multiline: bool) -> tuple[Pos, str]: - if multiline: - error_on = ILLEGAL_MULTILINE_BASIC_STR_CHARS - parse_escapes = parse_basic_str_escape_multiline - else: - error_on = ILLEGAL_BASIC_STR_CHARS - parse_escapes = parse_basic_str_escape - result = "" - start_pos = pos - while True: - try: - char = src[pos] - except IndexError: - raise TOMLDecodeError("Unterminated string", src, pos) from None - if char == '"': - if not multiline: - return pos + 1, result + src[start_pos:pos] - if src.startswith('"""', pos): - return pos + 3, result + src[start_pos:pos] - pos += 1 - continue - if char == "\\": - result += src[start_pos:pos] - pos, parsed_escape = parse_escapes(src, pos) - result += parsed_escape - start_pos = pos - continue - if char in error_on: - raise TOMLDecodeError(f"Illegal character {char!r}", src, pos) - pos += 1 - - -def parse_value( - src: str, pos: Pos, parse_float: ParseFloat, nest_lvl: int -) -> tuple[Pos, Any]: - if nest_lvl > MAX_INLINE_NESTING: - # Pure Python should have raised RecursionError already. - # This ensures mypyc binaries eventually do the same. - raise RecursionError( # pragma: no cover - "TOML inline arrays/tables are nested more than the allowed" - f" {MAX_INLINE_NESTING} levels" - ) - - try: - char: str | None = src[pos] - except IndexError: - char = None - - # IMPORTANT: order conditions based on speed of checking and likelihood - - # Basic strings - if char == '"': - if src.startswith('"""', pos): - return parse_multiline_str(src, pos, literal=False) - return parse_one_line_basic_str(src, pos) - - # Literal strings - if char == "'": - if src.startswith("'''", pos): - return parse_multiline_str(src, pos, literal=True) - return parse_literal_str(src, pos) - - # Booleans - if char == "t": - if src.startswith("true", pos): - return pos + 4, True - if char == "f": - if src.startswith("false", pos): - return pos + 5, False - - # Arrays - if char == "[": - return parse_array(src, pos, parse_float, nest_lvl + 1) - - # Inline tables - if char == "{": - return parse_inline_table(src, pos, parse_float, nest_lvl + 1) - - # Dates and times - datetime_match = RE_DATETIME.match(src, pos) - if datetime_match: - try: - datetime_obj = match_to_datetime(datetime_match) - except ValueError as e: - raise TOMLDecodeError("Invalid date or datetime", src, pos) from e - return datetime_match.end(), datetime_obj - localtime_match = RE_LOCALTIME.match(src, pos) - if localtime_match: - return localtime_match.end(), match_to_localtime(localtime_match) - - # Integers and "normal" floats. - # The regex will greedily match any type starting with a decimal - # char, so needs to be located after handling of dates and times. - number_match = RE_NUMBER.match(src, pos) - if number_match: - return number_match.end(), match_to_number(number_match, parse_float) - - # Special floats - first_three = src[pos : pos + 3] - if first_three in {"inf", "nan"}: - return pos + 3, parse_float(first_three) - first_four = src[pos : pos + 4] - if first_four in {"-inf", "+inf", "-nan", "+nan"}: - return pos + 4, parse_float(first_four) - - raise TOMLDecodeError("Invalid value", src, pos) - - -def is_unicode_scalar_value(codepoint: int) -> bool: - return (0 <= codepoint <= 55295) or (57344 <= codepoint <= 1114111) - - -def make_safe_parse_float(parse_float: ParseFloat) -> ParseFloat: - """A decorator to make `parse_float` safe. - - `parse_float` must not return dicts or lists, because these types - would be mixed with parsed TOML tables and arrays, thus confusing - the parser. The returned decorated callable raises `ValueError` - instead of returning illegal types. - """ - # The default `float` callable never returns illegal types. Optimize it. - if parse_float is float: - return float - - def safe_parse_float(float_str: str) -> Any: - float_value = parse_float(float_str) - if isinstance(float_value, (dict, list)): - raise ValueError("parse_float must not return dicts or lists") - return float_value - - return safe_parse_float diff --git a/lib/stack/_vendor/tomli/_re.py b/lib/stack/_vendor/tomli/_re.py deleted file mode 100644 index fc374ed..0000000 --- a/lib/stack/_vendor/tomli/_re.py +++ /dev/null @@ -1,119 +0,0 @@ -# SPDX-License-Identifier: MIT -# SPDX-FileCopyrightText: 2021 Taneli Hukkinen -# Licensed to PSF under a Contributor Agreement. - -from __future__ import annotations - -from datetime import date, datetime, time, timedelta, timezone, tzinfo -from functools import lru_cache -import re - -TYPE_CHECKING = False -if TYPE_CHECKING: - from typing import Any, Final - - from ._types import ParseFloat - -_TIME_RE_STR: Final = r""" -([01][0-9]|2[0-3]) # hours -:([0-5][0-9]) # minutes -(?: - :([0-5][0-9]) # optional seconds - (?:\.([0-9]{1,6})[0-9]*)? # optional fractions of a second -)? -""" - -RE_NUMBER: Final = re.compile( - r""" -0 -(?: - x[0-9A-Fa-f](?:_?[0-9A-Fa-f])* # hex - | - b[01](?:_?[01])* # bin - | - o[0-7](?:_?[0-7])* # oct -) -| -[+-]?(?:0|[1-9](?:_?[0-9])*) # dec, integer part -(?P - (?:\.[0-9](?:_?[0-9])*)? # optional fractional part - (?:[eE][+-]?[0-9](?:_?[0-9])*)? # optional exponent part -) -""", - flags=re.VERBOSE, -) -RE_LOCALTIME: Final = re.compile(_TIME_RE_STR, flags=re.VERBOSE) -RE_DATETIME: Final = re.compile( - rf""" -([0-9]{{4}})-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01]) # date, e.g. 1988-10-27 -(?: - [Tt ] - {_TIME_RE_STR} - (?:([Zz])|([+-])([01][0-9]|2[0-3]):([0-5][0-9]))? # optional time offset -)? -""", - flags=re.VERBOSE, -) - - -def match_to_datetime(match: re.Match[str]) -> datetime | date: - """Convert a `RE_DATETIME` match to `datetime.datetime` or `datetime.date`. - - Raises ValueError if the match does not correspond to a valid date - or datetime. - """ - ( - year_str, - month_str, - day_str, - hour_str, - minute_str, - sec_str, - micros_str, - zulu_time, - offset_sign_str, - offset_hour_str, - offset_minute_str, - ) = match.groups() - year, month, day = int(year_str), int(month_str), int(day_str) - if hour_str is None: - return date(year, month, day) - hour, minute = int(hour_str), int(minute_str) - sec = int(sec_str) if sec_str else 0 - micros = int(micros_str.ljust(6, "0")) if micros_str else 0 - if offset_sign_str: - tz: tzinfo | None = cached_tz( - offset_hour_str, offset_minute_str, offset_sign_str - ) - elif zulu_time: - tz = timezone.utc - else: # local date-time - tz = None - return datetime(year, month, day, hour, minute, sec, micros, tzinfo=tz) - - -# No need to limit cache size. This is only ever called on input -# that matched RE_DATETIME, so there is an implicit bound of -# 24 (hours) * 60 (minutes) * 2 (offset direction) = 2880. -@lru_cache(maxsize=None) -def cached_tz(hour_str: str, minute_str: str, sign_str: str) -> timezone: - sign = 1 if sign_str == "+" else -1 - return timezone( - timedelta( - hours=sign * int(hour_str), - minutes=sign * int(minute_str), - ) - ) - - -def match_to_localtime(match: re.Match[str]) -> time: - hour_str, minute_str, sec_str, micros_str = match.groups() - sec = int(sec_str) if sec_str else 0 - micros = int(micros_str.ljust(6, "0")) if micros_str else 0 - return time(int(hour_str), int(minute_str), sec, micros) - - -def match_to_number(match: re.Match[str], parse_float: ParseFloat) -> Any: - if match.group("floatpart"): - return parse_float(match.group()) - return int(match.group(), 0) diff --git a/lib/stack/_vendor/tomli/_types.py b/lib/stack/_vendor/tomli/_types.py deleted file mode 100644 index d949412..0000000 --- a/lib/stack/_vendor/tomli/_types.py +++ /dev/null @@ -1,10 +0,0 @@ -# SPDX-License-Identifier: MIT -# SPDX-FileCopyrightText: 2021 Taneli Hukkinen -# Licensed to PSF under a Contributor Agreement. - -from typing import Any, Callable, Tuple - -# Type annotations -ParseFloat = Callable[[str], Any] -Key = Tuple[str, ...] -Pos = int diff --git a/lib/stack/cli.py b/lib/stack/cli.py index b5094d6..9db4495 100644 --- a/lib/stack/cli.py +++ b/lib/stack/cli.py @@ -20,7 +20,7 @@ import json import os import sys -from ._compat import tomllib +import tomllib from pathlib import Path from . import docker diff --git a/lib/stack/installer.py b/lib/stack/installer.py index d3d22bb..fa3e923 100644 --- a/lib/stack/installer.py +++ b/lib/stack/installer.py @@ -42,7 +42,7 @@ def validate_name(value): def load_stacklets(repo_root): """Load stacklet definitions from stacklet.toml files.""" - from ._compat import tomllib + import tomllib stacklets_dir = repo_root / "stacklets" stacklets = {} @@ -342,7 +342,7 @@ def write_users_toml(users): def show_existing_config(): """When config already exists, show what's there and how to change it.""" - from ._compat import tomllib + import tomllib clear() banner("famstack") diff --git a/lib/stack/secrets.py b/lib/stack/secrets.py index 092c8ef..9dbdcfd 100644 --- a/lib/stack/secrets.py +++ b/lib/stack/secrets.py @@ -12,7 +12,7 @@ Writing always targets the stacklet namespace. """ -from ._compat import tomllib +import tomllib from pathlib import Path diff --git a/lib/stack/stack.py b/lib/stack/stack.py index 602e991..7364307 100644 --- a/lib/stack/stack.py +++ b/lib/stack/stack.py @@ -18,7 +18,7 @@ import shutil import subprocess import sys -from ._compat import tomllib +import tomllib from pathlib import Path from .docker import running_project_ids diff --git a/lib/stack/users.py b/lib/stack/users.py index 4fd30a3..85ecbaa 100644 --- a/lib/stack/users.py +++ b/lib/stack/users.py @@ -6,7 +6,7 @@ and secrets.toml. All code that needs user info goes through here. """ -from ._compat import tomllib +import tomllib from pathlib import Path # Internal service account — used by the CLI to manage all services. diff --git a/pyproject.toml b/pyproject.toml index 7c27a9d..92ebd8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "famstack" version = "0.2.0" description = "Self-hosted family server stack for Apple Silicon" -requires-python = ">=3.9" +requires-python = ">=3.11" # The CLI itself depends only on the stdlib — stacklet containers carry # their own runtime deps. These extras are for host-side tooling: tests @@ -10,9 +10,7 @@ requires-python = ">=3.9" [project.optional-dependencies] test = [ - # pytest 8.x still supports Python 3.9 (per AGENT.md runtime target); - # pytest 9+ requires 3.10+. Revisit when we drop 3.9 compat. - "pytest>=8,<9", + "pytest>=8", "pytest-asyncio>=0.21", "pytest-httpserver>=1.0", # Bot runtime deps — tests import the archivist and log into Matrix diff --git a/stack b/stack index abea281..d903971 100755 --- a/stack +++ b/stack @@ -1,4 +1,46 @@ #!/usr/bin/env bash # stack CLI — all logic lives in lib/stack/ +# +# Apple's Command Line Tools ship `python3` as 3.9, which is too old for +# stack and often takes priority over Homebrew on PATH. Prefer the +# version-named binaries that Homebrew installs and only fall back to +# `python3` if it's actually new enough. + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -exec env PYTHONPATH="$SCRIPT_DIR/lib" python3 -m stack "$@" + +PYTHON= +for cand in python3.13 python3.12 python3.11 python3; do + if command -v "$cand" >/dev/null 2>&1; then + ver=$("$cand" -c 'import sys; print(sys.version_info[0]*100 + sys.version_info[1])' 2>/dev/null || echo 0) + if [ "$ver" -ge 311 ]; then + PYTHON="$cand" + break + fi + fi +done + +if [ -z "$PYTHON" ]; then + if [ -t 2 ]; then + RED=$'\033[38;5;203m' + ORANGE=$'\033[38;5;208m' + BOLD=$'\033[1m' + RESET=$'\033[0m' + else + RED= ORANGE= BOLD= RESET= + fi + cat >&2 < dict: """Read [settings] from the archivist's bot.toml (same file the bot reads).""" - try: - import tomllib - except ModuleNotFoundError: - from stack._compat import tomllib + import tomllib if not _BOT_TOML_PATH.exists(): return {} with open(_BOT_TOML_PATH, "rb") as f: diff --git a/stacklets/docs/bot/cli/tags.py b/stacklets/docs/bot/cli/tags.py index 6ac5d76..db628fb 100644 --- a/stacklets/docs/bot/cli/tags.py +++ b/stacklets/docs/bot/cli/tags.py @@ -394,10 +394,7 @@ async def _delete_entity(paperless: PaperlessAPI, endpoint: str, def _read_taxonomy() -> dict: - try: - import tomllib - except ModuleNotFoundError: - from stack._compat import tomllib + import tomllib path = Path("/stacklets/docs/taxonomy.toml") if not path.exists(): return {} diff --git a/stacklets/docs/seed.py b/stacklets/docs/seed.py index 05eead4..81b04a3 100644 --- a/stacklets/docs/seed.py +++ b/stacklets/docs/seed.py @@ -34,10 +34,7 @@ def _load_taxonomy(language: str) -> dict: Falls back to English if the requested language isn't defined. Returns {"tags": [...], "types": [...]}. """ - try: - import tomllib - except ModuleNotFoundError: - from stack._compat import tomllib + import tomllib if not TAXONOMY_PATH.exists(): return {"tags": [], "types": []} diff --git a/stacklets/messages/cli/_matrix.py b/stacklets/messages/cli/_matrix.py index 94b78a4..6f74f5a 100644 --- a/stacklets/messages/cli/_matrix.py +++ b/stacklets/messages/cli/_matrix.py @@ -18,10 +18,7 @@ import ssl import sys import time -try: - import tomllib -except ModuleNotFoundError: - from stack._vendor import tomli as tomllib +import tomllib import urllib.error import urllib.request from pathlib import Path From 9efab0b2e70a3b7e7bd4414a80a9f35026cbbbd7 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 18 May 2026 17:39:58 +0200 Subject: [PATCH 2/2] docs(refactor-plan): mark Phase 4 (Python baseline lift) done --- docs/todos/refactor-cleanup-plan.md | 35 +++++++++-------------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/docs/todos/refactor-cleanup-plan.md b/docs/todos/refactor-cleanup-plan.md index 7590bc3..566881f 100644 --- a/docs/todos/refactor-cleanup-plan.md +++ b/docs/todos/refactor-cleanup-plan.md @@ -9,7 +9,6 @@ reduce long-term complexity. - We are still pre-1.0 and can change internal patterns safely. - `sys.path.insert(...)` import wiring is widespread and brittle. - Host framework and Docker bot runtimes share code without clear boundaries. -- Python 3.9 compatibility adds drag for testing and packaging. - HTTP behavior is inconsistent (timeouts, TLS policy, error handling). ## Design goals @@ -124,20 +123,9 @@ Without these wiring changes, the proposed structure will fail in containers. ## Python version policy -### Direction - -- Move baseline to Python `>=3.11` for framework and bot runtime. -- End active 3.9 compatibility after migration window. - -### Why - -- 3.9 is EOL and increases compatibility overhead (`pytest<9`, shims, guard code). -- 3.11 improves performance, typing ergonomics, and dependency support. - -### Transition (short-lived) - -- One migration cycle can run dual CI (3.9 + 3.11) to catch regressions. -- After parity, remove 3.9 constraints and simplify codepaths. +Baseline: Python 3.11+. Enforced at launch by the `./stack` wrapper (which +picks `python3.11`/`3.12`/`3.13` from PATH and refuses Apple's CLT 3.9). +`tomllib` is stdlib at this baseline — no compat shim is needed. ## HTTP request conventions @@ -250,7 +238,7 @@ Testing should enforce module boundaries and protect refactors from silent regre - Phase 3 gate: - HTTP wrappers validated for timeout/auth/decode/connect failure classes. - Phase 4 gate: - - Test matrix proves 3.11 baseline; 3.9 jobs removed only after parity confirmation. + - Baseline enforced at the wrapper level; no CI matrix needed. - Phase 5 gate: - Large-module splits preserve command/lifecycle behavior via unchanged blackbox tests. @@ -301,17 +289,14 @@ Exit criteria: - Runtime HTTP callsites use consistent policy and error semantics. -## Phase 4 - Python baseline lift - -- Switch project baseline to 3.11. -- Remove 3.9-only constraints and compatibility leftovers. -- Update CI and container base images. -- Add explicit startup/preflight message in host CLI when interpreter is below baseline. +## Phase 4 - Python baseline lift (DONE) -Exit criteria: +- ✅ Project baseline switched to 3.11 (`pyproject.toml`). +- ✅ Removed `_compat` shim and vendored tomli (`lib/stack/_vendor/`). +- ✅ Containers were already on `python:3.12-slim`. +- ✅ `./stack` wrapper enforces 3.11+ at launch with a brew hint. -- CI green on 3.11 baseline. -- No remaining 3.9 compatibility blockers. +No CI to update; baseline is enforced at the wrapper. ## Phase 5 - Structural cleanup