route-rules 0.2.2__tar.gz

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.
Files changed (37) hide show
  1. route_rules-0.2.2/.gitignore +192 -0
  2. route_rules-0.2.2/LICENSE +21 -0
  3. route_rules-0.2.2/PKG-INFO +40 -0
  4. route_rules-0.2.2/README.md +1 -0
  5. route_rules-0.2.2/pyproject.toml +105 -0
  6. route_rules-0.2.2/src/route_rules/__init__.py +3 -0
  7. route_rules-0.2.2/src/route_rules/__init__.pyi +42 -0
  8. route_rules-0.2.2/src/route_rules/_recipe.py +33 -0
  9. route_rules-0.2.2/src/route_rules/_version.py +34 -0
  10. route_rules-0.2.2/src/route_rules/_version.pyi +8 -0
  11. route_rules-0.2.2/src/route_rules/core/__init__.py +5 -0
  12. route_rules-0.2.2/src/route_rules/core/__init__.pyi +3 -0
  13. route_rules-0.2.2/src/route_rules/core/_ruleset.py +53 -0
  14. route_rules-0.2.2/src/route_rules/export/__init__.py +5 -0
  15. route_rules-0.2.2/src/route_rules/export/__init__.pyi +4 -0
  16. route_rules-0.2.2/src/route_rules/export/_abc.py +19 -0
  17. route_rules-0.2.2/src/route_rules/export/_mihomo.py +121 -0
  18. route_rules-0.2.2/src/route_rules/gen/__init__.py +5 -0
  19. route_rules-0.2.2/src/route_rules/gen/__init__.pyi +14 -0
  20. route_rules-0.2.2/src/route_rules/gen/_builder.py +87 -0
  21. route_rules-0.2.2/src/route_rules/gen/_config.py +21 -0
  22. route_rules-0.2.2/src/route_rules/gen/_meta.py +58 -0
  23. route_rules-0.2.2/src/route_rules/gen/_recipe.py +14 -0
  24. route_rules-0.2.2/src/route_rules/provider/__init__.py +5 -0
  25. route_rules-0.2.2/src/route_rules/provider/__init__.pyi +13 -0
  26. route_rules-0.2.2/src/route_rules/provider/_abc.py +36 -0
  27. route_rules-0.2.2/src/route_rules/provider/_registry.py +91 -0
  28. route_rules-0.2.2/src/route_rules/provider/mihomo/__init__.py +5 -0
  29. route_rules-0.2.2/src/route_rules/provider/mihomo/__init__.pyi +5 -0
  30. route_rules-0.2.2/src/route_rules/provider/mihomo/_decode.py +73 -0
  31. route_rules-0.2.2/src/route_rules/provider/mihomo/_enum.py +21 -0
  32. route_rules-0.2.2/src/route_rules/provider/mihomo/_provider.py +24 -0
  33. route_rules-0.2.2/src/route_rules/py.typed +0 -0
  34. route_rules-0.2.2/src/route_rules/utils/__init__.py +3 -0
  35. route_rules-0.2.2/src/route_rules/utils/__init__.pyi +4 -0
  36. route_rules-0.2.2/src/route_rules/utils/_download.py +16 -0
  37. route_rules-0.2.2/src/route_rules/utils/_slugify.py +7 -0
@@ -0,0 +1,192 @@
1
+ # Created by https://www.toptal.com/developers/gitignore/api/python
2
+ # Edit at https://www.toptal.com/developers/gitignore?templates=python
3
+
4
+ ### Python ###
5
+ # Byte-compiled / optimized / DLL files
6
+ __pycache__/
7
+ *.py[cod]
8
+ *$py.class
9
+
10
+ # C extensions
11
+ *.so
12
+
13
+ # Distribution / packaging
14
+ .Python
15
+ build/
16
+ develop-eggs/
17
+ dist/
18
+ downloads/
19
+ eggs/
20
+ .eggs/
21
+ lib/
22
+ lib64/
23
+ parts/
24
+ sdist/
25
+ var/
26
+ wheels/
27
+ share/python-wheels/
28
+ *.egg-info/
29
+ .installed.cfg
30
+ *.egg
31
+ MANIFEST
32
+
33
+ # PyInstaller
34
+ # Usually these files are written by a python script from a template
35
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
36
+ *.manifest
37
+ *.spec
38
+
39
+ # Installer logs
40
+ pip-log.txt
41
+ pip-delete-this-directory.txt
42
+
43
+ # Unit test / coverage reports
44
+ htmlcov/
45
+ .tox/
46
+ .nox/
47
+ .coverage
48
+ .coverage.*
49
+ .cache
50
+ nosetests.xml
51
+ coverage.xml
52
+ *.cover
53
+ *.py,cover
54
+ .hypothesis/
55
+ .pytest_cache/
56
+ cover/
57
+
58
+ # Translations
59
+ *.mo
60
+ *.pot
61
+
62
+ # Django stuff:
63
+ *.log
64
+ local_settings.py
65
+ db.sqlite3
66
+ db.sqlite3-journal
67
+
68
+ # Flask stuff:
69
+ instance/
70
+ .webassets-cache
71
+
72
+ # Scrapy stuff:
73
+ .scrapy
74
+
75
+ # Sphinx documentation
76
+ docs/_build/
77
+
78
+ # PyBuilder
79
+ .pybuilder/
80
+ target/
81
+
82
+ # Jupyter Notebook
83
+ .ipynb_checkpoints
84
+
85
+ # IPython
86
+ profile_default/
87
+ ipython_config.py
88
+
89
+ # pyenv
90
+ # For a library or package, you might want to ignore these files since the code is
91
+ # intended to run in multiple environments; otherwise, check them in:
92
+ # .python-version
93
+
94
+ # pipenv
95
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
96
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
97
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
98
+ # install all needed dependencies.
99
+ #Pipfile.lock
100
+
101
+ # poetry
102
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
103
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
104
+ # commonly ignored for libraries.
105
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
106
+ #poetry.lock
107
+
108
+ # pdm
109
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
110
+ #pdm.lock
111
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
112
+ # in version control.
113
+ # https://pdm.fming.dev/#use-with-ide
114
+ .pdm.toml
115
+
116
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
117
+ __pypackages__/
118
+
119
+ # Celery stuff
120
+ celerybeat-schedule
121
+ celerybeat.pid
122
+
123
+ # SageMath parsed files
124
+ *.sage.py
125
+
126
+ # Environments
127
+ .env
128
+ .venv
129
+ env/
130
+ venv/
131
+ ENV/
132
+ env.bak/
133
+ venv.bak/
134
+
135
+ # Spyder project settings
136
+ .spyderproject
137
+ .spyproject
138
+
139
+ # Rope project settings
140
+ .ropeproject
141
+
142
+ # mkdocs documentation
143
+ /site
144
+
145
+ # mypy
146
+ .mypy_cache/
147
+ .dmypy.json
148
+ dmypy.json
149
+
150
+ # Pyre type checker
151
+ .pyre/
152
+
153
+ # pytype static type analyzer
154
+ .pytype/
155
+
156
+ # Cython debug symbols
157
+ cython_debug/
158
+
159
+ # PyCharm
160
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
161
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
162
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
163
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
164
+ #.idea/
165
+
166
+ ### Python Patch ###
167
+ # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
168
+ poetry.toml
169
+
170
+ # ruff
171
+ .ruff_cache/
172
+
173
+ # LSP config files
174
+ # pyrightconfig.json
175
+
176
+ # End of https://www.toptal.com/developers/gitignore/api/python
177
+
178
+ # hatch-vcs
179
+ _version.py
180
+
181
+ # pytest
182
+ junit.xml
183
+
184
+ # pytest-benchmark
185
+ .benchmarks/
186
+
187
+ # pytest-codspeed
188
+ .codspeed/
189
+
190
+ *.log
191
+ *.log.*
192
+ playground/
@@ -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.
@@ -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 @@
1
+ # Route Rules
@@ -0,0 +1,105 @@
1
+ #:schema https://json.schemastore.org/pyproject.json
2
+ # ref: <https://packaging.python.org/en/latest/specifications/pyproject-toml/>
3
+
4
+ [project]
5
+ name = "route-rules"
6
+ description = ""
7
+ readme = "README.md"
8
+ requires-python = ">=3.12"
9
+ license = "MIT"
10
+ authors = [
11
+ { name = "liblaf", email = "30631553+liblaf@users.noreply.github.com" },
12
+ ]
13
+ keywords = []
14
+ classifiers = [
15
+ # ref: <https://pypi.org/classifiers/>
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = [
28
+ "anyio>=4,<5",
29
+ "autoregistry>=1,<3",
30
+ "cachetools-async>=0.0.5,<0.0.6",
31
+ "cachetools>=6,<7",
32
+ "cytoolz>=1,<2",
33
+ "hishel>=0.1,<0.2",
34
+ "httpx[socks]>=0.28,<0.29",
35
+ "lazy-loader>=0.4,<0.5",
36
+ "liblaf-grapes>=4,<5",
37
+ "loguru>=0.7,<0.8",
38
+ "prettytable>=3,<4",
39
+ "python-slugify>=8,<9",
40
+ "validators>=0.35,<0.36",
41
+ ]
42
+ dynamic = ["version"]
43
+
44
+ [project.urls]
45
+ "Changelog" = "https://github.com/liblaf/route-rules/blob/main/CHANGELOG.md"
46
+ "Documentation" = "https://liblaf.github.io/route-rules/"
47
+ "Funding" = "https://github.com/liblaf/route-rules?sponsor=1"
48
+ "Homepage" = "https://github.com/liblaf/route-rules"
49
+ "Issue Tracker" = "https://github.com/liblaf/route-rules/issues"
50
+ "Release Notes" = "https://github.com/liblaf/route-rules/releases"
51
+ "Source Code" = "https://github.com/liblaf/route-rules"
52
+
53
+ [dependency-groups]
54
+ build = ["check-wheel-contents>=0.6,<0.7", "hatch>=1,<2", "twine>=6,<7"]
55
+ dev = ["icecream>=2,<3"]
56
+ docs = [
57
+ "liblaf-mkdocs-preset>=0.2,<0.3",
58
+ "mkdocs-gen-files>=0.5,<0.6",
59
+ "mkdocs>=1,<2",
60
+ ]
61
+ test = ["liblaf-pytest-preset>=0.1,<0.2", "pytest>=8,<9"]
62
+
63
+ [build-system]
64
+ requires = ["hatch-vcs", "hatchling"]
65
+ build-backend = "hatchling.build"
66
+
67
+ [tool.check-wheel-contents]
68
+ ignore = ["W002"]
69
+
70
+ [tool.coverage.run]
71
+ branch = true
72
+ source = ["src"]
73
+
74
+ [tool.hatch.build.hooks.vcs]
75
+ version-file = "src/route_rules/_version.py"
76
+
77
+ [tool.hatch.build.targets.sdist]
78
+ only-include = ["src"]
79
+
80
+ [tool.hatch.build.targets.wheel]
81
+ packages = ["src/route_rules"]
82
+
83
+ [tool.hatch.version]
84
+ source = "vcs"
85
+
86
+ [tool.pyright]
87
+ exclude = ["**/.*", "**/__pycache__", "**/marimo", "**/node_modules"]
88
+ extends = ".config/linters/pyrightconfig.json"
89
+
90
+ [tool.pytest.ini_options]
91
+ addopts = [
92
+ "--doctest-modules",
93
+ "--hypothesis-show-statistics",
94
+ "--showlocals",
95
+ "--strict-config",
96
+ "--strict-markers",
97
+ "--suppress-no-test-exit-code",
98
+ ]
99
+ testpaths = ["benches", "src", "tests"]
100
+
101
+ [tool.ruff]
102
+ extend = ".config/linters/.ruff.toml"
103
+
104
+ [tool.uv]
105
+ default-groups = "all"
@@ -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
+ ]
@@ -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)
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)