URL Inspector — Python source
Break any URL into its components - protocol, host, port, path, query params, hash, and credentials. Detects default ports and security at a glance, with a decode toggle for query values. Runs entirely in your browser.
This is the Python implementation — the same logic the interactive tool runs, in a shareable, citable form.
"""
url-inspector — Python polyglot showcase port.
Wraps urllib.parse in a pure, never-raising function and exposes a flat,
serialisable report of every URL component — including the signals the URL
API hides (credentials, default-vs-explicit ports, root-only/fragment-only
URLs).
Display source — part of CosmoDev's polyglot tool pages.
Ported from src/lib/url-inspector.ts (the canonical TypeScript lib).
urllib.parse is more permissive than the WHATWG URL standard (it rarely
raises), so this port re-imposes WHATWG invariants by hand: a scheme is
required, special schemes require a host, the host is lowercased, a default
port (https:443) is stripped from the serialised host, and an empty path on a
special scheme is rendered as "/".
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import List, Optional
from urllib.parse import unquote, urlsplit
# Well-known default ports per scheme, keyed with the trailing colon to match
# the WHATWG `protocol` form. urllib does not surface these, so we keep the
# table to flag an explicitly-written port that equals the scheme default.
DEFAULT_PORTS = {
"http:": "80",
"https:": "443",
"ftp:": "21",
"ws:": "80",
"wss:": "443",
}
# WHATWG "special" schemes get host normalisation and an empty path -> "/".
_SPECIAL_SCHEMES = {"http", "https", "ws", "wss", "ftp", "file"}
# Schemes that yield a non-opaque origin (others serialise origin as "null").
_ORIGIN_SCHEMES = {"http", "https", "ws", "wss", "ftp"}
# scheme://rest — DOTALL so "rest" may span newlines, mirroring the TS /s flag.
_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*)://(.*)$", re.DOTALL)
@dataclass
class UrlParam:
"""A single decoded query parameter."""
key: str
value: str
@dataclass
class UrlReport:
"""Structured inspection result; optionals mirror the TypeScript surface."""
valid: bool
warnings: List[str]
protocol: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
host: Optional[str] = None
hostname: Optional[str] = None
port: Optional[str] = None
pathname: Optional[str] = None
search: Optional[str] = None
hash: Optional[str] = None
searchParams: List[UrlParam] = field(default_factory=list)
origin: Optional[str] = None
isSecure: Optional[bool] = None
defaultPort: Optional[bool] = None
def decode_param(v: str) -> str:
"""Percent-decode a query value, treating '+' as a space; never raises.
`unquote` (like decodeURIComponent) does NOT convert '+', so we swap that
first by hand. Malformed percent-encoding falls back to the original.
"""
try:
return unquote(v.replace("+", " "))
except Exception:
return v
def _parse_query(raw_query: str) -> List[UrlParam]:
"""Decode a raw query string into ordered key/value pairs, preserving
duplicates (parse_qsl would do, but parsing by hand keeps it dependency-free
and explicit about the '+' handling)."""
if not raw_query:
return []
params: List[UrlParam] = []
for pair in raw_query.split("&"):
if pair == "":
continue # skip empty pairs produced by "a=1&&b=2"
if "=" in pair:
key, value = pair.split("=", 1)
else:
key, value = pair, ""
params.append(UrlParam(key=decode_param(key), value=decode_param(value)))
return params
def _raw_port(trimmed: str) -> Optional[str]:
"""Read an explicitly-written port straight from the raw input.
urllib normalises default ports away, so we re-parse the authority to
recover them. Handles userinfo (`user:pass@`) and IPv6 literals
(`[::1]:8080`). Returns None when no numeric port is present.
"""
match = _SCHEME_RE.match(trimmed)
if not match:
return None
rest = match.group(2)
# The authority runs until the first path/query/fragment delimiter.
end_idx = None
for idx, ch in enumerate(rest):
if ch in "/?#":
end_idx = idx
break
authority = rest if end_idx is None else rest[:end_idx]
# Drop userinfo: everything up to the LAST '@' belongs to credentials.
at_idx = authority.rfind("@")
hostport = authority if at_idx == -1 else authority[at_idx + 1:]
port_candidate: Optional[str] = None
if hostport.startswith("["):
# IPv6 literal — the port (if any) lives after the closing bracket.
close = hostport.find("]")
if close == -1:
return None # unterminated bracket
tail = hostport[close + 1:]
if tail.startswith(":"):
port_candidate = tail[1:]
else:
colon = hostport.find(":")
if colon != -1:
port_candidate = hostport[colon + 1:]
if port_candidate is None:
return None
return port_candidate if port_candidate.isdigit() else None
def _safe_getter(getter):
"""Wrap a SplitResult accessor (.username/.password/.port) that raises
ValueError on malformed netlocs so the inspector never propagates errors."""
def wrapper(parts):
try:
return getter(parts)
except ValueError:
return None
return wrapper
@_safe_getter
def _username(parts):
return parts.username
@_safe_getter
def _password(parts):
return parts.password
@_safe_getter
def _port(parts):
return parts.port
def inspect_url(raw):
"""Parse and decompose a URL into a structured report; never raises."""
warnings: List[str] = []
trimmed = (raw or "").strip() if raw is not None else ""
if not trimmed:
return UrlReport(valid=False, warnings=["URL is empty"])
parts = urlsplit(trimmed)
scheme = parts.scheme.lower() # WHATWG lowercases the scheme
# Re-impose WHATWG validity: scheme required, and special schemes need a host.
if not scheme or (scheme in _SPECIAL_SCHEMES and not parts.hostname):
return UrlReport(
valid=False,
warnings=["Invalid URL — could not be parsed (include the scheme, e.g. https://)"],
)
proto = scheme + ":" # WHATWG url.protocol carries the trailing colon
# Query parameters — decode in insertion order, duplicates preserved.
raw_query = parts.query
search_params = _parse_query(raw_query)
# Credentials (guarded — SplitResult.username can raise on bad IPv6).
username = _username(parts)
password = _password(parts)
if username:
warnings.append("URL contains a username credential")
if password:
warnings.append("URL contains a password credential")
# Hostname: urllib strips IPv6 brackets; WHATWG keeps them. Lowercase to
# match WHATWG ASCII host canonicalisation.
hostname = (parts.hostname or "").lower()
if ":" in hostname:
hostname = "[" + hostname + "]"
# Host (hostname:port) — but WHATWG drops a port equal to the scheme default.
parser_port = _port(parts)
port_str = str(parser_port) if parser_port is not None else ""
host = hostname
if port_str:
expected = DEFAULT_PORTS.get(proto)
if expected is None or port_str != expected:
host = hostname + ":" + port_str
# Pathname — special schemes serialise an empty path as "/".
pathname = parts.path
if pathname == "" and scheme in _SPECIAL_SCHEMES:
pathname = "/"
if pathname == "/" and raw_query == "" and not search_params:
warnings.append("URL points to the site root (no path or query)")
# Recover the explicit port and flag it if it's the scheme default.
explicit_port = _raw_port(trimmed)
is_default_port: Optional[bool] = None
if explicit_port is not None:
expected = DEFAULT_PORTS.get(proto)
is_default_port = expected is not None and explicit_port == expected
if is_default_port:
warnings.append(f"Port {explicit_port} is the default for {proto}")
is_secure = proto in ("https:", "wss:")
# Origin — only special schemes with a host yield a non-opaque origin.
origin = None
if scheme in _ORIGIN_SCHEMES and parts.hostname:
origin = f"{scheme}://{host}"
return UrlReport(
valid=True,
warnings=warnings,
protocol=proto,
username=username or None,
password=password or None,
host=host or None,
hostname=hostname or None,
port=explicit_port,
pathname=pathname,
search=("?" + raw_query) if raw_query else None,
hash=("#" + parts.fragment) if parts.fragment else None,
searchParams=search_params,
origin=origin,
isSecure=is_secure,
defaultPort=is_default_port,
)
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →