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/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)