route-rules 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ import lazy_loader as lazy
2
+
3
+ __getattr__, __dir__, __all__ = lazy.attach_stub(__name__, __file__)
@@ -0,0 +1,42 @@
1
+ from . import core, gen, provider, utils
2
+ from ._recipe import Recipe
3
+ from .core import RuleSet
4
+ from .gen import (
5
+ ArtifactMeta,
6
+ Builder,
7
+ Config,
8
+ Meta,
9
+ ProviderMeta,
10
+ RecipeMeta,
11
+ RecipeWrapper,
12
+ )
13
+ from .provider import (
14
+ Behavior,
15
+ Format,
16
+ Provider,
17
+ ProviderMihomo,
18
+ ProviderRegistry,
19
+ )
20
+ from .utils import download
21
+
22
+ __all__ = [
23
+ "ArtifactMeta",
24
+ "Behavior",
25
+ "Builder",
26
+ "Config",
27
+ "Format",
28
+ "Meta",
29
+ "Provider",
30
+ "ProviderMeta",
31
+ "ProviderMihomo",
32
+ "ProviderRegistry",
33
+ "Recipe",
34
+ "RecipeMeta",
35
+ "RecipeWrapper",
36
+ "RuleSet",
37
+ "core",
38
+ "download",
39
+ "gen",
40
+ "provider",
41
+ "utils",
42
+ ]
route_rules/_recipe.py ADDED
@@ -0,0 +1,33 @@
1
+ import asyncio
2
+
3
+ import attrs
4
+ import cachetools
5
+ import cachetools_async as cta
6
+
7
+ from . import utils
8
+ from .core import RuleSet
9
+ from .provider import ProviderRegistry
10
+
11
+
12
+ @attrs.define
13
+ class Recipe:
14
+ name: str = attrs.field()
15
+ providers: list[str] = attrs.field()
16
+ registry: ProviderRegistry = attrs.field(
17
+ factory=ProviderRegistry.presets, kw_only=True
18
+ )
19
+ slug: str = attrs.field(
20
+ default=attrs.Factory(utils.default_slug, takes_self=True), kw_only=True
21
+ )
22
+
23
+ _cache: cachetools.Cache = attrs.field(
24
+ factory=lambda: cachetools.Cache(maxsize=1), kw_only=True
25
+ )
26
+
27
+ @cta.cachedmethod(lambda self: self._cache)
28
+ async def build(self) -> RuleSet:
29
+ ruleset: RuleSet = RuleSet.union(
30
+ *(await asyncio.gather(*(self.registry.load(p) for p in self.providers)))
31
+ )
32
+ ruleset = ruleset.optimize()
33
+ return ruleset
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.2.2'
32
+ __version_tuple__ = version_tuple = (0, 2, 2)
33
+
34
+ __commit_id__ = commit_id = None
@@ -0,0 +1,8 @@
1
+ # This file is @generated by <https://github.com/liblaf/copier-python>.
2
+ # DO NOT EDIT!
3
+
4
+ # ref: <https://github.com/ofek/hatch-vcs>
5
+ __version__: str
6
+ __version_tuple__: tuple[int | str, ...]
7
+ version_tuple: tuple[int | str, ...]
8
+ version: str
@@ -0,0 +1,5 @@
1
+ # tangerine-start: lazy-loader.py
2
+ import lazy_loader as _lazy
3
+
4
+ __getattr__, __dir__, __all__ = _lazy.attach_stub(__name__, __file__)
5
+ # tangerine-end
@@ -0,0 +1,3 @@
1
+ from ._ruleset import RuleSet
2
+
3
+ __all__ = ["RuleSet"]
@@ -0,0 +1,53 @@
1
+ import collections
2
+ from collections.abc import Mapping
3
+ from collections.abc import Set as AbstractSet
4
+ from typing import Self, override
5
+
6
+
7
+ class RuleSet(collections.UserDict[str, set[str]]):
8
+ """.
9
+
10
+ References:
11
+ 1. <https://wiki.metacubex.one/en/config/rules/>
12
+ """
13
+
14
+ @override
15
+ def __or__(self, other: Mapping[str, AbstractSet[str]], /) -> Self: # pyright: ignore[reportIncompatibleMethodOverride]
16
+ result: Self = type(self)()
17
+ for typ in self.keys() | other.keys():
18
+ result[typ] = self.get(typ, set()) | other.get(typ, set())
19
+ return result
20
+
21
+ def __missing__(self, key: str) -> set[str]:
22
+ self[key] = set()
23
+ return self[key]
24
+
25
+ @property
26
+ def domain(self) -> set[str]:
27
+ return self["DOMAIN"]
28
+
29
+ @property
30
+ def domain_suffix(self) -> set[str]:
31
+ return self["DOMAIN-SUFFIX"]
32
+
33
+ @property
34
+ def ip_cidr(self) -> set[str]:
35
+ return self["IP-CIDR"]
36
+
37
+ def add(self, typ: str, value: str) -> None:
38
+ # IP-CIDR and IP-CIDR6 have the same effect, with IP-CIDR6 being an alias.
39
+ if typ == "IP-CIDR6":
40
+ typ = "IP-CIDR"
41
+ self[typ].add(value)
42
+
43
+ def optimize(self) -> Self:
44
+ # TODO: implement
45
+ return self
46
+
47
+ def union(self, *others: Mapping[str, AbstractSet[str]]) -> Self:
48
+ result: Self = type(self)()
49
+ for typ in set(self.keys()).union(*(m.keys() for m in others)):
50
+ result[typ] = self.get(typ, set()).union(
51
+ *(m.get(typ, set()) for m in others)
52
+ )
53
+ return result
@@ -0,0 +1,5 @@
1
+ # tangerine-start: lazy-loader.py
2
+ import lazy_loader as _lazy
3
+
4
+ __getattr__, __dir__, __all__ = _lazy.attach_stub(__name__, __file__)
5
+ # tangerine-end
@@ -0,0 +1,4 @@
1
+ from ._abc import Exporter
2
+ from ._mihomo import ExporterMihomo
3
+
4
+ __all__ = ["Exporter", "ExporterMihomo"]
@@ -0,0 +1,19 @@
1
+ import abc
2
+ import os
3
+ from pathlib import Path
4
+
5
+ import attrs
6
+
7
+ from route_rules.core import RuleSet
8
+
9
+
10
+ @attrs.define
11
+ class Exporter(abc.ABC):
12
+ export_path_template: str = attrs.field(kw_only=True)
13
+
14
+ @abc.abstractmethod
15
+ def export(self, file: str | os.PathLike[str], ruleset: RuleSet) -> int:
16
+ raise NotImplementedError
17
+
18
+ def export_path(self, slug: str) -> Path:
19
+ return Path(self.export_path_template.format(slug=slug))
@@ -0,0 +1,121 @@
1
+ import os
2
+ import subprocess
3
+ import tempfile
4
+ from collections.abc import Generator, Iterable
5
+ from pathlib import Path
6
+ from typing import override
7
+
8
+ import attrs
9
+ import msgspec
10
+
11
+ from route_rules.core import RuleSet
12
+ from route_rules.provider.mihomo import Behavior, Format
13
+
14
+ from ._abc import Exporter
15
+
16
+
17
+ @attrs.define
18
+ class ExporterMihomo(Exporter):
19
+ behavior: Behavior = attrs.field()
20
+ format: Format = attrs.field()
21
+ export_path_template: str = attrs.field(
22
+ default="mihomo/{slug}.{behavior}{format.ext}", kw_only=True
23
+ )
24
+
25
+ @override
26
+ def export(
27
+ self,
28
+ file: str | os.PathLike[str],
29
+ ruleset: RuleSet,
30
+ ) -> int:
31
+ data: bytes = encode(ruleset, behavior=self.behavior, format=self.format)
32
+ if not data:
33
+ return 0
34
+ file = Path(file)
35
+ file.parent.mkdir(parents=True, exist_ok=True)
36
+ file.write_bytes(data)
37
+ return len(data)
38
+
39
+ @override
40
+ def export_path(self, slug: str) -> Path:
41
+ return Path(
42
+ self.export_path_template.format(
43
+ slug=slug, behavior=self.behavior, format=self.format
44
+ )
45
+ )
46
+
47
+
48
+ @attrs.define
49
+ class EncodeError(RuntimeError):
50
+ behavior: Behavior = attrs.field()
51
+ format: Format = attrs.field()
52
+
53
+
54
+ def encode(ruleset: RuleSet, behavior: Behavior, format: Format) -> bytes: # noqa: A002
55
+ payload: Iterable[str]
56
+ match behavior:
57
+ case Behavior.DOMAIN:
58
+ payload = _encode_domain(ruleset)
59
+ case Behavior.IPCIDR:
60
+ payload = _encode_ipcidr(ruleset)
61
+ case Behavior.CLASSICAL:
62
+ payload = _encode_classical(ruleset)
63
+ case _:
64
+ raise EncodeError(behavior=behavior, format=format)
65
+ match format:
66
+ case Format.YAML:
67
+ return _encode_yaml(payload)
68
+ case Format.TEXT:
69
+ return _encode_text(payload).encode()
70
+ case Format.MRS:
71
+ return _encode_mrs(payload, behavior=behavior)
72
+ case _:
73
+ raise EncodeError(behavior=behavior, format=format)
74
+
75
+
76
+ def _encode_domain(ruleset: RuleSet) -> Generator[str]:
77
+ yield from ruleset.domain
78
+ for domain in ruleset.domain_suffix:
79
+ yield f"+.{domain}"
80
+
81
+
82
+ def _encode_ipcidr(ruleset: RuleSet) -> set[str]:
83
+ return ruleset.ip_cidr
84
+
85
+
86
+ def _encode_classical(ruleset: RuleSet) -> Generator[str]:
87
+ for typ, values in ruleset.data.items():
88
+ if typ in {"DOMAIN", "DOMAIN-SUFFIX", "IP-CIDR"}:
89
+ continue
90
+ for value in values:
91
+ yield f"{typ},{value}"
92
+
93
+
94
+ def _encode_yaml(payload: Iterable[str]) -> bytes:
95
+ payload = list(payload)
96
+ if not payload:
97
+ return b""
98
+ return msgspec.yaml.encode({"payload": list(payload)})
99
+
100
+
101
+ def _encode_text(payload: Iterable[str]) -> str:
102
+ payload = list(payload)
103
+ if not payload:
104
+ return ""
105
+ return "\n".join(payload)
106
+
107
+
108
+ def _encode_mrs(payload: Iterable[str], behavior: Behavior) -> bytes:
109
+ payload = list(payload)
110
+ if not payload:
111
+ return b""
112
+ with tempfile.TemporaryDirectory() as tmpdir_str:
113
+ tmpdir = Path(tmpdir_str)
114
+ yaml_file: Path = tmpdir / "rule-set.yaml"
115
+ mrs_file: Path = tmpdir / "rule-set.mrs"
116
+ yaml_file.write_bytes(_encode_yaml(payload))
117
+ subprocess.run(
118
+ ["mihomo", "convert-ruleset", behavior, "yaml", yaml_file, mrs_file],
119
+ check=True,
120
+ )
121
+ return mrs_file.read_bytes()
@@ -0,0 +1,5 @@
1
+ # tangerine-start: lazy-loader.py
2
+ import lazy_loader as _lazy
3
+
4
+ __getattr__, __dir__, __all__ = _lazy.attach_stub(__name__, __file__)
5
+ # tangerine-end
@@ -0,0 +1,14 @@
1
+ from ._builder import Builder
2
+ from ._config import Config
3
+ from ._meta import ArtifactMeta, Meta, ProviderMeta, RecipeMeta
4
+ from ._recipe import RecipeWrapper
5
+
6
+ __all__ = [
7
+ "ArtifactMeta",
8
+ "Builder",
9
+ "Config",
10
+ "Meta",
11
+ "ProviderMeta",
12
+ "RecipeMeta",
13
+ "RecipeWrapper",
14
+ ]
@@ -0,0 +1,87 @@
1
+ import collections
2
+ import datetime
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Self
6
+
7
+ import attrs
8
+
9
+ from route_rules.core import RuleSet
10
+ from route_rules.export import ExporterMihomo
11
+ from route_rules.provider import Behavior, Format
12
+
13
+ from ._config import Config
14
+ from ._meta import ArtifactMeta, Meta, ProviderMeta, RecipeMeta, RecipeStatistics
15
+ from ._recipe import RecipeWrapper
16
+
17
+
18
+ def _default_exporters() -> list[ExporterMihomo]:
19
+ return [
20
+ ExporterMihomo(behavior=Behavior.DOMAIN, format=Format.MRS),
21
+ ExporterMihomo(behavior=Behavior.DOMAIN, format=Format.TEXT),
22
+ ExporterMihomo(behavior=Behavior.IPCIDR, format=Format.MRS),
23
+ ExporterMihomo(behavior=Behavior.IPCIDR, format=Format.TEXT),
24
+ ExporterMihomo(behavior=Behavior.CLASSICAL, format=Format.TEXT),
25
+ ]
26
+
27
+
28
+ @attrs.define
29
+ class Builder:
30
+ dist_dir: Path = attrs.field(default=Path("dist"))
31
+ exporters: list[ExporterMihomo] = attrs.field(factory=_default_exporters)
32
+ recipes: list[RecipeWrapper] = attrs.field(factory=list)
33
+
34
+ @classmethod
35
+ def load(cls, file: str | os.PathLike[str]) -> Self:
36
+ config: Config = Config.load(file)
37
+ self: Self = cls()
38
+ for recipe_config in config.recipes:
39
+ self.recipes.append(RecipeWrapper.from_config(recipe_config))
40
+ return self
41
+
42
+ async def build(self) -> None:
43
+ meta = Meta(build_time=datetime.datetime.now().astimezone())
44
+ for recipe in self.recipes:
45
+ meta.recipes.append(await self.build_recipe(recipe))
46
+ (self.dist_dir / "meta.json").write_bytes(meta.json_encode())
47
+
48
+ async def build_recipe(self, recipe: RecipeWrapper) -> RecipeMeta:
49
+ ruleset: RuleSet = await recipe.build()
50
+ meta = RecipeMeta(
51
+ name=recipe.name,
52
+ slug=recipe.slug,
53
+ statistics=await self._build_statistics(recipe, ruleset),
54
+ )
55
+ for provider in recipe.providers:
56
+ meta.providers.append(
57
+ ProviderMeta(
58
+ name=provider,
59
+ download_url=recipe.registry.download_url(provider),
60
+ preview_url=recipe.registry.preview_url(provider),
61
+ )
62
+ )
63
+ for exporter in self.exporters:
64
+ path: Path = exporter.export_path(recipe.slug)
65
+ size: int = exporter.export(self.dist_dir / path, ruleset)
66
+ if size == 0:
67
+ continue
68
+ meta.artifacts.append(
69
+ ArtifactMeta(
70
+ behavior=exporter.behavior,
71
+ format=exporter.format,
72
+ path=path,
73
+ size=size,
74
+ )
75
+ )
76
+ return meta
77
+
78
+ async def _build_statistics(
79
+ self, recipe: RecipeWrapper, ruleset: RuleSet
80
+ ) -> RecipeStatistics:
81
+ outputs: dict[str, int] = {typ: len(values) for typ, values in ruleset.items()}
82
+ inputs: dict[str, int] = collections.defaultdict(lambda: 0)
83
+ for provider in recipe.providers:
84
+ ruleset: RuleSet = await recipe.registry.load(provider)
85
+ for typ, values in ruleset.items():
86
+ inputs[typ] += len(values)
87
+ return RecipeStatistics(inputs=inputs, outputs=outputs)
@@ -0,0 +1,21 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Any, Self
4
+
5
+ import msgspec
6
+ import pydantic
7
+
8
+
9
+ class RecipeConfig(pydantic.BaseModel):
10
+ name: str
11
+ providers: list[str]
12
+
13
+
14
+ class Config(pydantic.BaseModel):
15
+ recipes: list[RecipeConfig]
16
+
17
+ @classmethod
18
+ def load(cls, file: str | os.PathLike[str]) -> Self:
19
+ file = Path(file)
20
+ data: Any = msgspec.yaml.decode(file.read_bytes())
21
+ return cls.model_validate(data)
@@ -0,0 +1,58 @@
1
+ import datetime
2
+ from collections.abc import Buffer
3
+ from pathlib import Path
4
+ from typing import Any, Self
5
+
6
+ import msgspec
7
+
8
+ from route_rules.provider import Behavior, Format
9
+
10
+
11
+ class ArtifactMeta(msgspec.Struct):
12
+ behavior: Behavior
13
+ format: Format
14
+ path: Path
15
+ size: int
16
+
17
+
18
+ class ProviderMeta(msgspec.Struct):
19
+ name: str
20
+ download_url: str
21
+ preview_url: str
22
+
23
+
24
+ class RecipeStatistics(msgspec.Struct):
25
+ inputs: dict[str, int] = msgspec.field(default_factory=dict)
26
+ outputs: dict[str, int] = msgspec.field(default_factory=dict)
27
+
28
+
29
+ class RecipeMeta(msgspec.Struct):
30
+ name: str
31
+ slug: str
32
+ artifacts: list[ArtifactMeta] = msgspec.field(default_factory=list)
33
+ providers: list[ProviderMeta] = msgspec.field(default_factory=list)
34
+ statistics: RecipeStatistics = msgspec.field(default_factory=RecipeStatistics)
35
+
36
+
37
+ class Meta(msgspec.Struct):
38
+ build_time: datetime.datetime
39
+ recipes: list[RecipeMeta] = msgspec.field(default_factory=list)
40
+
41
+ @classmethod
42
+ def json_decode(cls, data: Buffer | str) -> Self:
43
+ return msgspec.json.decode(data, type=cls, dec_hook=dec_hook)
44
+
45
+ def json_encode(self) -> bytes:
46
+ return msgspec.json.encode(self, enc_hook=enc_hook)
47
+
48
+
49
+ def dec_hook(typ: type, obj: Any) -> Any:
50
+ return typ(obj)
51
+
52
+
53
+ def enc_hook(obj: Any) -> Any:
54
+ match obj:
55
+ case Path():
56
+ return obj.as_posix()
57
+ case _:
58
+ return obj
@@ -0,0 +1,14 @@
1
+ from typing import Self
2
+
3
+ import attrs
4
+
5
+ from route_rules._recipe import Recipe
6
+
7
+ from ._config import RecipeConfig
8
+
9
+
10
+ @attrs.define
11
+ class RecipeWrapper(Recipe):
12
+ @classmethod
13
+ def from_config(cls, config: RecipeConfig) -> Self:
14
+ return cls(name=config.name, providers=config.providers)
@@ -0,0 +1,5 @@
1
+ # tangerine-start: lazy-loader.py
2
+ import lazy_loader as _lazy
3
+
4
+ __getattr__, __dir__, __all__ = _lazy.attach_stub(__name__, __file__)
5
+ # tangerine-end
@@ -0,0 +1,13 @@
1
+ from . import mihomo
2
+ from ._abc import Provider
3
+ from ._registry import ProviderRegistry
4
+ from .mihomo import Behavior, Format, ProviderMihomo
5
+
6
+ __all__ = [
7
+ "Behavior",
8
+ "Format",
9
+ "Provider",
10
+ "ProviderMihomo",
11
+ "ProviderRegistry",
12
+ "mihomo",
13
+ ]
@@ -0,0 +1,36 @@
1
+ import abc
2
+ import urllib.parse
3
+
4
+ import attrs
5
+ import cachetools
6
+
7
+ from route_rules.core import RuleSet
8
+
9
+
10
+ def _default_preview_url_template(self: "Provider") -> str:
11
+ return self.download_url_template
12
+
13
+
14
+ @attrs.define
15
+ class Provider(abc.ABC):
16
+ name: str = attrs.field()
17
+ download_url_template: str = attrs.field()
18
+ preview_url_template: str = attrs.field(
19
+ default=attrs.Factory(_default_preview_url_template, takes_self=True),
20
+ )
21
+
22
+ _cache: cachetools.Cache = attrs.field(
23
+ factory=lambda: cachetools.LRUCache(maxsize=65536), kw_only=True
24
+ )
25
+
26
+ def download_url(self, name: str) -> str:
27
+ name = urllib.parse.quote(name)
28
+ return self.download_url_template.format(name=name)
29
+
30
+ @abc.abstractmethod
31
+ async def load(self, name: str) -> RuleSet:
32
+ raise NotImplementedError
33
+
34
+ def preview_url(self, name: str) -> str:
35
+ name = urllib.parse.quote(name)
36
+ return self.preview_url_template.format(name=name)
@@ -0,0 +1,91 @@
1
+ import functools
2
+ from typing import Self
3
+
4
+ import attrs
5
+
6
+ from route_rules.core import RuleSet
7
+
8
+ from ._abc import Provider
9
+ from .mihomo import Behavior, Format, ProviderMihomo
10
+
11
+
12
+ @attrs.define
13
+ class ProviderRegistry:
14
+ registry: dict[str, Provider] = attrs.field(factory=dict)
15
+
16
+ @classmethod
17
+ @functools.cache
18
+ def presets(cls) -> Self:
19
+ self: Self = cls()
20
+ self.register(
21
+ ProviderMihomo(
22
+ "blackmatrix7",
23
+ "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Clash/{name}/{name}.list",
24
+ "https://github.com/blackmatrix7/ios_rule_script/tree/master/rule/Clash/{name}",
25
+ behavior=Behavior.CLASSICAL,
26
+ format=Format.TEXT,
27
+ ),
28
+ ProviderMihomo(
29
+ "dler-io",
30
+ "https://raw.githubusercontent.com/dler-io/Rules/main/Clash/Provider/{name}.yaml",
31
+ "https://github.com/dler-io/Rules/blob/main/Clash/Provider/{name}.yaml",
32
+ behavior=Behavior.CLASSICAL,
33
+ format=Format.YAML,
34
+ ),
35
+ ProviderMihomo(
36
+ "liblaf",
37
+ "https://raw.githubusercontent.com/liblaf/route-rules/main/rules/{name}.list",
38
+ "https://github.com/liblaf/route-rules/blob/main/rules/{name}.list",
39
+ behavior=Behavior.DOMAIN,
40
+ format=Format.TEXT,
41
+ ),
42
+ ProviderMihomo(
43
+ "MetaCubeX/geosite",
44
+ "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/{name}.yaml",
45
+ "https://github.com/MetaCubeX/meta-rules-dat/blob/meta/geo/geosite/{name}.yaml",
46
+ behavior=Behavior.DOMAIN,
47
+ format=Format.YAML,
48
+ ),
49
+ ProviderMihomo(
50
+ "SukkaW/classical",
51
+ "https://ruleset.skk.moe/Clash/{name}.txt",
52
+ behavior=Behavior.CLASSICAL,
53
+ format=Format.TEXT,
54
+ ),
55
+ ProviderMihomo(
56
+ "SukkaW/domain",
57
+ "https://ruleset.skk.moe/Clash/{name}.txt",
58
+ behavior=Behavior.DOMAIN,
59
+ format=Format.TEXT,
60
+ ),
61
+ )
62
+ return self
63
+
64
+ def download_url(self, name: str) -> str:
65
+ provider: Provider
66
+ ruleset_name: str
67
+ provider, ruleset_name = self._parse_name(name)
68
+ return provider.download_url(ruleset_name)
69
+
70
+ async def load(self, name: str) -> RuleSet:
71
+ provider: Provider
72
+ ruleset_name: str
73
+ provider, ruleset_name = self._parse_name(name)
74
+ return await provider.load(ruleset_name)
75
+
76
+ def preview_url(self, name: str) -> str:
77
+ provider: Provider
78
+ ruleset_name: str
79
+ provider, ruleset_name = self._parse_name(name)
80
+ return provider.preview_url(ruleset_name)
81
+
82
+ def register(self, *providers: Provider) -> None:
83
+ for provider in providers:
84
+ self.registry[provider.name] = provider
85
+
86
+ def _parse_name(self, name: str) -> tuple[Provider, str]:
87
+ provider_name: str
88
+ ruleset_name: str
89
+ provider_name, _, ruleset_name = name.partition(":")
90
+ provider: Provider = self.registry[provider_name]
91
+ return provider, ruleset_name
@@ -0,0 +1,5 @@
1
+ # tangerine-start: lazy-loader.py
2
+ import lazy_loader as _lazy
3
+
4
+ __getattr__, __dir__, __all__ = _lazy.attach_stub(__name__, __file__)
5
+ # tangerine-end
@@ -0,0 +1,5 @@
1
+ from ._decode import decode
2
+ from ._enum import Behavior, Format
3
+ from ._provider import ProviderMihomo
4
+
5
+ __all__ = ["Behavior", "Format", "ProviderMihomo", "decode"]
@@ -0,0 +1,73 @@
1
+ import re
2
+ from collections.abc import Iterable
3
+
4
+ import attrs
5
+ import msgspec
6
+ from loguru import logger
7
+
8
+ from route_rules.core import RuleSet
9
+
10
+ from ._enum import Behavior, Format
11
+
12
+
13
+ @attrs.define
14
+ class DecodeError(RuntimeError):
15
+ behavior: Behavior = attrs.field()
16
+ format: Format = attrs.field()
17
+
18
+
19
+ def decode(data: str | bytes, behavior: Behavior, format: Format) -> RuleSet: # noqa: A002
20
+ payload: list[str]
21
+ match format:
22
+ case Format.YAML:
23
+ payload = _decode_yaml(data)
24
+ case Format.TEXT:
25
+ payload = _decode_text(data)
26
+ case _:
27
+ raise DecodeError(behavior=behavior, format=format)
28
+ match behavior:
29
+ case Behavior.DOMAIN:
30
+ return _decode_domain(payload)
31
+ case Behavior.CLASSICAL:
32
+ return _decode_classical(payload)
33
+ case _:
34
+ raise DecodeError(behavior=behavior, format=format)
35
+
36
+
37
+ def _decode_domain(payload: Iterable[str]) -> RuleSet:
38
+ # ref: <https://wiki.metacubex.one/en/handbook/syntax/#domain-wildcards>
39
+ ruleset = RuleSet()
40
+ for line in payload:
41
+ if line.startswith("*."):
42
+ logger.warning("Unsupported: Domain Wildcard `*`.", once=True)
43
+ elif line.startswith("+."):
44
+ ruleset.domain_suffix.add(line[2:])
45
+ elif line.startswith("."):
46
+ logger.warning("Unsupported: Domain Wildcard `.`.", once=True)
47
+ else:
48
+ ruleset.domain.add(line)
49
+ return ruleset
50
+
51
+
52
+ def _decode_classical(payload: Iterable[str]) -> RuleSet:
53
+ ruleset = RuleSet()
54
+ for line in payload:
55
+ typ: str
56
+ value: str
57
+ typ, value, *_ = line.split(",", maxsplit=2)
58
+ ruleset.add(typ, value)
59
+ return ruleset
60
+
61
+
62
+ def _decode_yaml(data: str | bytes) -> list[str]:
63
+ return msgspec.yaml.decode(data)["payload"]
64
+
65
+
66
+ def _decode_text(text: str | bytes) -> list[str]:
67
+ if isinstance(text, bytes):
68
+ text = text.decode()
69
+ text = re.sub(r"#.*", "", text, flags=re.MULTILINE)
70
+ lines: list[str] = text.splitlines()
71
+ lines = [line.strip() for line in lines]
72
+ lines = [line for line in lines if line]
73
+ return lines
@@ -0,0 +1,21 @@
1
+ import enum
2
+
3
+
4
+ class Behavior(enum.StrEnum):
5
+ DOMAIN = enum.auto()
6
+ IPCIDR = enum.auto()
7
+ CLASSICAL = enum.auto()
8
+
9
+
10
+ class Format(enum.StrEnum):
11
+ YAML = enum.auto()
12
+ TEXT = enum.auto()
13
+ MRS = enum.auto()
14
+
15
+ @property
16
+ def ext(self) -> str:
17
+ return {
18
+ Format.YAML: ".yaml",
19
+ Format.TEXT: ".list",
20
+ Format.MRS: ".mrs",
21
+ }[self]
@@ -0,0 +1,24 @@
1
+ from typing import override
2
+
3
+ import attrs
4
+ import cachetools_async as cta
5
+ import httpx
6
+
7
+ from route_rules import utils
8
+ from route_rules.core import RuleSet
9
+ from route_rules.provider._abc import Provider
10
+
11
+ from ._decode import decode
12
+ from ._enum import Behavior, Format
13
+
14
+
15
+ @attrs.define
16
+ class ProviderMihomo(Provider):
17
+ behavior: Behavior = attrs.field(kw_only=True)
18
+ format: Format = attrs.field(default=Format.YAML, kw_only=True)
19
+
20
+ @override
21
+ @cta.cachedmethod(lambda self: self._cache)
22
+ async def load(self, name: str) -> RuleSet:
23
+ response: httpx.Response = await utils.download(self.download_url(name))
24
+ return decode(response.text, behavior=self.behavior, format=self.format)
route_rules/py.typed ADDED
File without changes
@@ -0,0 +1,3 @@
1
+ import lazy_loader as lazy
2
+
3
+ __getattr__, __dir__, __all__ = lazy.attach_stub(__name__, __file__)
@@ -0,0 +1,4 @@
1
+ from ._download import download
2
+ from ._slugify import default_slug
3
+
4
+ __all__ = ["default_slug", "download"]
@@ -0,0 +1,16 @@
1
+ import hishel
2
+ import httpx
3
+ from loguru import logger
4
+
5
+ storage = hishel.AsyncFileStorage(ttl=86400) # seconds
6
+ client = hishel.AsyncCacheClient(follow_redirects=True, storage=storage)
7
+
8
+
9
+ async def download(url: str) -> httpx.Response:
10
+ response: httpx.Response = await client.get(url)
11
+ response = response.raise_for_status()
12
+ if response.extensions["from_cache"]:
13
+ logger.success("Cache Hit: {}", url)
14
+ else:
15
+ logger.info("Cache Miss: {}", url)
16
+ return response
@@ -0,0 +1,7 @@
1
+ from typing import Any
2
+
3
+ from slugify import slugify
4
+
5
+
6
+ def default_slug(self: Any) -> str:
7
+ return slugify(self.name)
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: route-rules
3
+ Version: 0.2.2
4
+ Project-URL: Changelog, https://github.com/liblaf/route-rules/blob/main/CHANGELOG.md
5
+ Project-URL: Documentation, https://liblaf.github.io/route-rules/
6
+ Project-URL: Funding, https://github.com/liblaf/route-rules?sponsor=1
7
+ Project-URL: Homepage, https://github.com/liblaf/route-rules
8
+ Project-URL: Issue Tracker, https://github.com/liblaf/route-rules/issues
9
+ Project-URL: Release Notes, https://github.com/liblaf/route-rules/releases
10
+ Project-URL: Source Code, https://github.com/liblaf/route-rules
11
+ Author-email: liblaf <30631553+liblaf@users.noreply.github.com>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.12
25
+ Requires-Dist: anyio<5,>=4
26
+ Requires-Dist: autoregistry<3,>=1
27
+ Requires-Dist: cachetools-async<0.0.6,>=0.0.5
28
+ Requires-Dist: cachetools<7,>=6
29
+ Requires-Dist: cytoolz<2,>=1
30
+ Requires-Dist: hishel<0.2,>=0.1
31
+ Requires-Dist: httpx[socks]<0.29,>=0.28
32
+ Requires-Dist: lazy-loader<0.5,>=0.4
33
+ Requires-Dist: liblaf-grapes<5,>=4
34
+ Requires-Dist: loguru<0.8,>=0.7
35
+ Requires-Dist: prettytable<4,>=3
36
+ Requires-Dist: python-slugify<9,>=8
37
+ Requires-Dist: validators<0.36,>=0.35
38
+ Description-Content-Type: text/markdown
39
+
40
+ # Route Rules
@@ -0,0 +1,36 @@
1
+ route_rules/__init__.py,sha256=OHb6Xou2v6u42swTgjRfzej4CIlRg4OmgOIQXUiRjKA,97
2
+ route_rules/__init__.pyi,sha256=jJ6rZmgqtqyUrq-Ar3N4MAE1DOUAjIlr_HXZ0_7Iy4E,682
3
+ route_rules/_recipe.py,sha256=jZLj8DUY2vWKxVyUmIQRKVWyYY_7alxV-BKoJ3sjegA,890
4
+ route_rules/_version.py,sha256=o3ZTescp-19Z9cvBGq9dQnbppljgzdUYUf98Nov0spY,704
5
+ route_rules/_version.pyi,sha256=orSoCtn5mtEK2EOwbIDrqDsAe9HFoRVuN4ssTxbz-Xg,239
6
+ route_rules/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ route_rules/core/__init__.py,sha256=LDgi9R9sVRcyI8T-oOMzoXQl_F5SmAEM85WIY1TBZgw,149
8
+ route_rules/core/__init__.pyi,sha256=xlq2AY-Trk9Levivi38gIz6heHv3gxrv4QEep2NbrEQ,53
9
+ route_rules/core/_ruleset.py,sha256=p7_NS_h5bPDbBlYduHIuZEkVtZ205WJ6AqjRKvltlHA,1572
10
+ route_rules/export/__init__.py,sha256=LDgi9R9sVRcyI8T-oOMzoXQl_F5SmAEM85WIY1TBZgw,149
11
+ route_rules/export/__init__.pyi,sha256=Xn-_8BA4u19PkAKlw0UsSZcOgVuAFLmU9fAchtcp7gQ,105
12
+ route_rules/export/_abc.py,sha256=IcfuToIx0Jxv1osVNdkM7_aCwHzocLgT4rFt_Ni-A1c,445
13
+ route_rules/export/_mihomo.py,sha256=33gPlJPH0Tt8vVS6wv6esW8h0p_HwGIWBj4D_LvtB04,3413
14
+ route_rules/gen/__init__.py,sha256=LDgi9R9sVRcyI8T-oOMzoXQl_F5SmAEM85WIY1TBZgw,149
15
+ route_rules/gen/__init__.pyi,sha256=Rwqed9KhIkySASk5ah5u39G0JcPF6Ymthd9YCZXWqCA,292
16
+ route_rules/gen/_builder.py,sha256=nb5-t5IV2PNg93uyxgVArCOc4vAzKMf6xgrKvSnHGmg,3228
17
+ route_rules/gen/_config.py,sha256=N1A1kLuWn8KiyTasBhOSO-DXxO99PIJDS8cIYa0xAD0,445
18
+ route_rules/gen/_meta.py,sha256=1SxIKXq-hNzqlOSVD2rbz3KYI11TbllOBdompUbRKJI,1417
19
+ route_rules/gen/_recipe.py,sha256=VGw_XDSazJtyrKnw7kI9TkH2B_Jb9HcJslO_uqpWAXA,296
20
+ route_rules/provider/__init__.py,sha256=LDgi9R9sVRcyI8T-oOMzoXQl_F5SmAEM85WIY1TBZgw,149
21
+ route_rules/provider/__init__.pyi,sha256=mnZ8D2EUwlN0tmzjuSEgom5I2-ykv_hOJg9oKoTlfRE,262
22
+ route_rules/provider/_abc.py,sha256=dhGnyK4oz-051r-S4pN0lwPqwxQNMjyKvjwGwXhH6p0,972
23
+ route_rules/provider/_registry.py,sha256=7sWLYEXOyCXVhpzxNqF7A261_lR7PQNJ_uGjtioI9dw,3293
24
+ route_rules/provider/mihomo/__init__.py,sha256=LDgi9R9sVRcyI8T-oOMzoXQl_F5SmAEM85WIY1TBZgw,149
25
+ route_rules/provider/mihomo/__init__.pyi,sha256=Pzs9HVc0nPvnCXoA-WmL14q1EteLAFvZE8t9vBAk04I,164
26
+ route_rules/provider/mihomo/_decode.py,sha256=I0W-4r2KnJ21Gdp14QVNetrexURtUzIgGCmfqKfBOgk,2123
27
+ route_rules/provider/mihomo/_enum.py,sha256=DVigBAhPzevaRvIQHn6CqUpsJuqpDAyV-Y3mXWNuV40,394
28
+ route_rules/provider/mihomo/_provider.py,sha256=4tPsZ7HsiZYUEFgxF5x7-NojDOssHTO_4oSUw2vnnjc,706
29
+ route_rules/utils/__init__.py,sha256=OHb6Xou2v6u42swTgjRfzej4CIlRg4OmgOIQXUiRjKA,97
30
+ route_rules/utils/__init__.pyi,sha256=TjlslbdFPF7BLhFRf5ct7ckzeAg14cqcRVFQjZMi_bk,107
31
+ route_rules/utils/_download.py,sha256=vvo0WbfEBjKTMZy2-__tNqPzus4_U_5VgoZOGQO08ds,489
32
+ route_rules/utils/_slugify.py,sha256=EdTTIiouSPgNEXaWOTP4pJcCjWX5LVyxFhXcS5aXjDw,120
33
+ route_rules-0.2.2.dist-info/METADATA,sha256=srhyveQTu39phBVU5ZtZYxawZdMMjYK3fWzXF0Y2m7U,1631
34
+ route_rules-0.2.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
35
+ route_rules-0.2.2.dist-info/licenses/LICENSE,sha256=buKmmsDdpMgyd9Nu_4brBJuv_Lytvnk1Ikq3v-QRX7I,1063
36
+ route_rules-0.2.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 liblaf
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.