Initial AMPB scaffold

This commit is contained in:
jwinterm
2026-07-05 20:14:46 -04:00
commit ff287b8b4b
22 changed files with 896 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
/.ampb/
/docs/private/
__pycache__/
*.pyc
+57
View File
@@ -0,0 +1,57 @@
# Adaptive Multi-Protocol Browser
> Status: draft | Updated 2026-07-05 | Applies to: AMPB contributors and operators
Adaptive Multi-Protocol Browser, or AMPB, is a transport-aware browser shell. It routes
URLs to the right network, adopts transports that are already healthy, and manages missing
transports when configured to do so.
The first fixture is Wownero across clearnet, Tor, I2P, Gemini, and Reticulum-oriented
targets generated by AMPG. Interactive fixtures include `ai-gen-bot` and the Medusa
multi-tenant platform.
## Goals
- Route URLs by protocol and address family.
- Adopt existing Tor, I2P, Reticulum, and Gemini-capable tools when healthy.
- Prompt on first visit before installing or starting a missing transport.
- Manage approved transports through isolated AMPB state.
- Keep browser profiles isolated by transport.
- Present transport health in plain language.
## Non-goals
- Forking a browser engine before the orchestration model is proven.
- Mixing clearnet, Tor, I2P, and Reticulum cookies/storage in one profile.
- Publishing private transport state, keys, or host inventory in public docs.
## Local Commands
```sh
python3 -m ampbrowser route https://wownero.org/
python3 -m ampbrowser route http://example.b32.i2p/
python3 -m ampbrowser route gemini://wownero.org/
python3 -m ampbrowser inspect
python3 -m ampbrowser plan gemini://wownero.org/
python3 -m ampbrowser docs generate
python3 -m ampbrowser docs generate --check
python3 tools/docs_check.py
python3 -m unittest discover -s tests
```
## Documentation
- [Architecture](docs/architecture.md)
- [Configuration](docs/configuration.md)
- [Transport management](docs/transport-management.md)
- [Test fixtures](docs/test-fixtures.md)
- [Autodocs and drift gates](docs/autodocs.md)
## Reusable checklist
- [ ] Route URL to transport.
- [ ] Inspect whether transport can be adopted.
- [ ] Prompt before installing or starting a missing transport.
- [ ] Use isolated profile state for each transport.
- [ ] Spawn/manage only after a dry-run plan.
- [ ] Keep private notes under `docs/private/`.
+5
View File
@@ -0,0 +1,5 @@
"""Adaptive Multi-Protocol Browser."""
__all__ = ["__version__"]
__version__ = "0.1.0"
+5
View File
@@ -0,0 +1,5 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+101
View File
@@ -0,0 +1,101 @@
from __future__ import annotations
import argparse
from pathlib import Path
import sys
from .docsgen import generate_docs
from .plan import plan_url
from .routing import route_url
from .transports import inspect_transports
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="ampbrowser")
subcommands = parser.add_subparsers(dest="command", required=True)
route_parser = subcommands.add_parser("route", help="Route a URL to a transport.")
route_parser.add_argument("url")
plan_parser = subcommands.add_parser("plan", help="Plan how a URL would be opened.")
plan_parser.add_argument("url")
subcommands.add_parser("inspect", help="Inspect local transport readiness.")
docs_parser = subcommands.add_parser("docs", help="Generate or check generated docs.")
docs_subcommands = docs_parser.add_subparsers(dest="docs_command", required=True)
docs_generate = docs_subcommands.add_parser("generate", help="Generate docs from code.")
docs_generate.add_argument("--check", action="store_true")
args = parser.parse_args(argv)
try:
if args.command == "route":
return _cmd_route(args.url)
if args.command == "plan":
return _cmd_plan(args.url)
if args.command == "inspect":
return _cmd_inspect()
if args.command == "docs":
return _cmd_docs(args)
except Exception as exc: # noqa: BLE001
print(f"AMPBROWSER status=error message={exc}", file=sys.stderr)
return 1
return 1
def _cmd_route(url: str) -> int:
route = route_url(url)
print(
"AMPBROWSER_ROUTE "
f"url={route.normalized} "
f"transport={route.transport} "
f"profile={route.profile} "
f"reason=\"{route.reason}\""
)
return 0
def _cmd_plan(url: str) -> int:
plan = plan_url(url)
endpoint = plan.status.endpoint if plan.status else "-"
running = str(plan.status.running).lower() if plan.status else "false"
installed = str(plan.status.installed).lower() if plan.status else "false"
prompt = plan.prompt.replace('"', "'") if plan.prompt else "-"
print(
"AMPBROWSER_PLAN "
f"url={plan.route.normalized} "
f"transport={plan.route.transport} "
f"profile={plan.route.profile} "
f"installed={installed} "
f"running={running} "
f"endpoint={endpoint} "
f"requires_consent={str(plan.requires_consent).lower()} "
f"action=\"{plan.action}\" "
f"prompt=\"{prompt}\""
)
return 0
def _cmd_inspect() -> int:
for status in inspect_transports():
print(
"AMPBROWSER_INSPECT "
f"transport={status.transport} "
f"installed={str(status.installed).lower()} "
f"running={str(status.running).lower()} "
f"adoptable={str(status.adoptable).lower()} "
f"manage_supported={str(status.manage_supported).lower()} "
f"endpoint={status.endpoint} "
f"note=\"{status.note}\""
)
return 0
def _cmd_docs(args) -> int:
if args.docs_command == "generate":
changed = generate_docs(Path.cwd(), check=args.check)
changed_text = ",".join(str(path) for path in changed) if changed else "-"
mode = "check" if args.check else "write"
print(f"AMPBROWSER_DOCS status=ok mode={mode} changed={changed_text}")
return 0
return 1
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
from pathlib import Path
from .metadata import ROUTE_RULES, TRANSPORT_DEFINITIONS
GENERATED_DIR = Path("docs/generated")
def generate_docs(root: Path, *, check: bool = False) -> list[Path]:
docs = {
GENERATED_DIR / "route-rules.md": _route_rules_doc(),
GENERATED_DIR / "transports.md": _transports_doc(),
}
changed: list[Path] = []
for rel_path, content in docs.items():
path = root / rel_path
if path.exists() and path.read_text(encoding="utf-8") == content:
continue
changed.append(rel_path)
if not check:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
if check and changed:
names = ", ".join(str(path) for path in changed)
raise RuntimeError(f"generated docs are stale: {names}")
return changed
def _header(title: str) -> str:
return (
f"# {title}\n\n"
"> Status: generated | Updated by `python3 -m ampbrowser docs generate` | Applies to: AMPB\n\n"
"This file is generated from code. Do not edit it by hand.\n\n"
)
def _route_rules_doc() -> str:
rows = [
"| Match | Transport | Profile | Note |",
"| --- | --- | --- | --- |",
]
for rule in ROUTE_RULES:
rows.append(f"| `{rule.match}` | `{rule.transport}` | `{rule.profile}` | {rule.note} |")
return _header("Generated Route Rules") + "\n".join(rows) + "\n"
def _transports_doc() -> str:
rows = [
"| Transport | Adopt Check | Managed State | Profile | Note |",
"| --- | --- | --- | --- | --- |",
]
for transport in TRANSPORT_DEFINITIONS:
rows.append(
f"| `{transport.name}` | {transport.adopt_check} | `{transport.managed_state}` | "
f"`{transport.profile}` | {transport.note} |"
)
return _header("Generated Transports") + "\n".join(rows) + "\n"
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class RouteRule:
match: str
transport: str
profile: str
note: str
@dataclass(frozen=True)
class TransportDefinition:
name: str
adopt_check: str
managed_state: str
profile: str
note: str
ROUTE_RULES = (
RouteRule("*.onion", "tor", "tor", "route through Tor SOCKS"),
RouteRule("*.i2p", "i2p", "i2p", "route through I2P HTTP proxy"),
RouteRule("gemini://*", "gemini", "gemini", "fetch and render Gemtext"),
RouteRule("rns://*, lxmf://*, nomad://*", "reticulum", "reticulum", "route through Reticulum adapter"),
RouteRule("http://*, https://*", "clearnet", "clearnet", "ordinary web profile"),
)
TRANSPORT_DEFINITIONS = (
TransportDefinition(
"tor",
"SOCKS on 127.0.0.1:9050",
".ampb/transports/tor",
"tor",
"Adopt existing Tor when healthy; otherwise prompt before managed setup.",
),
TransportDefinition(
"i2p",
"HTTP proxy on 127.0.0.1:4444",
".ampb/transports/i2p",
"i2p",
"Adopt existing i2pd/I2P router when healthy; otherwise prompt before managed setup.",
),
TransportDefinition(
"gemini",
"built-in renderer available",
".ampb/transports/gemini",
"gemini",
"No daemon required for static Gemtext browsing.",
),
TransportDefinition(
"reticulum",
"RNS tools and configured interfaces",
".ampb/transports/reticulum",
"reticulum",
"Adapter planned; physical/link-layer setup may require operator config.",
),
)
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from dataclasses import dataclass
from .routing import Route, route_url
from .transports import TransportStatus, inspect_transport
@dataclass(frozen=True)
class BrowsePlan:
route: Route
status: TransportStatus | None
action: str
requires_consent: bool = False
prompt: str = ""
def plan_url(raw_url: str) -> BrowsePlan:
route = route_url(raw_url)
status = inspect_transport(route.transport)
if route.transport in {"clearnet", "unknown"}:
return BrowsePlan(route=route, status=status, action="open with clearnet profile")
if status and status.adoptable:
return BrowsePlan(route=route, status=status, action="adopt existing transport")
if status and status.manage_supported:
action = "prompt to start managed transport"
prompt = (
f"{route.transport} is not running. AMPB can start a managed {route.transport} "
f"transport for {route.normalized}."
)
if not status.installed:
action = "prompt to install and start managed transport"
prompt = (
f"{route.transport} is not installed or running. AMPB can install and start "
f"a managed {route.transport} transport for {route.normalized}."
)
return BrowsePlan(
route=route,
status=status,
action=action,
requires_consent=True,
prompt=prompt,
)
return BrowsePlan(route=route, status=status, action="blocked until adapter is configured")
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from dataclasses import dataclass
from urllib.parse import urlparse
@dataclass(frozen=True)
class Route:
original: str
normalized: str
transport: str
profile: str
reason: str
def route_url(raw_url: str) -> Route:
normalized = _normalize_url(raw_url)
parsed = urlparse(normalized)
scheme = parsed.scheme.lower()
host = (parsed.hostname or "").lower()
if scheme == "gemini":
return Route(raw_url, normalized, "gemini", "gemini", "gemini scheme")
if scheme in {"rns", "lxmf", "nomad"}:
return Route(raw_url, normalized, "reticulum", "reticulum", f"{scheme} scheme")
if host.endswith(".onion"):
return Route(raw_url, normalized, "tor", "tor", ".onion host")
if host.endswith(".i2p"):
return Route(raw_url, normalized, "i2p", "i2p", ".i2p host")
if scheme in {"http", "https"}:
return Route(raw_url, normalized, "clearnet", "clearnet", "http(s) URL")
return Route(raw_url, normalized, "unknown", "unknown", "no route rule matched")
def _normalize_url(raw_url: str) -> str:
value = raw_url.strip()
if "://" not in value and not value.startswith(("rns:", "lxmf:")):
value = "https://" + value
return value
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
from dataclasses import dataclass
import shutil
import socket
@dataclass(frozen=True)
class TransportStatus:
transport: str
installed: bool
running: bool
endpoint: str
adoptable: bool
manage_supported: bool
note: str
TRANSPORTS = ("tor", "i2p", "reticulum", "gemini")
def inspect_transports() -> list[TransportStatus]:
return [
_inspect_tor(),
_inspect_i2p(),
_inspect_reticulum(),
_inspect_gemini(),
]
def inspect_transport(name: str) -> TransportStatus | None:
for status in inspect_transports():
if status.transport == name:
return status
return None
def _inspect_tor() -> TransportStatus:
installed = shutil.which("tor") is not None
running = _can_connect("127.0.0.1", 9050)
return TransportStatus(
transport="tor",
installed=installed,
running=running,
endpoint="socks5://127.0.0.1:9050",
adoptable=running,
manage_supported=True,
note="Tor SOCKS proxy",
)
def _inspect_i2p() -> TransportStatus:
installed = shutil.which("i2pd") is not None or shutil.which("i2prouter") is not None
running = _can_connect("127.0.0.1", 4444)
return TransportStatus(
transport="i2p",
installed=installed,
running=running,
endpoint="http://127.0.0.1:4444",
adoptable=running,
manage_supported=True,
note="I2P HTTP proxy",
)
def _inspect_reticulum() -> TransportStatus:
installed = any(shutil.which(command) for command in ("rnsd", "rnstatus", "rnodeconf"))
return TransportStatus(
transport="reticulum",
installed=installed,
running=False,
endpoint="rns://local",
adoptable=False,
manage_supported=False,
note="Reticulum adapter is planned; interface readiness needs explicit config",
)
def _inspect_gemini() -> TransportStatus:
return TransportStatus(
transport="gemini",
installed=True,
running=True,
endpoint="builtin://gemtext-renderer",
adoptable=True,
manage_supported=True,
note="Built-in Gemtext fetch/render path",
)
def _can_connect(host: str, port: int) -> bool:
try:
with socket.create_connection((host, port), timeout=0.2):
return True
except OSError:
return False
+41
View File
@@ -0,0 +1,41 @@
# Architecture
> Status: draft | Updated 2026-07-05 | Applies to: AMPB contributors and integrators
AMPB is a browser orchestration layer for privacy and alternate-network transports. It
keeps the routing, transport management, and profile isolation decisions outside the
browser engine until those contracts are stable.
## Operating Model
1. Normalize the requested URL.
2. Select a transport from scheme and host rules.
3. Inspect whether the required local transport is already running.
4. Adopt healthy existing transports.
5. If the selected transport is missing, show a first-use prompt before setup.
6. Start or install managed transports only after user consent.
7. Open the URL with the matching isolated profile.
## Main Components
- `ampbrowser.routing`: deterministic URL-to-transport rules.
- `ampbrowser.transports`: local readiness checks for Tor, I2P, Reticulum, and Gemini.
- `ampbrowser.plan`: dry-run action planner for browser launches.
- `ampbrowser.docsgen`: generated public documentation from code-owned metadata.
## Browser Strategy
The first implementation should be a launcher and profile manager, not a browser-engine
fork. Tor Browser, Mullvad Browser, Firefox profiles, Chromium profiles, terminal Gemini
clients, and future Reticulum-native viewers can all sit behind the same route and
transport contracts.
The long-term browser can become more integrated once the orchestration surface is boring:
profile isolation is reliable, transport adoption is predictable, and generated fixtures
from AMPG can be opened without manual proxy setup.
## Relationship To AMPG
AMPG publishes protocol-specific site variants. AMPB opens those variants with the right
transport and profile. Wownero is the shared fixture for static publishing, while
interactive targets exercise sessions, dynamic assets, forms, and script policy.
+39
View File
@@ -0,0 +1,39 @@
# Autodocs
> Status: draft | Updated 2026-07-05 | Applies to: AMPB maintainers
Public docs should stay concise and current. Code-owned behavior is documented from code,
while manual docs explain stable concepts and operator-facing choices.
## Generated Docs
Run:
```sh
python3 -m ampbrowser docs generate
```
Check for drift:
```sh
python3 -m ampbrowser docs generate --check
```
Generated files live under `docs/generated/` and must not be edited by hand.
## Manual Docs
Manual public docs must include a status header near the top and should avoid strategy
notes, checkpointing, timelines, private addresses, local hostnames, credentials, and
deployment inventory.
Private notes belong in `docs/private/`. That directory is ignored and should remain
absent from commits.
## Link And Header Check
Run:
```sh
python3 tools/docs_check.py
```
+49
View File
@@ -0,0 +1,49 @@
# Configuration
> Status: draft | Updated 2026-07-05 | Applies to: AMPB users and package maintainers
AMPB should work without a config file for common local setups. Explicit configuration is
for changing defaults, pinning binary paths, choosing managed state directories, and
restricting which transports are allowed.
Transport setup is lazy. AMPB does not install, start, or configure a transport until the
user opens a URL that needs it and approves the first-use prompt.
## Default Behavior
- Clearnet URLs use a clearnet profile.
- `.onion` hosts use Tor through `127.0.0.1:9050` when available.
- `.i2p` hosts use I2P through `127.0.0.1:4444` when available.
- `gemini://` URLs use the Gemini profile.
- Reticulum-family URLs are routed to the Reticulum adapter once configured.
## Future Config Shape
```toml
[browser]
default_engine = "system"
state_dir = ".ampb"
[transports.tor]
enabled = true
mode = "adopt-or-manage"
socks_endpoint = "127.0.0.1:9050"
[transports.i2p]
enabled = true
mode = "adopt-or-manage"
http_proxy = "127.0.0.1:4444"
[transports.reticulum]
enabled = false
mode = "adopt"
[profiles]
isolate_by_transport = true
```
## Public And Private Files
Public docs describe behavior, configuration, and supported fixtures. Private strategy,
deployment notes, host inventory, generated keys, and local address books belong under
`docs/private/`, which is ignored by git.
+13
View File
@@ -0,0 +1,13 @@
# Generated Route Rules
> Status: generated | Updated by `python3 -m ampbrowser docs generate` | Applies to: AMPB
This file is generated from code. Do not edit it by hand.
| Match | Transport | Profile | Note |
| --- | --- | --- | --- |
| `*.onion` | `tor` | `tor` | route through Tor SOCKS |
| `*.i2p` | `i2p` | `i2p` | route through I2P HTTP proxy |
| `gemini://*` | `gemini` | `gemini` | fetch and render Gemtext |
| `rns://*, lxmf://*, nomad://*` | `reticulum` | `reticulum` | route through Reticulum adapter |
| `http://*, https://*` | `clearnet` | `clearnet` | ordinary web profile |
+12
View File
@@ -0,0 +1,12 @@
# Generated Transports
> Status: generated | Updated by `python3 -m ampbrowser docs generate` | Applies to: AMPB
This file is generated from code. Do not edit it by hand.
| Transport | Adopt Check | Managed State | Profile | Note |
| --- | --- | --- | --- | --- |
| `tor` | SOCKS on 127.0.0.1:9050 | `.ampb/transports/tor` | `tor` | Adopt existing Tor when healthy; otherwise prompt before managed setup. |
| `i2p` | HTTP proxy on 127.0.0.1:4444 | `.ampb/transports/i2p` | `i2p` | Adopt existing i2pd/I2P router when healthy; otherwise prompt before managed setup. |
| `gemini` | built-in renderer available | `.ampb/transports/gemini` | `gemini` | No daemon required for static Gemtext browsing. |
| `reticulum` | RNS tools and configured interfaces | `.ampb/transports/reticulum` | `reticulum` | Adapter planned; physical/link-layer setup may require operator config. |
+25
View File
@@ -0,0 +1,25 @@
# Test Fixtures
> Status: draft | Updated 2026-07-05 | Applies to: AMPB and AMPG compatibility testing
Fixtures should cover static sites, protocol-specific publishing, and interactive web
apps without exposing private deployment notes.
## Primary Fixture
Wownero is the first shared fixture between AMPG and AMPB. It exercises ordinary
clearnet pages, Tor and I2P HTML variants, Gemini output, static assets, and semantic
HTML improvements that improve alternate renderers.
## Interactive Fixtures
`ai-gen-bot` and `medusa-multi-tenant-platform` are stretch fixtures for dynamic behavior:
sessions, forms, asset policy, script policy, tenant routing, and transport-specific
degradation.
## Fixture Requirements
- Every fixture has a public description of what behavior it exercises.
- Private addresses, tunnel keys, host inventory, and credentials stay out of public docs.
- Browser checks prefer dry-run plans before live launch.
- Static output from AMPG should be openable by AMPB without manual proxy setup.
+34
View File
@@ -0,0 +1,34 @@
# Transport Management
> Status: draft | Updated 2026-07-05 | Applies to: AMPB transport adapters
Transport management follows one rule: use what is already healthy, and manage missing
transports only after the user opens a URL that needs them and approves setup.
## Lifecycle
1. Inspect known local endpoints.
2. Adopt running transports that match the selected URL.
3. For missing transports, show a prompt that names what will be installed or started.
4. Resolve a configured binary and state directory only after consent.
5. Start the daemon with AMPB-owned state.
6. Wait for the health endpoint.
7. Launch the browser profile only after the transport is ready.
8. Stop only AMPB-owned daemons.
## Ownership
AMPB never stops a daemon it did not start. Adopted transports remain owned by the user,
system service, or package manager that launched them.
Managed transports store runtime state under `.ampb/transports/<name>` by default. Keys,
leases, tunnels, and daemon logs in that tree are local state and must not be committed.
## Current Adapters
- Tor: detect SOCKS on `127.0.0.1:9050`; managed start planned.
- I2P: detect HTTP proxy on `127.0.0.1:4444`; managed start planned.
- Gemini: built-in route and render path; no daemon required for static browsing.
- Reticulum: route contract exists; adapter and readiness model are planned.
Generated transport metadata lives in [generated transports](generated/transports.md).
+12
View File
@@ -0,0 +1,12 @@
[project]
name = "adaptive-multi-protocol-browser"
version = "0.1.0"
description = "Transport-aware browser orchestration shell."
requires-python = ">=3.11"
license = "Apache-2.0"
[project.scripts]
ampbrowser = "ampbrowser.cli:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from ampbrowser.docsgen import generate_docs
class DocsGenTest(unittest.TestCase):
def test_generate_docs_writes_and_then_checks_clean(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
changed = generate_docs(root)
self.assertEqual(
{Path("docs/generated/route-rules.md"), Path("docs/generated/transports.md")},
set(changed),
)
self.assertEqual([], generate_docs(root, check=True))
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from ampbrowser.plan import plan_url
from ampbrowser.transports import TransportStatus
class PlanUrlTest(unittest.TestCase):
def test_gemini_uses_builtin_transport(self) -> None:
plan = plan_url("gemini://wownero.org/")
self.assertEqual("gemini", plan.route.transport)
self.assertEqual("adopt existing transport", plan.action)
self.assertIsNotNone(plan.status)
self.assertTrue(plan.status.adoptable)
def test_clearnet_opens_with_clearnet_profile(self) -> None:
plan = plan_url("wownero.org")
self.assertEqual("https://wownero.org", plan.route.normalized)
self.assertEqual("clearnet", plan.route.transport)
self.assertEqual("open with clearnet profile", plan.action)
self.assertFalse(plan.requires_consent)
def test_missing_transport_prompts_before_install_or_start(self) -> None:
status = TransportStatus(
transport="i2p",
installed=False,
running=False,
endpoint="http://127.0.0.1:4444",
adoptable=False,
manage_supported=True,
note="I2P HTTP proxy",
)
with patch("ampbrowser.plan.inspect_transport", return_value=status):
plan = plan_url("http://example.b32.i2p/")
self.assertEqual("prompt to install and start managed transport", plan.action)
self.assertTrue(plan.requires_consent)
self.assertIn("install and start", plan.prompt)
def test_stopped_transport_prompts_before_start(self) -> None:
status = TransportStatus(
transport="tor",
installed=True,
running=False,
endpoint="socks5://127.0.0.1:9050",
adoptable=False,
manage_supported=True,
note="Tor SOCKS proxy",
)
with patch("ampbrowser.plan.inspect_transport", return_value=status):
plan = plan_url("http://example.onion/")
self.assertEqual("prompt to start managed transport", plan.action)
self.assertTrue(plan.requires_consent)
self.assertIn("can start", plan.prompt)
if __name__ == "__main__":
unittest.main()
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
import unittest
from ampbrowser.routing import route_url
class RouteUrlTest(unittest.TestCase):
def test_defaults_plain_host_to_https_clearnet(self) -> None:
route = route_url("wownero.org")
self.assertEqual("https://wownero.org", route.normalized)
self.assertEqual("clearnet", route.transport)
self.assertEqual("clearnet", route.profile)
def test_routes_onion_hosts_to_tor(self) -> None:
route = route_url("http://example.onion/")
self.assertEqual("tor", route.transport)
self.assertEqual("tor", route.profile)
def test_routes_i2p_hosts_to_i2p(self) -> None:
route = route_url("http://example.b32.i2p/")
self.assertEqual("i2p", route.transport)
self.assertEqual("i2p", route.profile)
def test_routes_gemini_scheme(self) -> None:
route = route_url("gemini://wownero.org/")
self.assertEqual("gemini", route.transport)
self.assertEqual("gemini", route.profile)
def test_routes_reticulum_family_scheme(self) -> None:
route = route_url("rns://wownero")
self.assertEqual("reticulum", route.transport)
self.assertEqual("reticulum", route.profile)
if __name__ == "__main__":
unittest.main()
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
IGNORED_PARTS = {".git", "__pycache__", "generated", "private"}
LINK_RE = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
def main() -> int:
failures: list[str] = []
for path in _markdown_files():
text = path.read_text(encoding="utf-8")
rel = path.relative_to(ROOT)
if not _has_status_header(text):
failures.append(f"{rel}: missing status header in first six lines")
failures.extend(_broken_links(path, text))
if failures:
for failure in failures:
print(failure, file=sys.stderr)
return 1
print("AMPBROWSER_DOCS_CHECK status=ok")
return 0
def _markdown_files() -> list[Path]:
paths: list[Path] = []
for path in ROOT.rglob("*.md"):
rel_parts = set(path.relative_to(ROOT).parts)
if rel_parts & IGNORED_PARTS:
continue
paths.append(path)
return sorted(paths)
def _has_status_header(text: str) -> bool:
return any(line.startswith("> Status:") for line in text.splitlines()[:6])
def _broken_links(path: Path, text: str) -> list[str]:
failures: list[str] = []
for match in LINK_RE.finditer(text):
target = match.group(1)
if _is_external_or_anchor(target):
continue
target_path = (path.parent / target.split("#", 1)[0]).resolve()
try:
target_path.relative_to(ROOT)
except ValueError:
failures.append(f"{path.relative_to(ROOT)}: link escapes repo: {target}")
continue
if not target_path.exists():
failures.append(f"{path.relative_to(ROOT)}: broken link: {target}")
return failures
def _is_external_or_anchor(target: str) -> bool:
return (
target.startswith("#")
or "://" in target
or target.startswith("mailto:")
or target.startswith("tel:")
)
if __name__ == "__main__":
raise SystemExit(main())