clash-sub-manager 0.1.0__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 (145) hide show
  1. clash_sub_manager-0.1.0/PKG-INFO +17 -0
  2. clash_sub_manager-0.1.0/README.md +0 -0
  3. clash_sub_manager-0.1.0/pyproject.toml +154 -0
  4. clash_sub_manager-0.1.0/src/clash_sub_manager/__init__.py +5 -0
  5. clash_sub_manager-0.1.0/src/clash_sub_manager/__main__.py +4 -0
  6. clash_sub_manager-0.1.0/src/clash_sub_manager/api/__init__.py +3 -0
  7. clash_sub_manager-0.1.0/src/clash_sub_manager/api/dependencies.py +19 -0
  8. clash_sub_manager-0.1.0/src/clash_sub_manager/api/routes/__init__.py +22 -0
  9. clash_sub_manager-0.1.0/src/clash_sub_manager/api/routes/_db.py +34 -0
  10. clash_sub_manager-0.1.0/src/clash_sub_manager/api/routes/_rule_providers.py +26 -0
  11. clash_sub_manager-0.1.0/src/clash_sub_manager/api/routes/composite_templates.py +204 -0
  12. clash_sub_manager-0.1.0/src/clash_sub_manager/api/routes/config.py +172 -0
  13. clash_sub_manager-0.1.0/src/clash_sub_manager/api/routes/convert.py +29 -0
  14. clash_sub_manager-0.1.0/src/clash_sub_manager/api/routes/merge.py +29 -0
  15. clash_sub_manager-0.1.0/src/clash_sub_manager/api/routes/merge_profiles.py +254 -0
  16. clash_sub_manager-0.1.0/src/clash_sub_manager/api/routes/rules.py +93 -0
  17. clash_sub_manager-0.1.0/src/clash_sub_manager/api/routes/template_patches.py +118 -0
  18. clash_sub_manager-0.1.0/src/clash_sub_manager/api/schemas.py +289 -0
  19. clash_sub_manager-0.1.0/src/clash_sub_manager/api/schemas_patch.py +184 -0
  20. clash_sub_manager-0.1.0/src/clash_sub_manager/api/server.py +46 -0
  21. clash_sub_manager-0.1.0/src/clash_sub_manager/cli.py +38 -0
  22. clash_sub_manager-0.1.0/src/clash_sub_manager/core/__init__.py +20 -0
  23. clash_sub_manager-0.1.0/src/clash_sub_manager/core/composer.py +48 -0
  24. clash_sub_manager-0.1.0/src/clash_sub_manager/core/converter.py +125 -0
  25. clash_sub_manager-0.1.0/src/clash_sub_manager/core/fetcher.py +44 -0
  26. clash_sub_manager-0.1.0/src/clash_sub_manager/core/merger.py +102 -0
  27. clash_sub_manager-0.1.0/src/clash_sub_manager/core/patch.py +317 -0
  28. clash_sub_manager-0.1.0/src/clash_sub_manager/core/rules.py +91 -0
  29. clash_sub_manager-0.1.0/src/clash_sub_manager/core/template.py +213 -0
  30. clash_sub_manager-0.1.0/src/clash_sub_manager/db/__init__.py +20 -0
  31. clash_sub_manager-0.1.0/src/clash_sub_manager/db/base.py +21 -0
  32. clash_sub_manager-0.1.0/src/clash_sub_manager/db/models.py +88 -0
  33. clash_sub_manager-0.1.0/src/clash_sub_manager/db/models_patch.py +47 -0
  34. clash_sub_manager-0.1.0/src/clash_sub_manager/db/session.py +52 -0
  35. clash_sub_manager-0.1.0/src/clash_sub_manager/models/__init__.py +21 -0
  36. clash_sub_manager-0.1.0/src/clash_sub_manager/models/clash.py +26 -0
  37. clash_sub_manager-0.1.0/src/clash_sub_manager/models/proxy.py +88 -0
  38. clash_sub_manager-0.1.0/src/clash_sub_manager/models/subscription.py +35 -0
  39. clash_sub_manager-0.1.0/src/clash_sub_manager/parsers/__init__.py +84 -0
  40. clash_sub_manager-0.1.0/src/clash_sub_manager/parsers/base.py +101 -0
  41. clash_sub_manager-0.1.0/src/clash_sub_manager/parsers/clash.py +157 -0
  42. clash_sub_manager-0.1.0/src/clash_sub_manager/parsers/ss.py +73 -0
  43. clash_sub_manager-0.1.0/src/clash_sub_manager/parsers/ssr.py +49 -0
  44. clash_sub_manager-0.1.0/src/clash_sub_manager/parsers/trojan.py +56 -0
  45. clash_sub_manager-0.1.0/src/clash_sub_manager/parsers/vmess.py +65 -0
  46. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/.gitkeep +0 -0
  47. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/abap-DLDM7-KI.js +1 -0
  48. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/apex-DNDY2TF8.js +1 -0
  49. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/azcli-Y6nb8tq_.js +1 -0
  50. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/bat-BwHxbl9M.js +1 -0
  51. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/bicep-CFznDFnq.js +2 -0
  52. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/cameligo-Bf6VGUru.js +1 -0
  53. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/clojure-Dnu-v4kV.js +1 -0
  54. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/codicon-ngg6Pgfi.ttf +0 -0
  55. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/coffee-Bd8akH9Z.js +1 -0
  56. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/cpp-BbWJElDN.js +1 -0
  57. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/csharp-Co3qMtFm.js +1 -0
  58. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/csp-D-4FJmMZ.js +1 -0
  59. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/css-DdJfP1eB.js +3 -0
  60. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/css.worker-DBVD8oXr.js +93 -0
  61. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/cssMode-CEwRweiE.js +1 -0
  62. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/cypher-cTPe9QuQ.js +1 -0
  63. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/dart-BOtBlQCF.js +1 -0
  64. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/dockerfile-BG73LgW2.js +1 -0
  65. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/ecl-BEgZUVRK.js +1 -0
  66. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/editor.worker-CdQrwHl8.js +26 -0
  67. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/elixir-BkW5O-1t.js +1 -0
  68. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/flow9-BeJ5waoc.js +1 -0
  69. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/freemarker2-C8vB75nR.js +3 -0
  70. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/fsharp-PahG7c26.js +1 -0
  71. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/go-acbASCJo.js +1 -0
  72. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/graphql-BxJiqAUM.js +1 -0
  73. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/handlebars-CoS2kdk8.js +1 -0
  74. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/hcl-DtV1sZF8.js +1 -0
  75. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/html-DYVgKgkF.js +1 -0
  76. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/html.worker-CwpTb9lJ.js +470 -0
  77. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/htmlMode-_KQ2OpU9.js +1 -0
  78. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/index-BcrtEVFj.js +943 -0
  79. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/index-DIAjxuWt.css +1 -0
  80. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/ini-Kd9XrMLS.js +1 -0
  81. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/java-CXBNlu9o.js +1 -0
  82. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/javascript-kytjXFg_.js +1 -0
  83. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/json.worker-BoL8UZqY.js +58 -0
  84. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/jsonMode-CK5jLb5D.js +7 -0
  85. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/julia-cl7-CwDS.js +1 -0
  86. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/kotlin-s7OhZKlX.js +1 -0
  87. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/less-9HpZscsL.js +2 -0
  88. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/lexon-OrD6JF1K.js +1 -0
  89. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/liquid-7rnuAQ6t.js +1 -0
  90. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/lspLanguageFeatures-CCNtv2Fl.js +4 -0
  91. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/lua-Cyyb5UIc.js +1 -0
  92. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/m3-B8OfTtLu.js +1 -0
  93. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/markdown-BFxVWTOG.js +1 -0
  94. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/materialdesignicons-webfont-B7mPwVP_.ttf +0 -0
  95. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/materialdesignicons-webfont-CSr8KVlo.eot +0 -0
  96. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/materialdesignicons-webfont-Dp5v-WZN.woff2 +0 -0
  97. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/materialdesignicons-webfont-PXm3-2wK.woff +0 -0
  98. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/mdx-C64lqVrO.js +1 -0
  99. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/mips-CiqrrVzr.js +1 -0
  100. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/msdax-DmeGPVcC.js +1 -0
  101. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/mysql-C_tMU-Nz.js +1 -0
  102. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/objective-c-BDtDVThU.js +1 -0
  103. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/pascal-vHIfCaH5.js +1 -0
  104. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/pascaligo-DtZ0uQbO.js +1 -0
  105. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/perl-Ub6l9XKa.js +1 -0
  106. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/pgsql-BlNEE0v7.js +1 -0
  107. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/php-BBUBE1dy.js +1 -0
  108. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/pla-DSh2-awV.js +1 -0
  109. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/postiats-CocnycG-.js +1 -0
  110. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/powerquery-tScXyioY.js +1 -0
  111. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/powershell-COWaemsV.js +1 -0
  112. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/protobuf-Brw8urJB.js +2 -0
  113. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/pug-8SOpv6rk.js +1 -0
  114. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/python-CkjFJQTJ.js +1 -0
  115. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/qsharp-Bw9ernYp.js +1 -0
  116. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/r-j7ic8hl3.js +1 -0
  117. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/razor-DlJimliK.js +1 -0
  118. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/redis-Bu5POkcn.js +1 -0
  119. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/redshift-Bs9aos_-.js +1 -0
  120. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/restructuredtext-CqXO7rUv.js +1 -0
  121. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/ruby-zBfavPgS.js +1 -0
  122. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/rust-BzKRNQWT.js +1 -0
  123. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/sb-BBc9UKZt.js +1 -0
  124. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/scala-D9hQfWCl.js +1 -0
  125. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/scheme-BPhDTwHR.js +1 -0
  126. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/scss-CBJaRo0y.js +3 -0
  127. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/shell-DiJ1NA_G.js +1 -0
  128. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/solidity-Db0IVjzk.js +1 -0
  129. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/sophia-CnS9iZB_.js +1 -0
  130. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/sparql-CJmd_6j2.js +1 -0
  131. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/sql-ClhHkBeG.js +1 -0
  132. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/st-CHwy0fLd.js +1 -0
  133. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/swift-CnmFD0ga.js +1 -0
  134. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/systemverilog-Bs9z6M-B.js +1 -0
  135. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/tcl-Dm6ycUr_.js +1 -0
  136. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/ts.worker-BH9nVgjN.js +67718 -0
  137. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/tsMode-Ct41V8vy.js +11 -0
  138. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/twig-Csy3S7wG.js +1 -0
  139. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/typescript-CMZ7uylT.js +1 -0
  140. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/typespec-Btyra-wh.js +1 -0
  141. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/vb-Db0cS2oM.js +1 -0
  142. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/wgsl-BTesnYfV.js +298 -0
  143. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/xml-Dj5Mm3BH.js +1 -0
  144. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/assets/yaml-CrX2yQz_.js +1 -0
  145. clash_sub_manager-0.1.0/src/clash_sub_manager/static/webui/index.html +13 -0
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.3
2
+ Name: clash-sub-manager
3
+ Version: 0.1.0
4
+ Summary: Clash subscription management web service
5
+ Author: shoucandanghehe
6
+ Author-email: shoucandanghehe <wallfjjd@gmail.com>
7
+ Requires-Dist: fastapi>=0.135.1
8
+ Requires-Dist: pydantic>=2.12.5
9
+ Requires-Dist: uvicorn>=0.38.0
10
+ Requires-Dist: cyclopts>=4.9.0
11
+ Requires-Dist: httpx>=0.27.0
12
+ Requires-Dist: pyyaml>=6.0.1
13
+ Requires-Dist: sqlalchemy[asyncio]>=2.0
14
+ Requires-Dist: aiosqlite>=0.20.0
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+
File without changes
@@ -0,0 +1,154 @@
1
+ [project]
2
+ name = "clash-sub-manager"
3
+ version = "0.1.0"
4
+ description = "Clash subscription management web service"
5
+ readme = "README.md"
6
+ authors = [{ name = "shoucandanghehe", email = "wallfjjd@gmail.com" }]
7
+ requires-python = ">=3.10"
8
+ dependencies = [
9
+ "fastapi>=0.135.1",
10
+ "pydantic>=2.12.5",
11
+ "uvicorn>=0.38.0",
12
+ "cyclopts>=4.9.0",
13
+ "httpx>=0.27.0",
14
+ "pyyaml>=6.0.1",
15
+ "sqlalchemy[asyncio]>=2.0",
16
+ "aiosqlite>=0.20.0",
17
+ ]
18
+
19
+ [project.scripts]
20
+ clash-sub-manager = "clash_sub_manager.cli:main"
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.10.6,<0.11.0"]
24
+ build-backend = "uv_build"
25
+
26
+ [tool.fastapi]
27
+ entrypoint = "clash_sub_manager.api.server:app"
28
+
29
+ [tool.ruff]
30
+ line-length = 120
31
+ target-version = "py310"
32
+
33
+ [tool.ruff.lint]
34
+ select = [
35
+ "F", # pyflakes
36
+ "E", # pycodestyle errors
37
+ "W", # pycodestyle warnings
38
+ "C90", # mccabe
39
+ "I", # isort
40
+ "N", # PEP8-naming
41
+ "UP", # pyupgrade
42
+ "YTT", # flake8-2020
43
+ "ANN", # flake8-annotations
44
+ "ASYNC", # flake8-async
45
+ "S", # flake8-bandit
46
+ "BLE", # flake8-blind-except
47
+ "FBT", # flake8-boolean-trap
48
+ "B", # flake8-bugbear
49
+ "A", # flake8-builtins
50
+ "COM", # flake8-commas
51
+ "C4", # flake8-comprehensions
52
+ "DTZ", # flake8-datetimez
53
+ "T10", # flake8-debugger
54
+ "EM", # flake8-errmsg
55
+ "FA", # flake8-future-annotations
56
+ "ISC", # flake8-implicit-str-concat
57
+ "ICN", # flake8-import-conventions
58
+ "PIE", # flake8-pie
59
+ "T20", # flake8-print
60
+ "PYI", # flake8-pyi
61
+ "Q", # flake8-quotes
62
+ "RSE", # flake8-raise
63
+ "RET", # flake8-return
64
+ "SLF", # flake8-self
65
+ "SLOT", # flake8-slots
66
+ "SIM", # flake8-simplify
67
+ "TID", # flake8-tidy-imports
68
+ "TC", # flake8-type-checking
69
+ "ARG", # flake8-unused-arguments
70
+ "PTH", # flake8-use-pathlib
71
+ "ERA", # eradicate
72
+ "PD", # pandas-vet
73
+ "PGH", # pygrep-hooks
74
+ "PL", # pylint
75
+ "TRY", # tryceratops
76
+ "FLY", # flynt
77
+ "FAST", # FastAPI
78
+ "PERF", # Perflint
79
+ "FURB", # refurb
80
+ "RUF", # Ruff-specific rules
81
+ ]
82
+ ignore = [
83
+ "E501", # 过长的行由 ruff format 处理, 剩余的都是字符串
84
+ "TRY003",
85
+ "COM812", # 强制尾随逗号
86
+ "TID252", # 相对导入
87
+ "ISC001", # format warning
88
+ ]
89
+ flake8-quotes = { inline-quotes = "single", multiline-quotes = "double" }
90
+
91
+ [tool.ruff.lint.flake8-annotations]
92
+ mypy-init-return = true
93
+
94
+ [tool.ruff.lint.flake8-builtins]
95
+ builtins-ignorelist = ["id"]
96
+
97
+ [tool.ruff.lint.per-file-ignores]
98
+ "tests/**/*.py" = ["S101", "S603", "PLR2004"]
99
+
100
+ [tool.ruff.format]
101
+ quote-style = "single"
102
+
103
+ [tool.basedpyright]
104
+ pythonVersion = "3.10"
105
+ pythonPlatform = "All"
106
+ typeCheckingMode = "standard"
107
+ reportExplicitAny = 'hint'
108
+ reportUnnecessaryTypeIgnoreComment = 'error'
109
+ reportImplicitOverride = 'error'
110
+ reportUnnecessaryComparison = 'error'
111
+ reportImplicitAbstractClass = 'error'
112
+ enableTypeIgnoreComments = false
113
+
114
+ [tool.bumpversion]
115
+ parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
116
+ serialize = ["{major}.{minor}.{patch}"]
117
+ search = "{current_version}"
118
+ replace = "{new_version}"
119
+ regex = false
120
+ ignore_missing_version = false
121
+ ignore_missing_files = false
122
+ tag = true
123
+ sign_tags = true
124
+ tag_name = "v{new_version}"
125
+ tag_message = "🔖 bump version {new_version}"
126
+ allow_dirty = true
127
+ commit = true
128
+ message = "🔖 bump version {new_version}"
129
+ moveable_tags = []
130
+ commit_args = ""
131
+ setup_hooks = []
132
+ pre_commit_hooks = []
133
+ post_commit_hooks = []
134
+
135
+ [[tool.bumpversion.files]]
136
+ filename = "pyproject.toml"
137
+ search = "version = \"{current_version}\""
138
+ replace = "version = \"{new_version}\""
139
+
140
+ [[tool.bumpversion.files]]
141
+ filename = "src/clash_sub_manager/__init__.py"
142
+ search = "__version__ = '{current_version}'"
143
+ replace = "__version__ = '{new_version}'"
144
+
145
+
146
+ [dependency-groups]
147
+ dev = [
148
+ "basedpyright>=1.38.2",
149
+ "bump-my-version>=1.2.4",
150
+ "mypy>=1.19.1",
151
+ "ruff>=0.15.6",
152
+ "pytest>=8.4.2",
153
+ "pytest-asyncio>=1.2.0",
154
+ ]
@@ -0,0 +1,5 @@
1
+ """Clash subscription management web service."""
2
+
3
+ __all__ = ['__version__']
4
+
5
+ __version__ = '0.1.0'
@@ -0,0 +1,4 @@
1
+ from clash_sub_manager.cli import main
2
+
3
+ if __name__ == '__main__':
4
+ main()
@@ -0,0 +1,3 @@
1
+ from .server import app, create_app
2
+
3
+ __all__ = ['app', 'create_app']
@@ -0,0 +1,19 @@
1
+ """FastAPI dependency helpers."""
2
+
3
+ from collections.abc import AsyncIterator
4
+
5
+ from fastapi import Request
6
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
7
+
8
+
9
+ def get_session_factory(request: Request) -> async_sessionmaker[AsyncSession]:
10
+ return request.app.state.session_factory
11
+
12
+
13
+ async def get_db_session(request: Request) -> AsyncIterator[AsyncSession]:
14
+ session_factory = get_session_factory(request)
15
+ async with session_factory() as session:
16
+ yield session
17
+
18
+
19
+ __all__ = ['get_db_session', 'get_session_factory']
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from .composite_templates import router as composite_templates_router
6
+ from .config import router as config_router
7
+ from .convert import router as convert_router
8
+ from .merge import router as merge_router
9
+ from .merge_profiles import router as merge_profiles_router
10
+ from .rules import router as rules_router
11
+ from .template_patches import router as template_patches_router
12
+
13
+ api_router = APIRouter()
14
+ api_router.include_router(convert_router)
15
+ api_router.include_router(merge_router)
16
+ api_router.include_router(merge_profiles_router)
17
+ api_router.include_router(composite_templates_router)
18
+ api_router.include_router(template_patches_router)
19
+ api_router.include_router(config_router)
20
+ api_router.include_router(rules_router)
21
+
22
+ __all__ = ['api_router']
@@ -0,0 +1,34 @@
1
+ """Shared database helpers for API routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from fastapi import HTTPException, status
8
+ from sqlalchemy.exc import IntegrityError
9
+
10
+ if TYPE_CHECKING:
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+
14
+ def _is_unique_name_violation(exc: IntegrityError, *, table_name: str) -> bool:
15
+ message = str(exc.orig).lower()
16
+ return 'unique' in message and (
17
+ f'{table_name}.name' in message or f'uq_{table_name}_name' in message
18
+ )
19
+
20
+
21
+ async def commit_or_name_conflict(db: AsyncSession, *, resource_name: str, table_name: str) -> None:
22
+ try:
23
+ await db.commit()
24
+ except IntegrityError as exc:
25
+ await db.rollback()
26
+ if _is_unique_name_violation(exc, table_name=table_name):
27
+ raise HTTPException(
28
+ status_code=status.HTTP_409_CONFLICT,
29
+ detail=f'{resource_name} name already exists',
30
+ ) from exc
31
+ raise
32
+
33
+
34
+ __all__ = ['commit_or_name_conflict']
@@ -0,0 +1,26 @@
1
+ """Helpers for rewriting template rule-providers to project-served cached URLs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from sqlalchemy import select
8
+
9
+ from ...db.models import RuleSource
10
+
11
+ if TYPE_CHECKING:
12
+ from fastapi import Request
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+
15
+
16
+ async def build_cached_rule_provider_urls(db: AsyncSession, request: Request) -> dict[str, str]:
17
+ sources = list((await db.scalars(select(RuleSource).order_by(RuleSource.id))).all())
18
+ mappings: dict[str, str] = {}
19
+ for source in sources:
20
+ cached_url = str(request.url_for('get_cached_rule_provider', rule_source_id=source.id))
21
+ mappings[source.name] = cached_url
22
+ mappings[source.url] = cached_url
23
+ return mappings
24
+
25
+
26
+ __all__ = ['build_cached_rule_provider_urls']
@@ -0,0 +1,204 @@
1
+ """CRUD endpoints for composed templates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Response, status
8
+ from sqlalchemy import select
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+ from sqlalchemy.orm import selectinload
11
+
12
+ from ...core import PatchValidationError, TemplateComposer
13
+ from ...db import CompositeTemplate, MergeProfile, Template, TemplatePatch
14
+ from ..dependencies import get_db_session
15
+ from ..schemas import TemplateSummaryRead
16
+ from ..schemas_patch import (
17
+ CompositePreviewRead,
18
+ CompositeTemplateCreate,
19
+ CompositeTemplatePreviewRequest,
20
+ CompositeTemplateRead,
21
+ CompositeTemplateUpdate,
22
+ TemplatePatchSummaryRead,
23
+ )
24
+ from ._db import commit_or_name_conflict
25
+
26
+ router = APIRouter(tags=['composite-templates'])
27
+ DbSession = Annotated[AsyncSession, Depends(get_db_session)]
28
+
29
+
30
+ def _serialize_patch_summary(patch: TemplatePatch) -> TemplatePatchSummaryRead:
31
+ return TemplatePatchSummaryRead.model_validate(patch)
32
+
33
+
34
+ def _serialize_composite_template(composite: CompositeTemplate, patches: list[TemplatePatch]) -> CompositeTemplateRead:
35
+ return CompositeTemplateRead(
36
+ id=composite.id,
37
+ name=composite.name,
38
+ base_template_id=composite.base_template_id,
39
+ patch_sequence=list(composite.patch_sequence),
40
+ cached_content=composite.cached_content,
41
+ created_at=composite.created_at,
42
+ updated_at=composite.updated_at,
43
+ base_template=TemplateSummaryRead.model_validate(composite.base_template),
44
+ patches=[_serialize_patch_summary(patch) for patch in patches],
45
+ )
46
+
47
+
48
+ async def _get_template_or_404(db: AsyncSession, template_id: int) -> Template:
49
+ template = await db.get(Template, template_id)
50
+ if template is None:
51
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='template not found')
52
+ return template
53
+
54
+
55
+ async def _get_patches_or_404(db: AsyncSession, patch_sequence: list[int]) -> list[TemplatePatch]:
56
+ if not patch_sequence:
57
+ return []
58
+
59
+ unique_patch_ids = sorted(set(patch_sequence))
60
+ patches = list((await db.scalars(select(TemplatePatch).where(TemplatePatch.id.in_(unique_patch_ids)))).all())
61
+ patch_by_id = {patch.id: patch for patch in patches}
62
+ missing_ids = [patch_id for patch_id in patch_sequence if patch_id not in patch_by_id]
63
+ if missing_ids:
64
+ missing = ', '.join(str(patch_id) for patch_id in dict.fromkeys(missing_ids))
65
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f'template patches not found: {missing}')
66
+ return [patch_by_id[patch_id] for patch_id in patch_sequence]
67
+
68
+
69
+ async def _get_composite_template_or_404(composite_id: int, db: AsyncSession) -> CompositeTemplate:
70
+ statement = (
71
+ select(CompositeTemplate)
72
+ .options(selectinload(CompositeTemplate.base_template))
73
+ .where(CompositeTemplate.id == composite_id)
74
+ )
75
+ composite_template = (await db.scalars(statement)).one_or_none()
76
+ if composite_template is None:
77
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='composite template not found')
78
+ return composite_template
79
+
80
+
81
+ async def _render_cached_content(
82
+ composite_template: CompositeTemplate,
83
+ db: AsyncSession,
84
+ ) -> tuple[str, list[TemplatePatch]]:
85
+ patches = await _get_patches_or_404(db, composite_template.patch_sequence)
86
+ try:
87
+ cached_content = TemplateComposer().render_cached_content(composite_template.base_template, patches)
88
+ except (PatchValidationError, TypeError, ValueError) as exc:
89
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
90
+ return cached_content, patches
91
+
92
+
93
+ @router.get('/composite-templates', response_model=list[CompositeTemplateRead])
94
+ async def list_composite_templates(db: DbSession) -> list[CompositeTemplateRead]:
95
+ statement = select(CompositeTemplate).options(selectinload(CompositeTemplate.base_template)).order_by(CompositeTemplate.id)
96
+ composites = list((await db.scalars(statement)).all())
97
+
98
+ changed = False
99
+ serialized: list[CompositeTemplateRead] = []
100
+ for composite in composites:
101
+ cached_content, patches = await _render_cached_content(composite, db)
102
+ if composite.cached_content != cached_content:
103
+ composite.cached_content = cached_content
104
+ changed = True
105
+ serialized.append(_serialize_composite_template(composite, patches))
106
+
107
+ if changed:
108
+ await db.commit()
109
+ return serialized
110
+
111
+
112
+ @router.post('/composite-templates', response_model=CompositeTemplateRead, status_code=status.HTTP_201_CREATED)
113
+ async def create_composite_template(payload: CompositeTemplateCreate, db: DbSession) -> CompositeTemplateRead:
114
+ base_template = await _get_template_or_404(db, payload.base_template_id)
115
+ patches = await _get_patches_or_404(db, payload.patch_sequence)
116
+
117
+ try:
118
+ cached_content = TemplateComposer().render_cached_content(base_template, patches)
119
+ except (PatchValidationError, TypeError, ValueError) as exc:
120
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
121
+
122
+ composite = CompositeTemplate(
123
+ name=payload.name,
124
+ base_template_id=payload.base_template_id,
125
+ patch_sequence=list(payload.patch_sequence),
126
+ cached_content=cached_content,
127
+ )
128
+ composite.base_template = base_template
129
+ db.add(composite)
130
+ await commit_or_name_conflict(db, resource_name='composite template', table_name='composite_templates')
131
+ await db.refresh(composite)
132
+ return _serialize_composite_template(composite, patches)
133
+
134
+
135
+ @router.get('/composite-templates/{composite_id}', response_model=CompositeTemplateRead)
136
+ async def get_composite_template(composite_id: int, db: DbSession) -> CompositeTemplateRead:
137
+ composite = await _get_composite_template_or_404(composite_id, db)
138
+ cached_content, patches = await _render_cached_content(composite, db)
139
+ if composite.cached_content != cached_content:
140
+ composite.cached_content = cached_content
141
+ await db.commit()
142
+ await db.refresh(composite)
143
+ return _serialize_composite_template(composite, patches)
144
+
145
+
146
+ @router.put('/composite-templates/{composite_id}', response_model=CompositeTemplateRead)
147
+ async def update_composite_template(
148
+ composite_id: int,
149
+ payload: CompositeTemplateUpdate,
150
+ db: DbSession,
151
+ ) -> CompositeTemplateRead:
152
+ composite = await _get_composite_template_or_404(composite_id, db)
153
+
154
+ name = payload.name if 'name' in payload.model_fields_set and payload.name is not None else composite.name
155
+ base_template_id = (
156
+ payload.base_template_id
157
+ if 'base_template_id' in payload.model_fields_set and payload.base_template_id is not None
158
+ else composite.base_template_id
159
+ )
160
+ patch_sequence = list(payload.patch_sequence) if payload.patch_sequence is not None else list(composite.patch_sequence)
161
+
162
+ base_template = await _get_template_or_404(db, base_template_id)
163
+ patches = await _get_patches_or_404(db, patch_sequence)
164
+
165
+ try:
166
+ cached_content = TemplateComposer().render_cached_content(base_template, patches)
167
+ except (PatchValidationError, TypeError, ValueError) as exc:
168
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
169
+
170
+ composite.name = name
171
+ composite.base_template_id = base_template_id
172
+ composite.base_template = base_template
173
+ composite.patch_sequence = patch_sequence
174
+ composite.cached_content = cached_content
175
+
176
+ await commit_or_name_conflict(db, resource_name='composite template', table_name='composite_templates')
177
+ await db.refresh(composite)
178
+ return _serialize_composite_template(composite, patches)
179
+
180
+
181
+ @router.delete('/composite-templates/{composite_id}', status_code=status.HTTP_204_NO_CONTENT)
182
+ async def delete_composite_template(composite_id: int, db: DbSession) -> Response:
183
+ composite = await _get_composite_template_or_404(composite_id, db)
184
+ merge_profile = await db.scalar(select(MergeProfile.name).where(MergeProfile.composite_template_id == composite_id))
185
+ if merge_profile is not None:
186
+ raise HTTPException(
187
+ status_code=status.HTTP_409_CONFLICT,
188
+ detail=f'composite template is used by merge profile: {merge_profile}',
189
+ )
190
+ await db.delete(composite)
191
+ await db.commit()
192
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
193
+
194
+
195
+ @router.post('/composite-templates/preview', response_model=CompositePreviewRead)
196
+ async def preview_composite_template(payload: CompositeTemplatePreviewRequest, db: DbSession) -> CompositePreviewRead:
197
+ base_template = await _get_template_or_404(db, payload.base_template_id)
198
+ patches = await _get_patches_or_404(db, payload.patch_sequence)
199
+
200
+ try:
201
+ content = TemplateComposer().render_cached_content(base_template, patches)
202
+ return CompositePreviewRead(content=content)
203
+ except (PatchValidationError, TypeError, ValueError) as exc:
204
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@@ -0,0 +1,172 @@
1
+ """CRUD endpoints for subscriptions and templates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Response, status
8
+ from sqlalchemy import select
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from ...db.models import CompositeTemplate, Subscription, Template
12
+ from ...models import SubscriptionConfig
13
+ from ..dependencies import get_db_session
14
+ from ..schemas import (
15
+ SubscriptionCreate,
16
+ SubscriptionRead,
17
+ SubscriptionUpdate,
18
+ TemplateCreate,
19
+ TemplateRead,
20
+ TemplateUpdate,
21
+ )
22
+ from ._db import commit_or_name_conflict
23
+
24
+ router = APIRouter(tags=['config'])
25
+ DbSession = Annotated[AsyncSession, Depends(get_db_session)]
26
+
27
+
28
+ async def _ensure_template_exists(db: AsyncSession, template_id: int | None) -> None:
29
+ if template_id is None:
30
+ return
31
+
32
+ template = await db.get(Template, template_id)
33
+ if template is None:
34
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='template not found')
35
+
36
+
37
+ @router.get('/subscriptions', response_model=list[SubscriptionRead])
38
+ async def list_subscriptions(db: DbSession) -> list[Subscription]:
39
+ return list((await db.scalars(select(Subscription).order_by(Subscription.id))).all())
40
+
41
+
42
+ @router.post('/subscriptions', response_model=SubscriptionRead, status_code=status.HTTP_201_CREATED)
43
+ async def create_subscription(payload: SubscriptionCreate, db: DbSession) -> Subscription:
44
+ await _ensure_template_exists(db, payload.template_id)
45
+ subscription = Subscription(
46
+ name=payload.name,
47
+ url=str(payload.url) if payload.url is not None else None,
48
+ content=payload.content,
49
+ proxy=payload.proxy,
50
+ headers=payload.headers,
51
+ follow_redirects=payload.follow_redirects,
52
+ enabled=payload.enabled,
53
+ template_id=payload.template_id,
54
+ )
55
+ db.add(subscription)
56
+ await commit_or_name_conflict(db, resource_name='subscription', table_name='subscriptions')
57
+ await db.refresh(subscription)
58
+ return subscription
59
+
60
+
61
+ @router.get('/subscriptions/{subscription_id}', response_model=SubscriptionRead)
62
+ async def get_subscription(subscription_id: int, db: DbSession) -> Subscription:
63
+ subscription = await db.get(Subscription, subscription_id)
64
+ if subscription is None:
65
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='subscription not found')
66
+ return subscription
67
+
68
+
69
+ @router.put('/subscriptions/{subscription_id}', response_model=SubscriptionRead)
70
+ async def update_subscription(subscription_id: int, payload: SubscriptionUpdate, db: DbSession) -> Subscription:
71
+ subscription = await db.get(Subscription, subscription_id)
72
+ if subscription is None:
73
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='subscription not found')
74
+
75
+ current = {
76
+ 'name': subscription.name,
77
+ 'url': subscription.url,
78
+ 'content': subscription.content,
79
+ 'proxy': subscription.proxy,
80
+ 'headers': subscription.headers,
81
+ 'follow_redirects': subscription.follow_redirects,
82
+ 'enabled': subscription.enabled,
83
+ 'template_id': subscription.template_id,
84
+ }
85
+ updated = payload.model_dump(exclude_unset=True)
86
+ merged = current | updated
87
+ SubscriptionConfig.model_validate(
88
+ {
89
+ 'name': merged['name'],
90
+ 'url': merged['url'],
91
+ 'content': merged['content'],
92
+ 'proxy': merged['proxy'],
93
+ 'headers': merged['headers'],
94
+ 'follow_redirects': merged['follow_redirects'],
95
+ 'enabled': merged['enabled'],
96
+ }
97
+ )
98
+ await _ensure_template_exists(db, merged['template_id'])
99
+
100
+ for field, value in merged.items():
101
+ if field == 'url' and value is not None:
102
+ setattr(subscription, field, str(value))
103
+ else:
104
+ setattr(subscription, field, value)
105
+
106
+ await commit_or_name_conflict(db, resource_name='subscription', table_name='subscriptions')
107
+ await db.refresh(subscription)
108
+ return subscription
109
+
110
+
111
+ @router.delete('/subscriptions/{subscription_id}', status_code=status.HTTP_204_NO_CONTENT)
112
+ async def delete_subscription(subscription_id: int, db: DbSession) -> Response:
113
+ subscription = await db.get(Subscription, subscription_id)
114
+ if subscription is None:
115
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='subscription not found')
116
+ await db.delete(subscription)
117
+ await db.commit()
118
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
119
+
120
+
121
+ @router.get('/templates', response_model=list[TemplateRead])
122
+ async def list_templates(db: DbSession) -> list[Template]:
123
+ return list((await db.scalars(select(Template).order_by(Template.id))).all())
124
+
125
+
126
+ @router.post('/templates', response_model=TemplateRead, status_code=status.HTTP_201_CREATED)
127
+ async def create_template(payload: TemplateCreate, db: DbSession) -> Template:
128
+ template = Template(name=payload.name, content=payload.content, is_default=payload.is_default)
129
+ db.add(template)
130
+ await commit_or_name_conflict(db, resource_name='template', table_name='templates')
131
+ await db.refresh(template)
132
+ return template
133
+
134
+
135
+ @router.get('/templates/{template_id}', response_model=TemplateRead)
136
+ async def get_template(template_id: int, db: DbSession) -> Template:
137
+ template = await db.get(Template, template_id)
138
+ if template is None:
139
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='template not found')
140
+ return template
141
+
142
+
143
+ @router.put('/templates/{template_id}', response_model=TemplateRead)
144
+ async def update_template(template_id: int, payload: TemplateUpdate, db: DbSession) -> Template:
145
+ template = await db.get(Template, template_id)
146
+ if template is None:
147
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='template not found')
148
+ for field, value in payload.model_dump(exclude_unset=True).items():
149
+ setattr(template, field, value)
150
+ await commit_or_name_conflict(db, resource_name='template', table_name='templates')
151
+ await db.refresh(template)
152
+ return template
153
+
154
+
155
+ @router.delete('/templates/{template_id}', status_code=status.HTTP_204_NO_CONTENT)
156
+ async def delete_template(template_id: int, db: DbSession) -> Response:
157
+ template = await db.get(Template, template_id)
158
+ if template is None:
159
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='template not found')
160
+
161
+ composite_template = await db.scalar(
162
+ select(CompositeTemplate.id).where(CompositeTemplate.base_template_id == template_id)
163
+ )
164
+ if composite_template is not None:
165
+ raise HTTPException(
166
+ status_code=status.HTTP_409_CONFLICT,
167
+ detail='template is used by a composite template',
168
+ )
169
+
170
+ await db.delete(template)
171
+ await db.commit()
172
+ return Response(status_code=status.HTTP_204_NO_CONTENT)