Skip to content

Commit a5202c1

Browse files
committed
Use watchfiles to track cache
Catch asyncio.TimeoutError
1 parent 94fc446 commit a5202c1

File tree

8 files changed

+174
-132
lines changed

8 files changed

+174
-132
lines changed

pdm.lock

+76-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ requires-python = "<4.0,>=3.12"
66
dependencies = [
77
"aiohttp<4.0.0,>=3.9.3",
88
"defusedxml>=0.7.1",
9+
"watchfiles>=0.24.0",
10+
"deserializer @ git+https://github.com/arenekosreal/deserializer.git",
911
]
1012
name = "crx-repo"
1113
description = "Download Chrom(e|ium) extensions from Chrome Web Store and serve a update manifest."

src/crx_repo/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from crx_repo.config.parser import parse_config_async as _parse_config_async
2222

2323

24-
__version__ = "0.1.0"
24+
__version__ = "0.2.0"
2525

2626

2727
_logger = logging.getLogger(__name__)

src/crx_repo/client.py

+23-6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
from http import HTTPStatus
77
from typing import TypeGuard
8+
from aiohttp import ClientError
89
from pathlib import Path
910
from urllib.parse import urlencode
1011
from aiohttp.client import ClientSession
@@ -37,7 +38,7 @@ def __init__(
3738
if self.proxy is not None:
3839
_logger.info("Using proxy %s to download extension...", self.proxy)
3940
self.CHROME_WEB_STORE_API_BASE = "https://clients2.google.com/service/update2/crx"
40-
self.CHUNK_SIZE_BYTES = 10240
41+
self.CHUNK_SIZE_BYTES = 1024 * 1024 # 1MB
4142

4243
async def download_forever(self):
4344
"""Download extension forever."""
@@ -46,6 +47,12 @@ async def download_forever(self):
4647
await self._do_download()
4748
await asyncio.sleep(self.interval)
4849
except asyncio.CancelledError:
50+
_logger.debug("Cleaning old extensions...")
51+
for p in sorted(
52+
self.cache_path.rglob("*.crx"),
53+
key=lambda p: p.stat().st_mtime,
54+
)[:-1]:
55+
p.unlink()
4956
_logger.debug(
5057
"Stopping downloader for extension %s",
5158
self.extension_id,
@@ -68,12 +75,21 @@ async def _do_download(self):
6875
if response.content_length != int(size):
6976
_logger.warning("Content-Length is not equals to size returned by API.")
7077
hash_calculator = hashlib.sha256()
71-
extension_path = self.cache_path / (version + ".crx")
78+
extension_path = self.cache_path / (version + ".crx.part")
7279
with extension_path.open("wb") as writer:
73-
async for chunk in response.content.iter_chunked(self.CHUNK_SIZE_BYTES):
74-
_logger.debug("Writing %s byte(s) into %s...", len(chunk), extension_path)
75-
hash_calculator.update(chunk)
76-
_ = writer.write(chunk)
80+
try:
81+
async for chunk in response.content.iter_chunked(self.CHUNK_SIZE_BYTES):
82+
chunk_size = writer.write(chunk)
83+
hash_calculator.update(chunk)
84+
_logger.debug(
85+
"Writing %s byte(s) into %s...",
86+
chunk_size,
87+
extension_path,
88+
)
89+
except ClientError as e:
90+
_logger.error("Failed to download because %s", e)
91+
except asyncio.TimeoutError:
92+
_logger.error("Failed to build because async operation timeout.")
7793
_logger.debug("Checking checksums of extension %s...", self.extension_id)
7894
sha256_hash = hash_calculator.hexdigest()
7995
if sha256_hash != sha256:
@@ -87,6 +103,7 @@ async def _do_download(self):
87103
"SHA256 checksum of %s match. Keeping file.",
88104
self.extension_id,
89105
)
106+
_ = extension_path.rename(extension_path.parent / extension_path.stem)
90107

91108
async def _check_update(
92109
self,

src/crx_repo/config/config.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from dataclasses import dataclass
66

77

8-
LogLevelType = Literal["NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
8+
type LogLevelType = Literal["NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
99

1010

1111
@dataclass

src/crx_repo/config/parser/parser.py

+5-110
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,19 @@
11
"""Basic parser implementation."""
22

3-
import inspect
3+
# pyright: reportAny=false
4+
45
import logging
56
from abc import ABC
67
from abc import abstractmethod
7-
from types import UnionType
8-
from typing import Any
9-
from typing import Literal
10-
from typing import TypeVar
118
from typing import Callable
12-
from typing import TypeGuard
13-
from typing import get_args
149
from typing import overload
15-
from typing import get_origin
1610
from pathlib import Path
1711
from crx_repo.config.config import Config
1812

1913

20-
PathOrStr = Path | str
21-
T = TypeVar("T")
22-
ConfigJsonType = dict[str, Any]
23-
KeyConverterType = Callable[[str], str] | None
14+
type PathOrStr = Path | str
15+
type ConfigJsonType = dict[str, str | int | None | ConfigJsonType]
16+
type KeyConverterType = Callable[[str], str] | None
2417

2518
_logger = logging.getLogger(__name__)
2619

@@ -55,101 +48,3 @@ async def support_async(self, path: Path) -> bool:
5548
@abstractmethod
5649
async def support_async(self, path: PathOrStr) -> bool:
5750
"""Check if path is supported by the parser."""
58-
59-
@staticmethod
60-
def deserialize(
61-
cls_: type[T],
62-
json: ConfigJsonType,
63-
key_convert: KeyConverterType = None,
64-
) -> T:
65-
"""Deserialize json to a class.
66-
67-
Args:
68-
cls_(type[T]): The class itself, it must have a no-argument constructor.
69-
json(ConfigJsonType): The json data.
70-
key_convert(KeyConverterType): A converter to convert key between json and class.
71-
It should accept key in json and return a string,
72-
which represents the attribute name of cls_ instance.
73-
It defaults to None, means do not convert.
74-
75-
Returns:
76-
T: The instance of cls_
77-
78-
Remarks:
79-
This method is slow because using setattr() and getattr(),
80-
please cache its result to speed up.
81-
"""
82-
instance = cls_()
83-
type_of_instance = inspect.get_annotations(cls_)
84-
for k, v in json.items(): # pyright: ignore[reportAny]
85-
attr_name = key_convert(k) if key_convert is not None else k
86-
if hasattr(instance, attr_name):
87-
type_of_attr = type_of_instance.get(attr_name)
88-
_logger.debug("Type of %s is %s", k, type_of_attr)
89-
if type_of_attr is None:
90-
_logger.debug(
91-
"%s does not have a type hint, ignoring its deserialization.",
92-
attr_name,
93-
)
94-
elif ConfigParser._is_config_json(v): # pyright: ignore[reportAny]
95-
_logger.debug("Calling deserialize() recursively.")
96-
v_deserialized = ConfigParser.deserialize( # pyright: ignore[reportUnknownVariableType]
97-
ConfigParser._ensure_instanceable(type_of_attr), # pyright: ignore[reportAny]
98-
v,
99-
key_convert,
100-
)
101-
setattr(instance, attr_name, v_deserialized)
102-
elif ConfigParser._is_generics_valid(
103-
v, # pyright: ignore[reportAny]
104-
type_of_attr, # pyright: ignore[reportAny]
105-
) or isinstance(v, type_of_attr):
106-
_logger.debug("Type match, assigning value of %s directly.", k)
107-
setattr(instance, attr_name, v)
108-
else:
109-
_logger.debug("Do not know how to deserialize %s, ignoring.", k)
110-
return instance
111-
112-
@staticmethod
113-
def _is_config_json(obj: object) -> TypeGuard[ConfigJsonType]:
114-
return isinstance(obj, dict) and all(isinstance(k, str) for k in obj) # pyright: ignore[reportUnknownVariableType]
115-
116-
@staticmethod
117-
def _is_generics_valid(v: object, t: type) -> bool:
118-
args = get_args(t)
119-
if len(args) > 0:
120-
origin = get_origin(t)
121-
if origin is Literal or origin is UnionType:
122-
return v in args
123-
if origin is list:
124-
return isinstance(v, list) and ConfigParser._is_list_valid(v, t) # pyright: ignore[reportUnknownArgumentType]
125-
raise NotImplementedError("Unsupported type", origin)
126-
return False
127-
128-
@staticmethod
129-
def _is_list_valid(v: list[T], t: type[list[T]]) -> bool:
130-
return (len(v) == 0) or all(isinstance(value, get_args(t)[0]) for value in v)
131-
132-
@staticmethod
133-
def _ensure_instanceable(
134-
i: type,
135-
checker: Callable[[type], bool] = callable,
136-
) -> type:
137-
_logger.debug("Ensuring object %s is instanceable...", i)
138-
if checker(i):
139-
return i
140-
if ConfigParser._is_union_type(i):
141-
args = get_args(i)
142-
matches = (arg for arg in args if checker(arg)) # pyright: ignore[reportAny]
143-
found = next(matches, None)
144-
if found is None:
145-
raise ValueError("No instanceable object can be extracted in UnionType")
146-
return found # pyright: ignore[reportAny]
147-
raise NotImplementedError("Unsupported type", i)
148-
149-
@staticmethod
150-
def _is_union_type(i: type) -> TypeGuard[UnionType]:
151-
args = get_args(i)
152-
if len(args) > 0:
153-
origin = get_origin(i)
154-
return origin is UnionType
155-
return False

src/crx_repo/config/parser/toml.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import tomllib
44
from typing import override
55
from pathlib import Path
6+
from deserializer import deserialize
67
from crx_repo.config.config import Config
78
from crx_repo.config.parser.parser import PathOrStr
89
from crx_repo.config.parser.parser import ConfigParser
@@ -17,7 +18,7 @@ async def parse_async(self, path: PathOrStr) -> Config:
1718
if path not in self._cache:
1819
content = path.read_text()
1920
config_raw = tomllib.loads(content)
20-
self._cache[path] = TomlConfigParser.deserialize(
21+
self._cache[path] = deserialize(
2122
Config, config_raw,
2223
lambda x: x.replace("-", "_").lower(), # Kebab case to snake case
2324
)

0 commit comments

Comments
 (0)