diff --git a/linehaul/ua/parser.py b/linehaul/ua/parser.py index 1ce17f7..e06e3cd 100644 --- a/linehaul/ua/parser.py +++ b/linehaul/ua/parser.py @@ -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 diff --git a/tests/unit/ua/fixtures/hatch.yml b/tests/unit/ua/fixtures/hatch.yml new file mode 100644 index 0000000..56ac6f9 --- /dev/null +++ b/tests/unit/ua/fixtures/hatch.yml @@ -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 diff --git a/tests/unit/ua/test_parser.py b/tests/unit/ua/test_parser.py index de345f4..10a6b9b 100644 --- a/tests/unit/ua/test_parser.py +++ b/tests/unit/ua/test_parser.py @@ -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):