From caa9f4f99dae3a69793c94c54879deac02bd473a Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Mon, 29 Dec 2025 15:48:05 +0400 Subject: [PATCH] feat: improve config UX and packaging metadata --- LICENSE | 21 +++++++++ developing.md | 4 +- pyproject.toml | 17 ++++++- readme.md | 2 +- src/takopi/config.py | 66 +++++++++++++++++++++++++-- src/takopi/constants.py | 5 ++- src/takopi/exec_bridge.py | 93 +++++++++++++++++++++++++++------------ src/takopi/logging.py | 2 +- 8 files changed, 170 insertions(+), 40 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b4bf5ef --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 banteg + +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/developing.md b/developing.md index 17f934b..10769bb 100644 --- a/developing.md +++ b/developing.md @@ -71,14 +71,14 @@ def render_markdown(md: str) -> tuple[str, list[dict[str, Any]]]: ```python def load_telegram_config(path=None) -> dict: - # Loads ~/.codex/telegram.toml (or custom path) + # Loads ./codex/takopi.toml or ~/.codex/takopi.toml (or custom path) ``` ### `constants.py` — Shared Constants ```python TELEGRAM_HARD_LIMIT = 4096 # Max message length -TELEGRAM_CONFIG_PATH = ~/.codex/telegram.toml +DEFAULT_CONFIG_PATHS = (./codex/takopi.toml, ~/.codex/takopi.toml) ``` ### `logging.py` — Secure Logging Setup diff --git a/pyproject.toml b/pyproject.toml index 62adbf7..837a510 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,6 +3,7 @@ name = "takopi" version = "0.1.0" description = "Telegram bridge tools for Codex." readme = "readme.md" +license = { file = "LICENSE" } requires-python = ">=3.12" dependencies = [ "httpx>=0.28.1", @@ -10,10 +11,22 @@ dependencies = [ "sulguk>=0.11.0", "typer", ] +classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3 :: Only", + "Operating System :: OS Independent", +] + +[project.urls] +Homepage = "https://github.com/banteg/takopi" +Repository = "https://github.com/banteg/takopi" +Issues = "https://github.com/banteg/takopi/issues" [project.scripts] takopi = "takopi.exec_bridge:main" -exec-bridge = "takopi.exec_bridge:main" [build-system] requires = ["hatchling"] @@ -23,7 +36,7 @@ build-backend = "hatchling.build" packages = ["src/takopi"] [tool.hatch.build.targets.sdist] -include = ["src/takopi", "readme.md"] +include = ["src/takopi", "readme.md", "LICENSE"] [dependency-groups] dev = [ diff --git a/readme.md b/readme.md index 810b865..d54da3d 100644 --- a/readme.md +++ b/readme.md @@ -32,7 +32,7 @@ uv run takopi --help ### Configuration -Create `~/.codex/telegram.toml`: +Create `~/.codex/takopi.toml` (or `./codex/takopi.toml` for a repo-local config): ```toml bot_token = "123456789:ABCdefGHIjklMNOpqrsTUVwxyz" diff --git a/src/takopi/config.py b/src/takopi/config.py index 255968d..0a9f173 100644 --- a/src/takopi/config.py +++ b/src/takopi/config.py @@ -1,9 +1,67 @@ +from __future__ import annotations + import tomllib from pathlib import Path -from .constants import TELEGRAM_CONFIG_PATH +from .constants import DEFAULT_CONFIG_PATHS -def load_telegram_config(path=None): - cfg_path = Path(path) if path else TELEGRAM_CONFIG_PATH - return tomllib.loads(cfg_path.read_text(encoding="utf-8")) +class ConfigError(RuntimeError): + pass + + +def _display_path(path: Path) -> str: + try: + cwd = Path.cwd() + if path.is_relative_to(cwd): + return f"./{path.relative_to(cwd).as_posix()}" + home = Path.home() + if path.is_relative_to(home): + return f"~/{path.relative_to(home).as_posix()}" + except Exception: + return str(path) + return str(path) + + +def _missing_config_message(primary: Path, alternate: Path | None = None) -> str: + if alternate is None: + header = f"Missing config file `{_display_path(primary)}`." + else: + header = ( + f"Missing config file `{_display_path(primary)}` " + f"(or `{_display_path(alternate)}`)." + ) + return "\n".join( + [ + header, + "Create it with:", + ' bot_token = "123456789:ABCdefGHIjklMNOpqrsTUVwxyz"', + " chat_id = 123456789", + ] + ) + + +def _read_config(cfg_path: Path) -> dict: + try: + raw = cfg_path.read_text(encoding="utf-8") + except FileNotFoundError: + raise ConfigError(_missing_config_message(cfg_path)) from None + except OSError as e: + raise ConfigError(f"Failed to read config file {cfg_path}: {e}") from e + try: + return tomllib.loads(raw) + except tomllib.TOMLDecodeError as e: + raise ConfigError(f"Malformed TOML in {cfg_path}: {e}") from None + + +def load_telegram_config(path: str | Path | None = None) -> tuple[dict, Path]: + if path: + cfg_path = Path(path).expanduser() + return _read_config(cfg_path), cfg_path + + local_path, home_path = DEFAULT_CONFIG_PATHS + for candidate in (local_path, home_path): + if candidate.is_file(): + return _read_config(candidate), candidate + + raise ConfigError(_missing_config_message(home_path, local_path)) diff --git a/src/takopi/constants.py b/src/takopi/constants.py index 219b12c..22dfd10 100644 --- a/src/takopi/constants.py +++ b/src/takopi/constants.py @@ -3,4 +3,7 @@ from __future__ import annotations from pathlib import Path TELEGRAM_HARD_LIMIT = 4096 -TELEGRAM_CONFIG_PATH = Path.home() / ".codex" / "telegram.toml" +DEFAULT_CONFIG_PATHS = ( + Path.cwd() / "codex" / "takopi.toml", + Path.home() / ".codex" / "takopi.toml", +) diff --git a/src/takopi/exec_bridge.py b/src/takopi/exec_bridge.py index b77ec57..c824fc2 100644 --- a/src/takopi/exec_bridge.py +++ b/src/takopi/exec_bridge.py @@ -18,7 +18,7 @@ from weakref import WeakValueDictionary import typer -from .config import load_telegram_config +from .config import ConfigError, load_telegram_config from .exec_render import ExecProgressRenderer, render_event_cli from .logging import setup_logging from .rendering import render_markdown @@ -358,20 +358,35 @@ def _parse_bridge_config( cd: str | None, model: str | None, ) -> BridgeConfig: - config = load_telegram_config() - token = config["bot_token"] - chat_id = int(config["chat_id"]) + config, config_path = load_telegram_config() + try: + token = config["bot_token"] + except KeyError: + raise ConfigError(f"Missing key `bot_token` in {config_path}.") from None + try: + chat_id = int(config["chat_id"]) + except KeyError: + raise ConfigError(f"Missing key `chat_id` in {config_path}.") from None + except (TypeError, ValueError): + raise ConfigError( + f"Invalid `chat_id` in {config_path}; expected an integer." + ) from None codex_cmd = shutil.which("codex") if not codex_cmd: - raise RuntimeError("codex not found on PATH") + raise ConfigError( + "codex not found on PATH. Install the Codex CLI with:\n" + " npm install -g @openai/codex\n" + " # or on macOS\n" + " brew install codex" + ) startup_pwd = os.getcwd() workspace = None if cd is not None: expanded_cd = os.path.expanduser(cd) if not os.path.isdir(expanded_cd): - raise RuntimeError(f"--cd must be an existing directory: {expanded_cd}") + raise ConfigError(f"--cd must be an existing directory: {expanded_cd}") workspace = expanded_cd startup_pwd = expanded_cd @@ -425,18 +440,22 @@ async def _send_startup(cfg: BridgeConfig) -> None: async def _drain_backlog(cfg: BridgeConfig, offset: int | None) -> int | None: - try: - updates = await cfg.bot.get_updates( - offset=offset, timeout_s=0, allowed_updates=["message"] - ) - except Exception as e: - logger.info("[startup] backlog drain failed: %s", e) - return offset - logger.debug("[startup] backlog updates: %s", updates) - if updates: + drained = 0 + while True: + try: + updates = await cfg.bot.get_updates( + offset=offset, timeout_s=0, allowed_updates=["message"] + ) + except Exception as e: + logger.info("[startup] backlog drain failed: %s", e) + return offset + logger.debug("[startup] backlog updates: %s", updates) + if not updates: + if drained: + logger.info("[startup] drained %s pending update(s)", drained) + return offset offset = updates[-1]["update_id"] + 1 - logger.info("[startup] drained %s pending update(s)", len(updates)) - return offset + drained += len(updates) async def _handle_message( @@ -463,11 +482,15 @@ async def _handle_message( last_edit_at = 0.0 edit_task: asyncio.Task[None] | None = None + last_rendered: str | None = None + pending_rendered: str | None = None - async def _edit_progress(md: str) -> None: + async def _edit_progress( + md: str, rendered: str, entities: list[dict[str, Any]] | None + ) -> None: + nonlocal last_rendered, pending_rendered if progress_id is None: return - rendered, entities = prepare_telegram(md, limit=TELEGRAM_MARKDOWN_LIMIT) logger.debug( "[progress] edit message_id=%s md=%s rendered=%s entities=%s", progress_id, @@ -482,6 +505,7 @@ async def _handle_message( text=rendered, entities=entities, ) + last_rendered = rendered except Exception as e: logger.info( "[progress] edit failed chat_id=%s message_id=%s: %s", @@ -489,6 +513,9 @@ async def _handle_message( progress_id, e, ) + finally: + if pending_rendered == rendered: + pending_rendered = None try: initial_md = progress_renderer.render_progress(0.0) @@ -511,6 +538,7 @@ async def _handle_message( ) progress_id = int(progress_msg["message_id"]) last_edit_at = clock() + last_rendered = initial_rendered logger.debug("[progress] sent chat_id=%s message_id=%s", chat_id, progress_id) except Exception as e: logger.info( @@ -518,7 +546,7 @@ async def _handle_message( ) async def on_event(evt: dict[str, Any]) -> None: - nonlocal last_edit_at, edit_task + nonlocal last_edit_at, edit_task, pending_rendered if progress_id is None: return if not progress_renderer.note_event(evt): @@ -528,11 +556,14 @@ async def _handle_message( return if edit_task is not None and not edit_task.done(): return - last_edit_at = now elapsed = now - started_at - edit_task = asyncio.create_task( - _edit_progress(progress_renderer.render_progress(elapsed)) - ) + md = progress_renderer.render_progress(elapsed) + rendered, entities = prepare_telegram(md, limit=TELEGRAM_MARKDOWN_LIMIT) + if rendered == last_rendered or rendered == pending_rendered: + return + last_edit_at = now + pending_rendered = rendered + edit_task = asyncio.create_task(_edit_progress(md, rendered, entities)) try: session_id, answer, saw_agent_message = await cfg.runner.run_serialized( @@ -692,11 +723,15 @@ def run( ), ) -> None: setup_logging(debug=debug) - cfg = _parse_bridge_config( - final_notify=final_notify, - cd=cd, - model=model, - ) + try: + cfg = _parse_bridge_config( + final_notify=final_notify, + cd=cd, + model=model, + ) + except ConfigError as e: + typer.echo(str(e), err=True) + raise typer.Exit(code=1) asyncio.run(_run_main_loop(cfg)) diff --git a/src/takopi/logging.py b/src/takopi/logging.py index 12b23c9..94faa2b 100644 --- a/src/takopi/logging.py +++ b/src/takopi/logging.py @@ -25,7 +25,7 @@ class RedactTokenFilter(logging.Filter): def setup_logging(*, debug: bool = False) -> None: root_logger = logging.getLogger() - root_logger.setLevel(logging.DEBUG) + root_logger.setLevel(logging.DEBUG if debug else logging.INFO) for handler in root_logger.handlers[:]: root_logger.removeHandler(handler) handler.close()