proxy-scraper-cli 1.7.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- proxy_scraper_cli-1.7.1.dist-info/METADATA +794 -0
- proxy_scraper_cli-1.7.1.dist-info/RECORD +57 -0
- proxy_scraper_cli-1.7.1.dist-info/WHEEL +5 -0
- proxy_scraper_cli-1.7.1.dist-info/entry_points.txt +4 -0
- proxy_scraper_cli-1.7.1.dist-info/licenses/LICENSE +21 -0
- proxy_scraper_cli-1.7.1.dist-info/top_level.txt +1 -0
- proxyscraper/__init__.py +12 -0
- proxyscraper/__main__.py +7 -0
- proxyscraper/agent.py +635 -0
- proxyscraper/api.py +139 -0
- proxyscraper/app.py +595 -0
- proxyscraper/asndb.py +181 -0
- proxyscraper/blocklist.py +92 -0
- proxyscraper/checker.py +514 -0
- proxyscraper/cli.py +269 -0
- proxyscraper/compat.py +83 -0
- proxyscraper/completion.py +196 -0
- proxyscraper/exporters.py +118 -0
- proxyscraper/fetchcache.py +103 -0
- proxyscraper/geo.py +150 -0
- proxyscraper/geodb.py +143 -0
- proxyscraper/handshake.py +153 -0
- proxyscraper/history.py +106 -0
- proxyscraper/judges.py +159 -0
- proxyscraper/mcp_entry.py +34 -0
- proxyscraper/mcp_server.py +220 -0
- proxyscraper/netio.py +167 -0
- proxyscraper/options.py +274 -0
- proxyscraper/output.py +181 -0
- proxyscraper/pages.py +212 -0
- proxyscraper/parsing.py +192 -0
- proxyscraper/paths.py +55 -0
- proxyscraper/pipeline.py +434 -0
- proxyscraper/preferences.py +24 -0
- proxyscraper/publish.py +236 -0
- proxyscraper/server/__init__.py +41 -0
- proxyscraper/server/core.py +518 -0
- proxyscraper/server/http.py +164 -0
- proxyscraper/server/pool.py +195 -0
- proxyscraper/server/socks.py +65 -0
- proxyscraper/server/status.py +108 -0
- proxyscraper/server/upstream.py +119 -0
- proxyscraper/site/apple-touch-icon.png +0 -0
- proxyscraper/site/googleaac1161b7853c5b5.html +1 -0
- proxyscraper/site/index.html +934 -0
- proxyscraper/site/logo.png +0 -0
- proxyscraper/site/og.png +0 -0
- proxyscraper/sources.json +395 -0
- proxyscraper/sources.py +491 -0
- proxyscraper/targets.py +76 -0
- proxyscraper/ui/__init__.py +54 -0
- proxyscraper/ui/dashboard.py +344 -0
- proxyscraper/ui/keys.py +82 -0
- proxyscraper/ui/report.py +209 -0
- proxyscraper/ui/serve.py +142 -0
- proxyscraper/ui/widgets.py +250 -0
- proxyscraper/ui/wizard.py +595 -0
proxyscraper/ui/serve.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Live view of the rotating proxy server (--serve)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from rich import box
|
|
9
|
+
from rich.align import Align
|
|
10
|
+
from rich.console import Group
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from ..server import RotatingServer
|
|
16
|
+
from . import widgets
|
|
17
|
+
from .widgets import (
|
|
18
|
+
ACCENT,
|
|
19
|
+
BAD,
|
|
20
|
+
GOOD,
|
|
21
|
+
MUTED,
|
|
22
|
+
TYPE_STYLE,
|
|
23
|
+
WARN,
|
|
24
|
+
card,
|
|
25
|
+
fmt,
|
|
26
|
+
fmt_duration,
|
|
27
|
+
pct,
|
|
28
|
+
row,
|
|
29
|
+
shown_proxy,
|
|
30
|
+
table,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _mb(n: int) -> str:
|
|
35
|
+
return f"{n / 2**20:.1f} MB"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ServeDashboard:
|
|
39
|
+
def __init__(self, server: RotatingServer):
|
|
40
|
+
self.server = server
|
|
41
|
+
|
|
42
|
+
def __rich__(self):
|
|
43
|
+
server, st, pool = self.server, self.server.stats, self.server.pool
|
|
44
|
+
# if it listens on all addresses (Docker), it can still be reached locally via 127.0.0.1
|
|
45
|
+
shown_host = {"0.0.0.0": "127.0.0.1", "": "127.0.0.1", "::": "[::1]"}.get(server.host, server.host)
|
|
46
|
+
if ":" in shown_host and not shown_host.startswith("["):
|
|
47
|
+
shown_host = f"[{shown_host}]" # IPv6 in URLs only in brackets
|
|
48
|
+
address = f"{shown_host}:{server.port}"
|
|
49
|
+
width = widgets.console.size.width
|
|
50
|
+
uptime = max(time.perf_counter() - st.started, 1e-6)
|
|
51
|
+
usable = pool.usable
|
|
52
|
+
|
|
53
|
+
title = Table.grid(expand=True)
|
|
54
|
+
title.add_column()
|
|
55
|
+
title.add_column(justify="right")
|
|
56
|
+
title.add_row(
|
|
57
|
+
Text.assemble(("● ", f"bold {GOOD}"), ("Proxy server running on ", "bold"), (address, f"bold {ACCENT}")),
|
|
58
|
+
Text(f"⏱ {fmt_duration(uptime)}", style=MUTED),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
usage = Table.grid(padding=(0, 2))
|
|
62
|
+
usage.add_column(style=MUTED, no_wrap=True)
|
|
63
|
+
usage.add_column(overflow="fold")
|
|
64
|
+
# with a password every example needs the login; the password itself is never shown
|
|
65
|
+
from_env = server.password and os.environ.get("PROXY_SCRAPER_SERVE_PASSWORD") == server.password
|
|
66
|
+
pw = "$PROXY_SCRAPER_SERVE_PASSWORD" if from_env else "PASSWORD" if server.password else "x"
|
|
67
|
+
login = f"any:{pw}@" if server.password else ""
|
|
68
|
+
usage.add_row("Test", Text(f"curl -x http://{login}{address} https://api.ipify.org", style="bold"))
|
|
69
|
+
usage.add_row("SOCKS5", Text(f"curl -x socks5h://{login}{address} https://api.ipify.org"))
|
|
70
|
+
usage.add_row("One country", Text(f"curl -x http://country-de:{pw}@{address} https://api.ipify.org"))
|
|
71
|
+
usage.add_row("Fixed session", Text(f"curl -x http://session-abc:{pw}@{address} … (keeps the same proxy)"))
|
|
72
|
+
usage.add_row("Terminal", Text(f"export http_proxy=http://{login}{address} https_proxy=http://{login}{address}"))
|
|
73
|
+
usage.add_row("Status (JSON)", Text(f"curl {'-u any:' + pw + ' ' if server.password else ''}"
|
|
74
|
+
f"http://{address}/__proxy-scraper/status"))
|
|
75
|
+
usage.add_row("Prometheus", Text(f"http://{'any:' + pw + '@' if server.password else ''}"
|
|
76
|
+
f"{address}/__proxy-scraper/metrics"))
|
|
77
|
+
mode = pool.strategy + (f" · sticky {pool.sticky_seconds:g} s" if pool.sticky_seconds else "")
|
|
78
|
+
if server.revived:
|
|
79
|
+
mode += f" · {fmt(server.revived)} brought back"
|
|
80
|
+
if server.last_refill:
|
|
81
|
+
ago = fmt_duration(time.time() - server.last_refill)
|
|
82
|
+
mode += f" · {fmt(server.refilled)} added by refills (last {ago} ago)"
|
|
83
|
+
usage.add_row("Rotation", Text(mode, style=MUTED))
|
|
84
|
+
|
|
85
|
+
cards = row(
|
|
86
|
+
card("Requests", fmt(st.requests), f"{st.requests / uptime * 60:.1f} per minute"),
|
|
87
|
+
card("Successful", pct(st.ok, st.requests) if st.requests else "–",
|
|
88
|
+
f"{fmt(st.failed)} failed", f"bold {GOOD}" if not st.failed else f"bold {WARN}"),
|
|
89
|
+
card("Active", fmt(st.active), "open connections", f"bold {ACCENT}"),
|
|
90
|
+
card("Pool", f"{fmt(len(usable))} / {fmt(len(pool.entries))}",
|
|
91
|
+
f"{fmt(len(pool.tls_capable))} for HTTPS · {fmt(len(pool.entries) - len(usable))} out"),
|
|
92
|
+
card("Traffic", _mb(st.bytes_down), f"↑ {_mb(st.bytes_up)}"),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
recent = table()
|
|
96
|
+
recent.add_column("", width=1)
|
|
97
|
+
recent.add_column("Target", ratio=2, no_wrap=True, overflow="ellipsis")
|
|
98
|
+
recent.add_column("via proxy", ratio=2, no_wrap=True, overflow="ellipsis")
|
|
99
|
+
if width >= 100:
|
|
100
|
+
recent.add_column("Client", style=MUTED, no_wrap=True)
|
|
101
|
+
recent.add_column("Attempts", justify="right", width=8)
|
|
102
|
+
recent.add_column("Time", justify="right", width=8)
|
|
103
|
+
for log in reversed(st.recent):
|
|
104
|
+
ptype = log.via.split("://", 1)[0]
|
|
105
|
+
cells = [
|
|
106
|
+
Text("✔", style=GOOD) if log.ok else Text("✘", style=BAD),
|
|
107
|
+
log.target,
|
|
108
|
+
Text(shown_via(log.via), style=TYPE_STYLE.get(ptype, MUTED)),
|
|
109
|
+
]
|
|
110
|
+
if width >= 100:
|
|
111
|
+
cells.append(log.client)
|
|
112
|
+
cells += [str(log.attempts), f"{fmt(log.ms)} ms"]
|
|
113
|
+
recent.add_row(*cells)
|
|
114
|
+
|
|
115
|
+
busiest = table()
|
|
116
|
+
busiest.add_column("Proxy", no_wrap=True, overflow="ellipsis")
|
|
117
|
+
busiest.add_column("OK", justify="right", style=GOOD)
|
|
118
|
+
busiest.add_column("Errors", justify="right", style=MUTED)
|
|
119
|
+
busiest.add_column("Latency", justify="right")
|
|
120
|
+
for entry in sorted(pool.entries, key=lambda e: (-e.ok, e.result.latency))[:5]:
|
|
121
|
+
r = entry.result
|
|
122
|
+
style = MUTED if entry.disabled else TYPE_STYLE.get(r.ptype, "")
|
|
123
|
+
name = Text(f"{r.ptype}://{shown_proxy(r.proxy)}", style=style)
|
|
124
|
+
busiest.add_row(name, fmt(entry.ok), fmt(entry.fail), f"{fmt(r.latency)} ms")
|
|
125
|
+
|
|
126
|
+
waiting = Align.center(Text("waiting for the first connection …", style=MUTED))
|
|
127
|
+
return Group(
|
|
128
|
+
Panel(Group(title, Text(""), usage), box=box.HEAVY, border_style=ACCENT, padding=(0, 1)),
|
|
129
|
+
cards,
|
|
130
|
+
row(
|
|
131
|
+
widgets.panel(recent if st.recent else waiting, "Recent connections", GOOD),
|
|
132
|
+
widgets.panel(busiest, "Most used proxies"),
|
|
133
|
+
ratios=(3, 2),
|
|
134
|
+
),
|
|
135
|
+
Text(" Ctrl+C stops the server", style=MUTED),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def shown_via(via: str) -> str:
|
|
140
|
+
"""'socks5://user:pass@1.2.3.4:1080' -> password masked; '–' (no proxy) stays."""
|
|
141
|
+
scheme, sep, proxy = via.partition("://")
|
|
142
|
+
return f"{scheme}://{shown_proxy(proxy)}" if sep else via
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Colors, formatting and reusable building blocks for the terminal UI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Optional, Sequence
|
|
8
|
+
from urllib.parse import unquote
|
|
9
|
+
|
|
10
|
+
from rich import box
|
|
11
|
+
from rich.align import Align
|
|
12
|
+
from rich.console import Console, Group
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.progress import ProgressColumn
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
from .. import __version__
|
|
19
|
+
from ..geo import flag
|
|
20
|
+
|
|
21
|
+
console = Console(highlight=False)
|
|
22
|
+
|
|
23
|
+
# One palette for everything (the lime is the brand color of the logo and the website). rich
|
|
24
|
+
# downsamples it automatically on terminals with fewer colors.
|
|
25
|
+
ACCENT = "#D4F77A"
|
|
26
|
+
GOOD = "#34D399"
|
|
27
|
+
WARN = "#FBBF24"
|
|
28
|
+
BAD = "#F87171"
|
|
29
|
+
MUTED = "#8B949E"
|
|
30
|
+
BORDER = "#30363D"
|
|
31
|
+
TYPE_STYLE = {"http": "#60A5FA", "socks4": "#C084FC", "socks5": "#34D399"}
|
|
32
|
+
ANON_STYLE = {"elite": ("E", GOOD), "anonymous": ("A", WARN), "transparent": ("T", BAD)}
|
|
33
|
+
ANON_LABEL = {"elite": "Elite", "anonymous": "Anonymous", "transparent": "Transp."}
|
|
34
|
+
PHASES = ("Sources", "Collect", "Check", "Done")
|
|
35
|
+
SPARK = "▁▂▃▄▅▆▇█"
|
|
36
|
+
LATENCY_EDGES = (300, 700, 1500, 3000, 6000)
|
|
37
|
+
LATENCY_LABELS = ("< 0.3 s", "< 0.7 s", "< 1.5 s", "< 3 s", "< 6 s", "≥ 6 s")
|
|
38
|
+
# below a 0.2 % hit rate the network is probably blocking proxy connections (firewall)
|
|
39
|
+
BLOCKED_HIT_RATE = 0.002
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --------------------------------------------------------------------------- #
|
|
43
|
+
# Formatting
|
|
44
|
+
# --------------------------------------------------------------------------- #
|
|
45
|
+
|
|
46
|
+
def fmt(n: float) -> str:
|
|
47
|
+
"""Thousands separators: 12345 -> 12,345"""
|
|
48
|
+
return f"{n:,.0f}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def pct(part: float, whole: float) -> str:
|
|
52
|
+
return f"{part / whole * 100:.1f}%" if whole else "–"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def fmt_duration(seconds: float) -> str:
|
|
56
|
+
seconds = int(seconds)
|
|
57
|
+
if seconds < 60:
|
|
58
|
+
return f"{seconds} s"
|
|
59
|
+
m, s = divmod(seconds, 60)
|
|
60
|
+
if m < 60:
|
|
61
|
+
return f"{m} min {s:02d} s"
|
|
62
|
+
h, m = divmod(m, 60)
|
|
63
|
+
return f"{h} h {m:02d} min"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def latency_style(ms: int) -> str:
|
|
67
|
+
return GOOD if ms < 1000 else WARN if ms < 3000 else BAD
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def bar(value: float, maximum: float, width: int, style: str) -> Text:
|
|
71
|
+
"""Bar with eighth steps for fine resolution in little space."""
|
|
72
|
+
width = max(width, 1)
|
|
73
|
+
filled = 0 if maximum <= 0 else min(value / maximum, 1.0) * width
|
|
74
|
+
full = int(filled)
|
|
75
|
+
rest = int((filled - full) * 8)
|
|
76
|
+
text = Text("█" * full, style=style)
|
|
77
|
+
if rest and full < width:
|
|
78
|
+
text.append(" ▏▎▍▌▋▊▉"[rest], style=style)
|
|
79
|
+
full += 1
|
|
80
|
+
text.append("·" * (width - full), style=MUTED)
|
|
81
|
+
return text
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def sparkline(values: Sequence[float], width: int) -> Text:
|
|
85
|
+
values = list(values)[-width:]
|
|
86
|
+
if not values:
|
|
87
|
+
return Text("")
|
|
88
|
+
hi = max(values) or 1
|
|
89
|
+
return Text("".join(SPARK[min(int(v / hi * (len(SPARK) - 1) + 0.5), len(SPARK) - 1)] for v in values), style=ACCENT)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def type_badge(ptype: str) -> Text:
|
|
93
|
+
return Text(ptype, style=f"bold {TYPE_STYLE.get(ptype, 'white')}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def country_cell(cc: str) -> Text:
|
|
97
|
+
return Text(f"{flag(cc)} {cc}" if cc else " ··", style="" if cc else MUTED)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def https_cell(value: Optional[bool]) -> Text:
|
|
101
|
+
if value is None:
|
|
102
|
+
return Text("·", style=MUTED)
|
|
103
|
+
return Text("✔", style=GOOD) if value else Text("✘", style=BAD)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def anon_cell(level: str) -> Text:
|
|
107
|
+
letter, style = ANON_STYLE.get(level, ("·", MUTED))
|
|
108
|
+
return Text(letter, style=f"bold {style}" if level else style)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def short_url(url: str) -> str:
|
|
112
|
+
gh = "https://raw.githubusercontent.com/"
|
|
113
|
+
if url.startswith(gh):
|
|
114
|
+
return "gh:" + url[len(gh):]
|
|
115
|
+
return url.split("://", 1)[-1]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class CountColumn(ProgressColumn):
|
|
119
|
+
"""12,345 / 1,000,000 instead of 12345/1000000."""
|
|
120
|
+
|
|
121
|
+
def render(self, task) -> Text:
|
|
122
|
+
return Text.assemble((fmt(task.completed), "bold"), (f" / {fmt(task.total or 0)}", MUTED))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --------------------------------------------------------------------------- #
|
|
126
|
+
# Building blocks
|
|
127
|
+
# --------------------------------------------------------------------------- #
|
|
128
|
+
|
|
129
|
+
def banner() -> Panel:
|
|
130
|
+
grid = Table.grid(expand=True)
|
|
131
|
+
grid.add_column()
|
|
132
|
+
grid.add_column(justify="right")
|
|
133
|
+
grid.add_row(
|
|
134
|
+
Text.assemble(("◆ proxy-scraper", f"bold {ACCENT}"), (f" v{__version__}", MUTED)),
|
|
135
|
+
Text(datetime.now().strftime("%Y-%m-%d %H:%M"), style=MUTED),
|
|
136
|
+
)
|
|
137
|
+
grid.add_row(Text("Free proxies that actually work – collected, checked, learned.", style=MUTED), "")
|
|
138
|
+
return Panel(grid, box=box.ROUNDED, border_style=ACCENT, padding=(0, 2))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def phase_bar(current: int) -> Text:
|
|
142
|
+
text = Text()
|
|
143
|
+
for i, name in enumerate(PHASES):
|
|
144
|
+
if i:
|
|
145
|
+
text.append(" ─ ", style=GOOD if i <= current else BORDER)
|
|
146
|
+
if i < current:
|
|
147
|
+
text.append(f"✔ {name}", style=GOOD)
|
|
148
|
+
elif i == current:
|
|
149
|
+
text.append(f"● {i + 1} {name}", style=f"bold {ACCENT}")
|
|
150
|
+
else:
|
|
151
|
+
text.append(f"○ {i + 1} {name}", style=MUTED)
|
|
152
|
+
return text
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def header(current: int, started: float) -> Table:
|
|
156
|
+
grid = Table.grid(expand=True, padding=(0, 1))
|
|
157
|
+
grid.add_column()
|
|
158
|
+
grid.add_column(justify="right")
|
|
159
|
+
grid.add_row(phase_bar(current), Text(f"⏱ {fmt_duration(time.perf_counter() - started)}", style=MUTED))
|
|
160
|
+
return grid
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
_section_open = False
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def section(title: str) -> None:
|
|
167
|
+
"""Starts a section; the following info()/note() lines hang off its left border line."""
|
|
168
|
+
global _section_open
|
|
169
|
+
if _section_open:
|
|
170
|
+
section_end()
|
|
171
|
+
line = Text()
|
|
172
|
+
line.append(" ╭─ ", style=BORDER)
|
|
173
|
+
line.append(title, style=f"bold {ACCENT}")
|
|
174
|
+
line.append(" ", style=BORDER)
|
|
175
|
+
line.append("─" * max(console.size.width - line.cell_len - 2, 4), style=BORDER)
|
|
176
|
+
console.print(line)
|
|
177
|
+
_section_open = True
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def section_end() -> None:
|
|
181
|
+
global _section_open
|
|
182
|
+
if _section_open:
|
|
183
|
+
console.print(Text(" ╰─", style=BORDER))
|
|
184
|
+
_section_open = False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _gutter() -> Text:
|
|
188
|
+
# color only for the border character – not as the base style, which would bleed into the whole line
|
|
189
|
+
gutter = Text()
|
|
190
|
+
gutter.append(" │ " if _section_open else " ", style=BORDER)
|
|
191
|
+
return gutter
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def info(label: str, value, style: str = "") -> None:
|
|
195
|
+
"""Uniform info line – with a border line inside a section, indented otherwise."""
|
|
196
|
+
line = _gutter()
|
|
197
|
+
line.append(f"{label:<14}", style=MUTED)
|
|
198
|
+
line.append_text(value if isinstance(value, Text) else Text(str(value), style=style or "bold"))
|
|
199
|
+
console.print(line)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def note(message: str, style: str = WARN, icon: str = "⚠") -> None:
|
|
203
|
+
console.print(_gutter() + Text(f"{icon} ", style=style) + Text.from_markup(message))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def card(label: str, value: str, sub, style: str = "bold") -> Panel:
|
|
207
|
+
# all cards in a row should stay the same height – long subtitles are shortened instead of wrapped
|
|
208
|
+
sub = sub if isinstance(sub, Text) else Text(sub, style=MUTED)
|
|
209
|
+
sub.no_wrap, sub.overflow = True, "ellipsis"
|
|
210
|
+
body = Group(Text(label.upper(), style=f"bold {MUTED}"), Text(value, style=style), sub)
|
|
211
|
+
return Panel(body, box=box.ROUNDED, border_style=BORDER, padding=(0, 1))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def table(**kwargs) -> Table:
|
|
215
|
+
"""Uniform, airy table without border lines (the frame comes from the panel)."""
|
|
216
|
+
kwargs.setdefault("expand", True)
|
|
217
|
+
return Table(box=None, header_style=f"bold {MUTED}", pad_edge=False, **kwargs)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def panel_title(title: str, style: str = BORDER) -> Text:
|
|
221
|
+
"""Keep titles readable: with a subtle frame, muted-light instead of the dark frame color."""
|
|
222
|
+
return Text(f" {title} ", style=f"bold {MUTED if style == BORDER else style}")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def panel(renderable, title: str, style: str = BORDER, **kwargs) -> Panel:
|
|
226
|
+
return Panel(renderable, title=panel_title(title, style), title_align="left", box=box.ROUNDED,
|
|
227
|
+
border_style=style, **kwargs)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def row(*renderables, ratios: Optional[Sequence[int]] = None) -> Table:
|
|
231
|
+
grid = Table.grid(expand=True)
|
|
232
|
+
for i, _ in enumerate(renderables):
|
|
233
|
+
grid.add_column(ratio=(ratios[i] if ratios else 1))
|
|
234
|
+
grid.add_row(*renderables)
|
|
235
|
+
return grid
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def centered(text: str, style: str = MUTED) -> Align:
|
|
239
|
+
return Align.center(Text(text, style=style))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def shown_proxy(proxy: str) -> str:
|
|
243
|
+
"""Proxy for the terminal: passwords are masked ('alice:•••@1.2.3.4:1080'), the files keep them."""
|
|
244
|
+
auth, sep, address = proxy.rpartition("@")
|
|
245
|
+
if not sep:
|
|
246
|
+
return proxy
|
|
247
|
+
user = unquote(auth.partition(":")[0])
|
|
248
|
+
# user names come from third-party lists – never let control characters (newline, ESC) into the terminal
|
|
249
|
+
user = "".join(c if c.isprintable() else "?" for c in user)
|
|
250
|
+
return f"{user}:•••@{address}"
|