diff --git a/.github/workflows/release-builtins.yml b/.github/workflows/release-builtins.yml index 6f41709..408f9c7 100644 --- a/.github/workflows/release-builtins.yml +++ b/.github/workflows/release-builtins.yml @@ -1,6 +1,6 @@ name: Publish ua-parser builtins -run-name: Publish ${{ inputs.tag || 'master' }} to ${{ inputs.environment || 'dummy' }} +run-name: Publish ${{ inputs.tag || 'master' }} to ${{ inputs.environment || 'pypy (scheduled)' }} on: schedule: @@ -14,6 +14,7 @@ on: environment: description: "environment to release for (testpypy or pypy)" type: environment + required: true jobs: build: @@ -28,7 +29,7 @@ jobs: persist-credentials: false - name: update core env: - TAG: ${{ inputs.tag || 'master '}} + TAG: ${{ inputs.tag || 'origin/master' }} # needs to detach because we can update to a tag run: git -C uap-core switch --detach "$TAG" - name: Set up Python diff --git a/pyproject.toml b/pyproject.toml index 161c98b..11425fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ua-parser" description = "Python port of Browserscope's user agent parser" -version = "1.0.0" +version = "1.0.1" readme = "README.rst" requires-python = ">=3.9" dependencies = ["ua-parser-builtins"] diff --git a/src/ua_parser/__init__.py b/src/ua_parser/__init__.py index 040dda3..5b5ba71 100644 --- a/src/ua_parser/__init__.py +++ b/src/ua_parser/__init__.py @@ -41,7 +41,8 @@ ] import importlib.util -from typing import Callable, Optional +import threading +from typing import Callable, Optional, cast from .basic import Resolver as BasicResolver from .caching import CachingResolver, S3Fifo as Cache @@ -78,7 +79,7 @@ ) -VERSION = (1, 0, 0) +VERSION = (1, 0, 1) class Parser: @@ -135,15 +136,27 @@ def parse_device(self: Resolver, ua: str) -> Optional[Device]: initialisation, rather than pay for it at first call. """ +_lazy_globals_lock = threading.Lock() + def __getattr__(name: str) -> Parser: global parser - if name == "parser": - if RegexResolver or Re2Resolver or IS_GRAAL: - matchers = load_lazy_builtins() - else: - matchers = load_builtins() - return Parser.from_matchers(matchers) + with _lazy_globals_lock: + if name == "parser": + # if two threads access `ua_parser.parser` before it's + # initialised, the second one will wait until the first + # one's finished by which time the parser global should be + # set and can be returned with no extra work + if p := globals().get("parser"): + return cast(Parser, p) + + if RegexResolver or Re2Resolver or IS_GRAAL: + matchers = load_lazy_builtins() + else: + matchers = load_builtins() + parser = Parser.from_matchers(matchers) + return parser + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/ua_parser/__main__.py b/src/ua_parser/__main__.py index 0ed140f..047efaa 100644 --- a/src/ua_parser/__main__.py +++ b/src/ua_parser/__main__.py @@ -101,7 +101,7 @@ def run_stdout(args: argparse.Namespace) -> None: lines = list(args.file) count = len(lines) uniques = len(set(lines)) - print(f"{args.file.name}: {count} lines, {uniques} unique ({uniques/count:.0%})") + print(f"{args.file.name}: {count} lines, {uniques} unique ({uniques / count:.0%})") rules = get_rules(args.bases, args.regexes) @@ -320,7 +320,7 @@ def belady(maxsize: int) -> Cache: overhead / cache_size, ) print( - f"{cache.__name__.lower():8}({cache_size:{w}}): {(total - misses.count)/total*100:2.0f}% hit rate {diff}" + f"{cache.__name__.lower():8}({cache_size:{w}}): {(total - misses.count) / total * 100:2.0f}% hit rate {diff}" ) del misses, parser @@ -378,7 +378,7 @@ def run_threaded(args: argparse.Namespace) -> None: totlines = len(lines) * args.threads # runtime in us t = (time.perf_counter_ns() - st) / 1000 - print(f"{t/totlines:>4.0f}us/line", flush=True) + print(f"{t / totlines:>4.0f}us/line", flush=True) EPILOG = """For good results the sample `file` should be an actual diff --git a/tests/test_convenience_parser.py b/tests/test_convenience_parser.py index cf1d360..8624061 100644 --- a/tests/test_convenience_parser.py +++ b/tests/test_convenience_parser.py @@ -1,6 +1,23 @@ +import ua_parser from ua_parser import Domain, Parser, PartialResult, Result +def test_parser_memoized() -> None: + """The global parser should be lazily instantiated but memoized""" + # ensure there is no global parser + vars(ua_parser).pop("parser", None) + + p1 = ua_parser.parser + p2 = ua_parser.parser + + assert p1 is p2 + + # force the creation of a clean parser + del ua_parser.parser + p3 = ua_parser.parser + assert p3 is not p1 + + def resolver(s: str, d: Domain) -> PartialResult: return PartialResult(d, None, None, None, s) diff --git a/tests/test_legacy.py b/tests/test_legacy.py index 7ada17c..8fafbee 100644 --- a/tests/test_legacy.py +++ b/tests/test_legacy.py @@ -107,18 +107,18 @@ def runUserAgentTestsFromYAML(self, file_name): result = {} result = user_agent_parser.ParseUserAgent(user_agent_string) - assert ( - result == expected - ), "UA: {0}\n expected<{1}, {2}, {3}, {4}> != actual<{5}, {6}, {7}, {8}>".format( - user_agent_string, - expected["family"], - expected["major"], - expected["minor"], - expected["patch"], - result["family"], - result["major"], - result["minor"], - result["patch"], + assert result == expected, ( + "UA: {0}\n expected<{1}, {2}, {3}, {4}> != actual<{5}, {6}, {7}, {8}>".format( + user_agent_string, + expected["family"], + expected["major"], + expected["minor"], + expected["patch"], + result["family"], + result["major"], + result["minor"], + result["patch"], + ) ) assert ( len(user_agent_parser._PARSE_CACHE) <= user_agent_parser.MAX_CACHE_SIZE @@ -143,20 +143,20 @@ def runOSTestsFromYAML(self, file_name): } result = user_agent_parser.ParseOS(user_agent_string) - assert ( - result == expected - ), "UA: {0}\n expected<{1} {2} {3} {4} {5}> != actual<{6} {7} {8} {9} {10}>".format( - user_agent_string, - expected["family"], - expected["major"], - expected["minor"], - expected["patch"], - expected["patch_minor"], - result["family"], - result["major"], - result["minor"], - result["patch"], - result["patch_minor"], + assert result == expected, ( + "UA: {0}\n expected<{1} {2} {3} {4} {5}> != actual<{6} {7} {8} {9} {10}>".format( + user_agent_string, + expected["family"], + expected["major"], + expected["minor"], + expected["patch"], + expected["patch_minor"], + result["family"], + result["major"], + result["minor"], + result["patch"], + result["patch_minor"], + ) ) def runDeviceTestsFromYAML(self, file_name): @@ -176,16 +176,16 @@ def runDeviceTestsFromYAML(self, file_name): } result = user_agent_parser.ParseDevice(user_agent_string) - assert ( - result == expected - ), "UA: {0}\n expected<{1} {2} {3}> != actual<{4} {5} {6}>".format( - user_agent_string, - expected["family"], - expected["brand"], - expected["model"], - result["family"], - result["brand"], - result["model"], + assert result == expected, ( + "UA: {0}\n expected<{1} {2} {3}> != actual<{4} {5} {6}>".format( + user_agent_string, + expected["family"], + expected["brand"], + expected["model"], + result["family"], + result["brand"], + result["model"], + ) ) diff --git a/ua-parser-builtins/hatch_build.py b/ua-parser-builtins/hatch_build.py index 1eac776..9bbe23f 100644 --- a/ua-parser-builtins/hatch_build.py +++ b/ua-parser-builtins/hatch_build.py @@ -1,6 +1,7 @@ from __future__ import annotations import io +import json import os import os.path import tempfile @@ -10,19 +11,24 @@ import yaml from hatchling.builders.hooks.plugin.interface import BuildHookInterface from hatchling.metadata.plugin.interface import MetadataHookInterface -from versioningit import get_version +from versioningit import errors, get_version class MetadataHook(MetadataHookInterface): def update(self, metadata: dict[str, Any]) -> None: - v = get_version( - os.path.join(self.root, "uap-core"), - config={ - "format": { - "distance": "{next_version}.dev{distance}", - } - }, - ) + try: + v = get_version( + os.path.join(self.root, "uap-core"), + config={ + "format": { + "distance": "{next_version}.dev{distance}", + } + }, + ) + except errors.NotSdistError: + with open(os.path.join(self.root, "uap-core", "package.json")) as ufile: + ujson = json.load(ufile) + v = ujson["version"] if v in ("0.15.0", "0.16.0", "0.18.0"): v = f"{v}.post1"