File size: 3,842 Bytes
1177cc0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
"""
Local-run helpers (same file in vectoriser and internScraper): load the env files, pick a free port, say clearly what is running.

    load_env(Path(__file__).parent)                       # at the top of the entrypoint, before anything reads os.environ
    port = pick_port("internScraper", 5000, "ifind and vectoriser (INTERNSCRAPER_URL)")
    banner("internScraper", port, {"Talks to vectoriser": os.environ.get("VECTORISER_URL")})

Fixed local ports (deliberately off the usual 3000 / 8000 / 8080): ifind 4000, internScraper 5000, vectoriser 6000.
Only used by `python app.py`; the Docker images start uvicorn themselves on $PORT and never touch this.
"""

import os
import socket
import sys
from pathlib import Path
from typing import Dict, Optional

# the banner goes to the real stdout, not through the print-to-logger capture some services install
OUT = sys.__stdout__ or sys.stdout
RED = "\x1b[1;97;41m"   # bold white on red
BOLD = "\x1b[1m"
DIM = "\x1b[2m"
GREEN = "\x1b[1;32m"
OFF = "\x1b[0m"

if os.name == "nt":
    os.system("")   # switches the Windows console to ANSI escape handling


def load_env(base: Path) -> None:
    """`.env` first (production values; variables already set in the process win), then `.env.local` over it for local runs.
    `.env.local` is gitignored and `.dockerignore`d, so a deployed image never sees it."""
    try:
        from dotenv import load_dotenv
    except ImportError:
        return
    load_dotenv(base / ".env")
    local = base / ".env.local"
    if local.exists():
        load_dotenv(local, override=True)


def port_in_use(port: int) -> bool:
    """True when something already listens on `port` (checked on localhost and on all interfaces)."""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
        probe.settimeout(0.4)
        if probe.connect_ex(("127.0.0.1", port)) == 0:
            return True
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
        try:
            probe.bind(("0.0.0.0", port))
        except OSError:
            return True
    return False


def pick_port(name: str, preferred: int, dependents: str = "the other components") -> int:
    """`preferred` when it is free; otherwise the next free port, with a big red warning on the console."""
    port = preferred
    while port_in_use(port):
        port += 1
    if port != preferred:
        width = 78
        lines = [
            "",
            f"  WARNING: PORT {preferred} IS ALREADY IN USE",
            f"  {name} will start on port {port} instead.",
            f"  You may need to reconfigure {dependents}",
            f"  so they point to http://localhost:{port}",
            "",
        ]
        print("", file=sys.stderr)
        for line in lines:
            print(f"{RED}{line.ljust(width)}{OFF}", file=sys.stderr)
        print("", file=sys.stderr, flush=True)
    return port


def banner(name: str, port: int, extra: Optional[Dict[str, Optional[str]]] = None) -> None:
    """What is about to run and where (printed before uvicorn starts, so it is the first thing you see)."""
    base = f"http://localhost:{port}"
    rows = [("API", base), ("Docs", f"{base}/docs"), ("Health", f"{base}/health")]
    rows += [(k, v or "(not set)") for k, v in (extra or {}).items()]
    try:
        "─".encode(getattr(OUT, "encoding", None) or "ascii")
        rule = "─"
    except (UnicodeEncodeError, LookupError):
        rule = "-"   # redirected output on a legacy Windows code page
    bar = rule * 66
    lines = [f"\n{DIM}{bar}{OFF}", f"  {BOLD}{name}{OFF}  starting on {GREEN}port {port}{OFF}"]
    lines += [f"  {key.ljust(24)} {value}" for key, value in rows]
    lines.append(f"{DIM}{bar}{OFF}\n")
    OUT.write("\n".join(lines) + "\n")   # not print(): services that capture print() would wrap it in log lines
    OUT.flush()