소스 검색

add python changes

Breandan Dezendorf 12 시간 전
부모
커밋
873914c1af

+ 13 - 0
dezendorf/applications/chrome-tab-switcher/BUILD

@@ -0,0 +1,13 @@
+load("@rules_python//python:defs.bzl", "py_binary", "py_test")
+
+py_binary(
+    name = "chrome-tab-switcher",
+    srcs = ["chrome-tab-switcher.py"],
+    visibility = ["//visibility:public"],
+)
+
+py_test(
+    name = "chrome-tab-switcher_test",
+    srcs = ["chrome-tab-switcher_test.py"],
+    main = "chrome-tab-switcher_test.py",
+)

+ 58 - 0
dezendorf/applications/chrome-tab-switcher/README.md

@@ -0,0 +1,58 @@
+# chrome-tab-switcher
+
+`chrome-tab-switcher` reads `chrome-session-dump --json`, presents tabs via `rofi`,
+and switches to the chosen Chrome tab in i3wm using `wmctrl` + `xdotool`.
+
+By default it discovers all active Chrome/Chromium sessions by inspecting running
+processes (`ps -eo args`), then merges tabs from each discovered
+`--user-data-dir`.
+
+## Requirements
+
+- `chrome-session-dump`
+- `rofi`
+- `wmctrl`
+- `xdotool`
+
+## Usage
+
+Interactive (default, all active sessions):
+
+```bash
+python3 dezendorf/applications/chrome-tab-switcher/chrome-tab-switcher.py
+```
+
+List tabs only:
+
+```bash
+python3 dezendorf/applications/chrome-tab-switcher/chrome-tab-switcher.py --list-only
+```
+
+Switch by explicit token:
+
+```bash
+python3 dezendorf/applications/chrome-tab-switcher/chrome-tab-switcher.py --select S0W0T5
+```
+
+Dry-run (show focus/switch commands without executing):
+
+```bash
+python3 dezendorf/applications/chrome-tab-switcher/chrome-tab-switcher.py --select S0W0T5 --dry-run
+```
+
+Single-session mode (legacy behavior):
+
+```bash
+python3 dezendorf/applications/chrome-tab-switcher/chrome-tab-switcher.py --single-session
+```
+
+## i3 binding example
+
+```i3
+bindsym $mod+u exec --no-startup-id python3 /home/bwdezend/monorepo/dezendorf/applications/chrome-tab-switcher/chrome-tab-switcher.py
+```
+
+## Notes
+
+- The tool prefers matching the parent Chrome window by active-window title from session data, then falls back to index order.
+- Tab switching resets to first tab (`Ctrl+1`) and advances with repeated `Ctrl+Tab`.

+ 429 - 0
dezendorf/applications/chrome-tab-switcher/chrome-tab-switcher.py

@@ -0,0 +1,429 @@
+#!/usr/bin/env python3
+
+import argparse
+import json
+import os
+import re
+import shlex
+import subprocess
+import sys
+import time
+from dataclasses import dataclass
+from typing import Any, Iterable, List, Optional, Sequence, Tuple
+from urllib.parse import urlparse
+
+
+@dataclass
+class TabEntry:
+    token: str
+    window_index: int
+    tab_index: int
+    title: str
+    url: str
+    active: bool
+    session_name: str = ""
+    window_active_title: str = ""
+
+    def menu_line(self) -> str:
+        return f"{self.token}\t{self.label()}"
+
+    def label(self) -> str:
+        host = ""
+        if self.url:
+            parsed = urlparse(self.url)
+            host = parsed.netloc
+        active_marker = "*" if self.active else " "
+        title = self.title if self.title else "(untitled)"
+        host_segment = f" [{host}]" if host else ""
+        session_segment = f" {self.session_name}" if self.session_name else ""
+        return f"[{active_marker}{session_segment} w{self.window_index + 1} t{self.tab_index + 1}] {title}{host_segment}"
+
+
+@dataclass
+class ChromeWindow:
+    window_id: str
+    class_name: str
+    title: str
+
+
+def _run_capture(args: Sequence[str], stdin_text: Optional[str] = None) -> str:
+    proc = subprocess.run(
+        list(args),
+        input=stdin_text,
+        text=True,
+        capture_output=True,
+        check=False,
+    )
+    if proc.returncode != 0:
+        stderr = (proc.stderr or "").strip()
+        stdout = (proc.stdout or "").strip()
+        details = stderr or stdout or f"exit {proc.returncode}"
+        raise RuntimeError(f"command failed: {' '.join(args)}: {details}")
+    return proc.stdout
+
+
+def _run(args: Sequence[str]) -> None:
+    proc = subprocess.run(list(args), check=False)
+    if proc.returncode != 0:
+        raise RuntimeError(f"command failed: {' '.join(args)} (exit {proc.returncode})")
+
+
+def _as_bool(v: Any) -> bool:
+    if isinstance(v, bool):
+        return v
+    if isinstance(v, (int, float)):
+        return v != 0
+    if isinstance(v, str):
+        return v.lower() in {"1", "true", "yes", "on"}
+    return False
+
+
+def _windows_from_payload(payload: Any) -> List[Any]:
+    if isinstance(payload, list):
+        return payload
+    if isinstance(payload, dict):
+        windows = payload.get("windows")
+        if isinstance(windows, list):
+            return windows
+    raise ValueError("unsupported chrome-session-dump JSON shape")
+
+
+def _session_name_from_dir(path: str) -> str:
+    clean = path.rstrip("/")
+    if not clean:
+        return "session"
+    return os.path.basename(clean) or clean
+
+
+def _extract_user_data_dir(cmdline: str) -> Optional[str]:
+    pattern = r"--user-data-dir(?:=|\s+)(?:\"([^\"]+)\"|'([^']+)'|(\S+))"
+    match = re.search(pattern, cmdline)
+    if not match:
+        return None
+    for group in match.groups():
+        if group:
+            return os.path.expanduser(group)
+    return None
+
+
+def discover_active_session_dirs(ps_output: str, home_dir: str) -> List[str]:
+    dirs: List[str] = []
+    default_needed = False
+
+    for line in ps_output.splitlines():
+        line_lower = line.lower()
+        if "chrome-session-dump" in line_lower:
+            continue
+        if "chrome" not in line_lower and "chromium" not in line_lower:
+            continue
+
+        user_data_dir = _extract_user_data_dir(line)
+        if user_data_dir:
+            dirs.append(user_data_dir)
+        else:
+            default_needed = True
+
+    if default_needed:
+        for candidate in (
+            os.path.join(home_dir, ".config", "google-chrome"),
+            os.path.join(home_dir, ".config", "chromium"),
+            os.path.join(home_dir, ".config", "chrome"),
+        ):
+            if os.path.isdir(candidate):
+                dirs.append(candidate)
+
+    deduped: List[str] = []
+    seen = set()
+    for path in dirs:
+        normalized = os.path.normpath(path)
+        if normalized not in seen:
+            deduped.append(normalized)
+            seen.add(normalized)
+    return deduped
+
+
+def discover_active_session_dirs_from_system(ps_command: str) -> List[str]:
+    ps_output = _run_capture(shlex.split(ps_command))
+    return discover_active_session_dirs(ps_output, os.path.expanduser("~"))
+
+
+def parse_tabs_from_payload(
+    payload: Any,
+    *,
+    token_prefix: str = "W",
+    session_name: str = "",
+    window_offset: int = 0,
+) -> Tuple[List[TabEntry], int]:
+    windows = _windows_from_payload(payload)
+
+    entries: List[TabEntry] = []
+    for w_idx, window in enumerate(windows):
+        if not isinstance(window, dict):
+            continue
+
+        tabs = window.get("tabs", [])
+        if not isinstance(tabs, list):
+            continue
+
+        window_active_title = ""
+        active_tab_index = window.get("activeTabIndex")
+        if not isinstance(active_tab_index, int):
+            active_tab_index = window.get("selected")
+            if isinstance(active_tab_index, int):
+                # Some tools use 1-based selected index.
+                active_tab_index = max(active_tab_index - 1, 0)
+            else:
+                active_tab_index = None
+
+        for t_idx, tab in enumerate(tabs):
+            if not isinstance(tab, dict):
+                continue
+
+            title = str(tab.get("title") or tab.get("name") or "")
+            url = str(tab.get("url") or tab.get("uri") or "")
+            active = _as_bool(tab.get("active"))
+            if active_tab_index is not None and t_idx == active_tab_index:
+                active = True
+            if active and not window_active_title and title:
+                window_active_title = title
+
+        for t_idx, tab in enumerate(tabs):
+            if not isinstance(tab, dict):
+                continue
+
+            title = str(tab.get("title") or tab.get("name") or "")
+            url = str(tab.get("url") or tab.get("uri") or "")
+            active = _as_bool(tab.get("active"))
+            if active_tab_index is not None and t_idx == active_tab_index:
+                active = True
+
+            token = f"{token_prefix}{w_idx}T{t_idx}"
+            entries.append(
+                TabEntry(
+                    token=token,
+                    window_index=window_offset + w_idx,
+                    tab_index=t_idx,
+                    title=title,
+                    url=url,
+                    active=active,
+                    session_name=session_name,
+                    window_active_title=window_active_title,
+                )
+            )
+
+    return entries, len(windows)
+
+
+def parse_tabs(json_text: str) -> List[TabEntry]:
+    payload = json.loads(json_text)
+    entries, _ = parse_tabs_from_payload(payload)
+    return entries
+
+
+def load_entries(
+    *,
+    json_file: Optional[str],
+    read_stdin: bool,
+    single_session: bool,
+    session_command: str,
+    ps_command: str,
+) -> List[TabEntry]:
+    if json_file:
+        with open(json_file, "r", encoding="utf-8") as f:
+            return parse_tabs(f.read())
+    if read_stdin:
+        return parse_tabs(sys.stdin.read())
+
+    if single_session:
+        return parse_tabs(_run_capture(shlex.split(session_command)))
+
+    session_dirs = discover_active_session_dirs_from_system(ps_command)
+    if not session_dirs:
+        # Fall back to legacy behavior when process discovery cannot find active sessions.
+        return parse_tabs(_run_capture(shlex.split(session_command)))
+
+    all_entries: List[TabEntry] = []
+    window_offset = 0
+    errors: List[str] = []
+
+    for s_idx, session_dir in enumerate(session_dirs):
+        try:
+            json_text = _run_capture(["chrome-session-dump", "--json", session_dir])
+            payload = json.loads(json_text)
+            session_name = _session_name_from_dir(session_dir)
+            entries, window_count = parse_tabs_from_payload(
+                payload,
+                token_prefix=f"S{s_idx}W",
+                session_name=session_name,
+                window_offset=window_offset,
+            )
+            all_entries.extend(entries)
+            window_offset += window_count
+        except Exception as exc:
+            errors.append(f"{session_dir}: {exc}")
+
+    if all_entries:
+        return all_entries
+    if errors:
+        raise RuntimeError("; ".join(errors))
+    raise RuntimeError("no tabs found in active Chrome sessions")
+
+
+def parse_menu_selection(line: str) -> str:
+    selected = line.strip()
+    if not selected:
+        raise ValueError("empty selection")
+    token = selected.split("\t", 1)[0]
+    if not token:
+        raise ValueError("missing selection token")
+    return token
+
+
+def list_chrome_windows() -> List[ChromeWindow]:
+    out = _run_capture(["wmctrl", "-lx"])
+    windows: List[ChromeWindow] = []
+    for line in out.splitlines():
+        parts = line.split(None, 4)
+        if len(parts) < 3:
+            continue
+        class_name = parts[2].lower()
+        if "chrome" in class_name or "chromium" in class_name:
+            title = parts[4] if len(parts) >= 5 else ""
+            windows.append(ChromeWindow(window_id=parts[0], class_name=class_name, title=title))
+    return windows
+
+
+def _title_match_score(hint: str, title: str) -> int:
+    hint_norm = hint.strip().lower()
+    title_norm = title.strip().lower()
+    if not hint_norm or not title_norm:
+        return 0
+    if hint_norm == title_norm:
+        return 3
+    if hint_norm in title_norm:
+        return 2
+    if title_norm in hint_norm:
+        return 1
+    return 0
+
+
+def resolve_window_id(entry: TabEntry, windows: List[ChromeWindow]) -> str:
+    if not windows:
+        raise RuntimeError("no Chrome windows found")
+
+    best_idx = -1
+    best_score = 0
+    for idx, window in enumerate(windows):
+        score = _title_match_score(entry.window_active_title, window.title)
+        if entry.session_name and entry.session_name.lower() in window.title.lower():
+            score += 1
+        if score > best_score:
+            best_score = score
+            best_idx = idx
+
+    if best_idx >= 0 and best_score > 0:
+        return windows[best_idx].window_id
+
+    if entry.window_index >= len(windows):
+        raise RuntimeError(
+            f"chrome window index {entry.window_index} not available (found {len(windows)} chrome windows)"
+        )
+    return windows[entry.window_index].window_id
+
+
+def build_switch_commands(window_id: str, tab_index: int) -> List[List[str]]:
+    cmds: List[List[str]] = [["wmctrl", "-ia", window_id], ["xdotool", "key", "--clearmodifiers", "ctrl+1"]]
+    if tab_index > 0:
+        cmds.append(["xdotool", "key", "--clearmodifiers", "--repeat", str(tab_index), "ctrl+Tab"])
+    return cmds
+
+
+def switch_to_tab(entry: TabEntry, focus_delay_ms: int, dry_run: bool) -> None:
+    windows = list_chrome_windows()
+    window_id = resolve_window_id(entry, windows)
+    commands = build_switch_commands(window_id, entry.tab_index)
+
+    if dry_run:
+        for cmd in commands:
+            print("DRY-RUN:", " ".join(cmd))
+        return
+
+    _run(commands[0])
+    if focus_delay_ms > 0:
+        time.sleep(focus_delay_ms / 1000.0)
+    for cmd in commands[1:]:
+        _run(cmd)
+
+
+def rofi_select(entries: Iterable[TabEntry], rofi_command: str) -> str:
+    lines = "\n".join(entry.menu_line() for entry in entries)
+    out = _run_capture(shlex.split(rofi_command), stdin_text=lines)
+    return parse_menu_selection(out)
+
+
+def _entry_by_token(entries: List[TabEntry], token: str) -> TabEntry:
+    for entry in entries:
+        if entry.token == token:
+            return entry
+    raise RuntimeError(f"tab token not found: {token}")
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Switch Chrome tabs using chrome-session-dump JSON and rofi.")
+    parser.add_argument("--json-file", help="Read chrome-session-dump JSON from a file")
+    parser.add_argument("--stdin", action="store_true", help="Read chrome-session-dump JSON from stdin")
+    parser.add_argument(
+        "--session-command",
+        default="chrome-session-dump --json",
+        help="Command used to obtain session JSON in single-session mode",
+    )
+    parser.add_argument(
+        "--single-session",
+        action="store_true",
+        help="Disable active-session discovery and only read one session via --session-command",
+    )
+    parser.add_argument(
+        "--ps-command",
+        default="ps -eo args",
+        help="Process listing command used to discover active Chrome user-data directories",
+    )
+    parser.add_argument("--list-only", action="store_true", help="Print tab list and exit")
+    parser.add_argument("--select", help="Tab token to switch to")
+    parser.add_argument("--dry-run", action="store_true", help="Print switch commands without executing")
+    parser.add_argument("--focus-delay-ms", type=int, default=80, help="Delay after focusing window before tab keys")
+    parser.add_argument(
+        "--rofi-command",
+        default="rofi -dmenu -i -p ChromeTabs",
+        help="Rofi command used for interactive selection",
+    )
+
+    args = parser.parse_args()
+
+    try:
+        entries = load_entries(
+            json_file=args.json_file,
+            read_stdin=args.stdin,
+            single_session=args.single_session,
+            session_command=args.session_command,
+            ps_command=args.ps_command,
+        )
+
+        if not entries:
+            raise RuntimeError("no tabs found in session dump")
+
+        if args.list_only:
+            for entry in entries:
+                print(entry.menu_line())
+            return 0
+
+        token = args.select if args.select else rofi_select(entries, args.rofi_command)
+        entry = _entry_by_token(entries, token)
+        switch_to_tab(entry, args.focus_delay_ms, args.dry_run)
+        return 0
+    except Exception as exc:
+        print(f"error: {exc}", file=sys.stderr)
+        return 1
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 110 - 0
dezendorf/applications/chrome-tab-switcher/chrome-tab-switcher_test.py

@@ -0,0 +1,110 @@
+#!/usr/bin/env python3
+
+import importlib.util
+import json
+import pathlib
+import unittest
+
+
+_SCRIPT_PATH = pathlib.Path(__file__).with_name("chrome-tab-switcher.py")
+_spec = importlib.util.spec_from_file_location("chrome_tab_switcher", _SCRIPT_PATH)
+mod = importlib.util.module_from_spec(_spec)
+assert _spec and _spec.loader
+_spec.loader.exec_module(mod)
+
+
+class ChromeTabSwitcherTest(unittest.TestCase):
+    def test_parse_tabs_dict_payload(self) -> None:
+        payload = {
+            "windows": [
+                {
+                    "activeTabIndex": 1,
+                    "tabs": [
+                        {"title": "Home", "url": "https://example.com"},
+                        {"title": "Docs", "url": "https://docs.example.com"},
+                    ],
+                }
+            ]
+        }
+
+        entries = mod.parse_tabs(json.dumps(payload))
+        self.assertEqual(2, len(entries))
+        self.assertEqual("W0T0", entries[0].token)
+        self.assertFalse(entries[0].active)
+        self.assertEqual("W0T1", entries[1].token)
+        self.assertTrue(entries[1].active)
+        self.assertEqual("Docs", entries[0].window_active_title)
+
+    def test_parse_tabs_list_payload_with_selected(self) -> None:
+        payload = [
+            {
+                "selected": 1,
+                "tabs": [
+                    {"name": "First", "uri": "https://a.example"},
+                    {"name": "Second", "uri": "https://b.example"},
+                ],
+            }
+        ]
+
+        entries = mod.parse_tabs(json.dumps(payload))
+        self.assertEqual(2, len(entries))
+        self.assertTrue(entries[0].active)
+        self.assertFalse(entries[1].active)
+
+    def test_parse_menu_selection(self) -> None:
+        token = mod.parse_menu_selection("S1W3T8\t[session w4 t9] Example")
+        self.assertEqual("S1W3T8", token)
+
+    def test_build_switch_commands(self) -> None:
+        cmds = mod.build_switch_commands("0x01a00002", 3)
+        self.assertEqual(["wmctrl", "-ia", "0x01a00002"], cmds[0])
+        self.assertEqual(["xdotool", "key", "--clearmodifiers", "ctrl+1"], cmds[1])
+        self.assertEqual(["xdotool", "key", "--clearmodifiers", "--repeat", "3", "ctrl+Tab"], cmds[2])
+
+    def test_discover_active_session_dirs(self) -> None:
+        ps_output = "\n".join(
+            [
+                '/opt/google/chrome/chrome --type=browser --user-data-dir="/tmp/chrome-a"',
+                '/usr/bin/chromium --type=browser --user-data-dir=/tmp/chrome-b',
+                '/opt/google/chrome/chrome --type=gpu-process',
+            ]
+        )
+        dirs = mod.discover_active_session_dirs(ps_output, "/home/test")
+        self.assertEqual(["/tmp/chrome-a", "/tmp/chrome-b"], dirs)
+
+    def test_resolve_window_id_prefers_title_match(self) -> None:
+        entry = mod.TabEntry(
+            token="S0W1T2",
+            window_index=1,
+            tab_index=2,
+            title="Target Tab",
+            url="https://example.com",
+            active=False,
+            session_name="Profile 2",
+            window_active_title="Sprint Board",
+        )
+        windows = [
+            mod.ChromeWindow("0x100", "google-chrome.Google-chrome", "Inbox - Google Chrome"),
+            mod.ChromeWindow("0x200", "google-chrome.Google-chrome", "Sprint Board - Google Chrome"),
+        ]
+        self.assertEqual("0x200", mod.resolve_window_id(entry, windows))
+
+    def test_resolve_window_id_falls_back_to_index(self) -> None:
+        entry = mod.TabEntry(
+            token="S0W1T2",
+            window_index=1,
+            tab_index=2,
+            title="Target Tab",
+            url="https://example.com",
+            active=False,
+            window_active_title="",
+        )
+        windows = [
+            mod.ChromeWindow("0x100", "google-chrome.Google-chrome", "Inbox - Google Chrome"),
+            mod.ChromeWindow("0x200", "google-chrome.Google-chrome", "Sprint Board - Google Chrome"),
+        ]
+        self.assertEqual("0x200", mod.resolve_window_id(entry, windows))
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 6 - 1
dezendorf/applications/offset-fixer/BUILD

@@ -1,4 +1,4 @@
-load("@rules_python//python:defs.bzl", "py_binary")
+load("@rules_python//python:defs.bzl", "py_binary", "py_test")
 
 py_binary(
   name = "offset-fixer",
@@ -6,3 +6,8 @@ py_binary(
   visibility = ["//visibility:public"],
 )
 
+py_test(
+  name = "offset-fixer_test",
+  srcs = ["offset-fixer_test.py"],
+  data = ["offset-fixer.py"],
+)

+ 1 - 0
dezendorf/applications/offset-fixer/offset-fixer.py

@@ -4,6 +4,7 @@ import re
 from datetime import timedelta
 import argparse
 import locale
+import sys
 
 import math
 

+ 162 - 0
dezendorf/applications/offset-fixer/offset-fixer_test.py

@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+
+import contextlib
+import importlib.util
+import io
+import pathlib
+import tempfile
+import unittest
+from unittest import mock
+
+
+_SCRIPT_PATH = pathlib.Path(__file__).with_name("offset-fixer.py")
+_SPEC = importlib.util.spec_from_file_location("offset_fixer", _SCRIPT_PATH)
+mod = importlib.util.module_from_spec(_SPEC)
+assert _SPEC and _SPEC.loader
+_SPEC.loader.exec_module(mod)
+
+
+class OffsetFixerTest(unittest.TestCase):
+    def test_format_time_string(self) -> None:
+        entry = mod.SubtitleEntry(1, "hello\n", "01:02:03,250", "01:02:04,500")
+        self.assertEqual(
+            "01:02:03,250 --> 01:02:04,500",
+            entry.formatTimeString(),
+        )
+
+    def test_shift_updates_start_and_end(self) -> None:
+        entry = mod.SubtitleEntry(1, "hello\n", "00:00:10,000", "00:00:12,500")
+        entry.shift(1.25)
+        self.assertEqual(
+            "00:00:11,250 --> 00:00:13,750",
+            entry.formatTimeString(),
+        )
+
+    def test_main_writes_shifted_output(self) -> None:
+        srt = (
+            "1\n"
+            "00:00:01,000 --> 00:00:02,000\n"
+            "Hello\n"
+            "\n"
+            "2\n"
+            "00:00:03,500 --> 00:00:04,250\n"
+            "Bye\n"
+            "\n"
+        )
+        with tempfile.TemporaryDirectory() as td:
+            in_path = pathlib.Path(td) / "input.srt"
+            out_path = pathlib.Path(td) / "output.srt"
+            in_path.write_text(srt, encoding="utf-8")
+
+            argv = [
+                "offset-fixer.py",
+                "--seconds",
+                "1.5",
+                "--file",
+                str(in_path),
+                "--outfile",
+                str(out_path),
+            ]
+            with mock.patch("sys.argv", argv), contextlib.redirect_stdout(io.StringIO()):
+                mod.main()
+
+            output = out_path.read_text(encoding="utf-8")
+            self.assertIn("00:00:02,500 --> 00:00:03,500", output)
+            self.assertIn("00:00:05,000 --> 00:00:05,750", output)
+            self.assertIn("Hello", output)
+            self.assertIn("Bye", output)
+
+    def test_main_strip_first_and_last(self) -> None:
+        srt = (
+            "1\n"
+            "00:00:01,000 --> 00:00:02,000\n"
+            "First subtitle\n"
+            "\n"
+            "2\n"
+            "00:00:03,000 --> 00:00:04,000\n"
+            "Last subtitle\n"
+            "\n"
+        )
+        with tempfile.TemporaryDirectory() as td:
+            in_path = pathlib.Path(td) / "input.srt"
+            out_path = pathlib.Path(td) / "output.srt"
+            in_path.write_text(srt, encoding="utf-8")
+
+            argv = [
+                "offset-fixer.py",
+                "--seconds",
+                "0",
+                "--file",
+                str(in_path),
+                "--outfile",
+                str(out_path),
+                "--strip-first",
+                "--strip-last",
+            ]
+            with mock.patch("sys.argv", argv), contextlib.redirect_stdout(io.StringIO()):
+                mod.main()
+
+            output = out_path.read_text(encoding="utf-8")
+            self.assertNotIn("First subtitle", output)
+            self.assertNotIn("Last subtitle", output)
+            self.assertIn("00:00:01,000 --> 00:00:02,000", output)
+            self.assertIn("00:00:03,000 --> 00:00:04,000", output)
+
+    def test_main_inplace_creates_backup_and_updates_source(self) -> None:
+        srt = (
+            "1\n"
+            "00:00:01,000 --> 00:00:02,000\n"
+            "Keep me\n"
+            "\n"
+        )
+        with tempfile.TemporaryDirectory() as td:
+            in_path = pathlib.Path(td) / "input.srt"
+            in_path.write_text(srt, encoding="utf-8")
+
+            argv = [
+                "offset-fixer.py",
+                "--seconds",
+                "0.5",
+                "--file",
+                str(in_path),
+                "--inplace",
+            ]
+            with mock.patch("sys.argv", argv), contextlib.redirect_stdout(io.StringIO()):
+                mod.main()
+
+            backup_text = (pathlib.Path(str(in_path) + ".backup")).read_text(encoding="utf-8")
+            updated_text = in_path.read_text(encoding="utf-8")
+            self.assertIn("00:00:01,000 --> 00:00:02,000", backup_text)
+            self.assertIn("00:00:01,500 --> 00:00:02,500", updated_text)
+            self.assertIn("Keep me", updated_text)
+
+    def test_main_inplace_rejects_outfile(self) -> None:
+        srt = (
+            "1\n"
+            "00:00:01,000 --> 00:00:02,000\n"
+            "Hello\n"
+            "\n"
+        )
+        with tempfile.TemporaryDirectory() as td:
+            in_path = pathlib.Path(td) / "input.srt"
+            out_path = pathlib.Path(td) / "output.srt"
+            in_path.write_text(srt, encoding="utf-8")
+
+            argv = [
+                "offset-fixer.py",
+                "--seconds",
+                "1",
+                "--file",
+                str(in_path),
+                "--inplace",
+                "--outfile",
+                str(out_path),
+            ]
+            with mock.patch("sys.argv", argv), contextlib.redirect_stdout(io.StringIO()):
+                with self.assertRaises(SystemExit) as exc:
+                    mod.main()
+            self.assertEqual(1, exc.exception.code)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 27 - 0
dezendorf/applications/python_web_framework/BUILD

@@ -0,0 +1,27 @@
+load("@rules_python//python:defs.bzl", "py_binary", "py_library", "py_test")
+
+py_library(
+    name = "framework",
+    srcs = ["framework.py"],
+    data = [
+        "templates/base.html",
+        "templates/index.html",
+    ],
+    visibility = ["//visibility:public"],
+)
+
+py_binary(
+    name = "example_server",
+    srcs = ["example_server.py"],
+    deps = [":framework"],
+)
+
+py_test(
+    name = "framework_test",
+    srcs = ["framework_test.py"],
+    data = [
+        "templates/base.html",
+        "templates/index.html",
+    ],
+    deps = [":framework"],
+)

+ 9 - 0
dezendorf/applications/python_web_framework/example_server.py

@@ -0,0 +1,9 @@
+#!/usr/bin/env python3
+
+from dezendorf.applications.python_web_framework.framework import create_app
+
+app = create_app("python-web-framework", title="Python Web Framework")
+
+
+if __name__ == "__main__":
+    app.run(host="0.0.0.0", port=8080)

+ 40 - 0
dezendorf/applications/python_web_framework/framework.py

@@ -0,0 +1,40 @@
+#!/usr/bin/env python3
+
+from __future__ import annotations
+
+import time
+from pathlib import Path
+
+from flask import Flask, Response, render_template
+
+_START_TIME = time.time()
+
+
+def create_app(app_name: str, *, title: str | None = None) -> Flask:
+    """Create a Flask app with shared web defaults for this monorepo."""
+    template_dir = Path(__file__).with_name("templates")
+    app = Flask(app_name, template_folder=str(template_dir))
+    app.config["APP_TITLE"] = title or app_name
+    register_default_routes(app)
+    return app
+
+
+def register_default_routes(app: Flask) -> None:
+    @app.get("/")
+    def index() -> str:
+        return render_template("index.html", app_title=app.config["APP_TITLE"])
+
+    @app.get("/healthz")
+    def healthz() -> Response:
+        uptime_seconds = max(0.0, time.time() - _START_TIME)
+        lines = [
+            "# HELP app_health Application health status (1 = healthy).",
+            "# TYPE app_health gauge",
+            f'app_health{{service="{app.name}"}} 1',
+            "# HELP app_uptime_seconds Application uptime in seconds.",
+            "# TYPE app_uptime_seconds gauge",
+            f'app_uptime_seconds{{service="{app.name}"}} {uptime_seconds:.3f}',
+            "",
+        ]
+        body = "\n".join(lines)
+        return Response(body, status=200, headers={"Content-Type": "text/plain; version=0.0.4; charset=utf-8"})

+ 32 - 0
dezendorf/applications/python_web_framework/framework_test.py

@@ -0,0 +1,32 @@
+#!/usr/bin/env python3
+
+import unittest
+
+from dezendorf.applications.python_web_framework.framework import create_app
+
+
+class FrameworkTest(unittest.TestCase):
+    def setUp(self) -> None:
+        self.app = create_app("test-service", title="Test Service")
+        self.client = self.app.test_client()
+
+    def test_healthz_prometheus_output(self) -> None:
+        response = self.client.get("/healthz")
+        self.assertEqual(200, response.status_code)
+        self.assertIn("text/plain", response.headers["Content-Type"])
+        body = response.data.decode("utf-8")
+        self.assertIn("# HELP app_health", body)
+        self.assertIn("# TYPE app_health gauge", body)
+        self.assertIn('app_health{service="test-service"} 1', body)
+
+    def test_homepage_uses_responsive_template(self) -> None:
+        response = self.client.get("/")
+        self.assertEqual(200, response.status_code)
+        html = response.data.decode("utf-8")
+        self.assertIn('<meta name="viewport" content="width=device-width, initial-scale=1" />', html)
+        self.assertIn("@media (max-width: 900px)", html)
+        self.assertIn("Test Service", html)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 81 - 0
dezendorf/applications/python_web_framework/templates/base.html

@@ -0,0 +1,81 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <title>{{ app_title }}</title>
+    <style>
+      :root {
+        --bg: #f4f7f8;
+        --panel: #ffffff;
+        --ink: #102a43;
+        --accent: #00798c;
+        --accent-dark: #005f6b;
+      }
+      * {
+        box-sizing: border-box;
+      }
+      body {
+        margin: 0;
+        font-family: "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif;
+        color: var(--ink);
+        background: radial-gradient(circle at top right, #dff3ff, var(--bg));
+      }
+      .page {
+        max-width: 1100px;
+        margin: 0 auto;
+        padding: 2rem;
+      }
+      .hero {
+        display: grid;
+        grid-template-columns: 2fr 1fr;
+        gap: 1.5rem;
+        align-items: center;
+      }
+      .card {
+        background: var(--panel);
+        border-radius: 14px;
+        padding: 1.5rem;
+        box-shadow: 0 10px 30px rgba(16, 42, 67, 0.08);
+      }
+      .badge {
+        display: inline-block;
+        padding: 0.3rem 0.7rem;
+        border-radius: 999px;
+        background: #e0f2f1;
+        color: var(--accent-dark);
+        font-size: 0.8rem;
+        font-weight: 600;
+      }
+      h1 {
+        margin: 0.8rem 0;
+        line-height: 1.15;
+      }
+      p {
+        line-height: 1.6;
+      }
+      code {
+        background: #f0f4f8;
+        border-radius: 6px;
+        padding: 0.15rem 0.35rem;
+      }
+      .check {
+        margin-top: 1rem;
+        padding: 0.8rem 1rem;
+        border-left: 4px solid var(--accent);
+        background: #f7fbff;
+      }
+      @media (max-width: 900px) {
+        .page {
+          padding: 1rem;
+        }
+        .hero {
+          grid-template-columns: 1fr;
+        }
+      }
+    </style>
+  </head>
+  <body>
+    <main class="page">{% block content %}{% endblock %}</main>
+  </body>
+</html>

+ 22 - 0
dezendorf/applications/python_web_framework/templates/index.html

@@ -0,0 +1,22 @@
+{% extends "base.html" %}
+
+{% block content %}
+<section class="hero">
+  <article class="card">
+    <span class="badge">Reusable Framework</span>
+    <h1>{{ app_title }}</h1>
+    <p>
+      This shared Flask + Jinja framework provides a responsive baseline UI and
+      standard service endpoints for Python web apps in this monorepo.
+    </p>
+    <div class="check">
+      Health metrics are exposed at <code>/healthz</code> in Prometheus text format.
+    </div>
+  </article>
+  <aside class="card">
+    <h2>Defaults Included</h2>
+    <p>Responsive layout for phones, tablets, laptops, and desktops.</p>
+    <p>App factory pattern for straightforward app extension.</p>
+  </aside>
+</section>
+{% endblock %}