Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions linehaul/ua/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,30 @@ def UvUserAgent(user_agent):
raise UnableToParse from None


@_parser.register
@ua_parser
def HatchUserAgent(user_agent):
# We're only concerned about Hatch user agents.
if not user_agent.startswith("Hatch/"):
raise UnableToParse

# Hatch's User-Agent format is: Hatch/{version} {json} HTTPX/{version}
# JSON string values may include spaces, so we locate the JSON payload
# using the first opening and last closing brace.
json_start = user_agent.find("{")
if json_start == -1:
raise UnableToParse

json_end = user_agent.rfind("}")
if json_end == -1:
raise UnableToParse

try:
return json.loads(user_agent[json_start : json_end + 1])
except (json.JSONDecodeError, UnicodeDecodeError):
raise UnableToParse from None


# TODO: We should probably consider not parsing this specially, and moving it to
# just the same as we treat browsers, since we don't really know anything
# about it-- including whether or not the version of Python mentioned is
Expand Down
90 changes: 90 additions & 0 deletions tests/unit/ua/fixtures/hatch.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Hatch format: Hatch/{version} {json} HTTPX/{httpx_version}

# Linux (Ubuntu, CI, full distro with libc)
- ua: 'Hatch/1.15.0 {"ci":true,"cpu":"x86_64","distro":{"id":"jammy","libc":{"lib":"glibc","version":"2.35"},"name":"Ubuntu","version":"22.04"},"implementation":{"name":"CPython","version":"3.12.0"},"installer":{"name":"hatch","version":"1.15.0"},"openssl_version":"OpenSSL 3.0.2 15 Mar 2022","python":"3.12.0","system":{"name":"Linux","release":"6.5.0-1016-azure"}} HTTPX/0.27.0'
result:
installer:
name: hatch
version: '1.15.0'
python: 3.12.0
implementation:
name: CPython
version: 3.12.0
distro:
name: Ubuntu
version: 22.04
id: jammy
libc:
lib: glibc
version: 2.35
system:
name: Linux
release: 6.5.0-1016-azure
cpu: x86_64
openssl_version: OpenSSL 3.0.2 15 Mar 2022
ci: true

# macOS (no CI)
- ua: 'Hatch/1.15.0 {"ci":null,"cpu":"arm64","distro":{"name":"macOS","version":"14.4"},"implementation":{"name":"CPython","version":"3.12.2"},"installer":{"name":"hatch","version":"1.15.0"},"openssl_version":"LibreSSL 3.3.6","python":"3.12.2","system":{"name":"Darwin","release":"23.2.0"}} HTTPX/0.27.0'
result:
installer:
name: hatch
version: '1.15.0'
python: 3.12.2
implementation:
name: CPython
version: 3.12.2
distro:
name: macOS
version: 14.4
system:
name: Darwin
release: 23.2.0
cpu: arm64
openssl_version: LibreSSL 3.3.6

# Windows (CI, no distro)
- ua: 'Hatch/1.15.0 {"ci":true,"cpu":"AMD64","implementation":{"name":"CPython","version":"3.12.0"},"installer":{"name":"hatch","version":"1.15.0"},"python":"3.12.0","system":{"name":"Windows","release":"2022Server"}} HTTPX/0.27.0'
result:
installer:
name: hatch
version: '1.15.0'
python: 3.12.0
implementation:
name: CPython
version: 3.12.0
system:
name: Windows
release: 2022Server
cpu: AMD64
ci: true

# PyPy runtime
- ua: 'Hatch/1.15.0 {"ci":null,"cpu":"x86_64","implementation":{"name":"PyPy","version":"7.3.15"},"installer":{"name":"hatch","version":"1.15.0"},"python":"3.10.14","system":{"name":"Linux","release":"6.1.0"}} HTTPX/0.27.0'
result:
installer:
name: hatch
version: '1.15.0'
python: 3.10.14
implementation:
name: PyPy
version: 7.3.15
system:
name: Linux
release: 6.1.0
cpu: x86_64

# Minimal (no distro, no openssl)
- ua: 'Hatch/1.15.0 {"ci":null,"cpu":"x86_64","implementation":{"name":"CPython","version":"3.12.0"},"installer":{"name":"hatch","version":"1.15.0"},"python":"3.12.0","system":{"name":"Linux","release":"6.1.0"}} HTTPX/0.27.0'
result:
installer:
name: hatch
version: '1.15.0'
python: 3.12.0
implementation:
name: CPython
version: 3.12.0
system:
name: Linux
release: 6.1.0
cpu: x86_64
38 changes: 38 additions & 0 deletions tests/unit/ua/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,44 @@ def test_invalid_json(self, json_blob):
with pytest.raises(parser.UnableToParse):
parser.UvUserAgent(f"uv/0.1.22 {json_blob}")


class TestHatchUserAgent:
@given(st.text().filter(lambda i: not i.startswith("Hatch/")))
def test_not_hatch(self, ua):
with pytest.raises(parser.UnableToParse):
parser.HatchUserAgent(ua)

def test_no_json(self):
with pytest.raises(parser.UnableToParse):
parser.HatchUserAgent("Hatch/1.15.0")

@given(st.text(max_size=100).filter(lambda i: not _is_valid_json(i)))
def test_invalid_json(self, json_blob):
with pytest.raises(parser.UnableToParse):
parser.HatchUserAgent(f"Hatch/1.15.0 {json_blob} HTTPX/0.27.0")

def test_valid_with_trailing_httpx(self):
ua = (
"Hatch/1.15.0 "
'{"installer":{"name":"hatch","version":"1.15.0"},'
'"openssl_version":"OpenSSL 3.0.2 15 Mar 2022","python":"3.12.0"} '
"HTTPX/0.27.0"
)
result = parser.HatchUserAgent(ua)
assert result["installer"]["name"] == "hatch"
assert result["installer"]["version"] == "1.15.0"
assert result["python"] == "3.12.0"

def test_valid_without_trailing_httpx(self):
ua = (
"Hatch/1.15.0 "
'{"installer":{"name":"hatch","version":"1.15.0"},'
'"python":"3.12.0"}'
)
result = parser.HatchUserAgent(ua)
assert result["installer"]["name"] == "hatch"


class TestParse:
@given(st.text())
def test_unknown_user_agent(self, user_agent):
Expand Down