Skip to content

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 →