1 #!/usr/bin/env nix-shell
2 #!nix-shell -i python3 -p "python3.withPackages (ps: with ps; [ jinja2 mashumaro orjson aiofiles packaging ])" -p pyright ruff isort
10 from dataclasses
import dataclass
, field
11 from functools
import cache
12 from io
import BytesIO
13 from pathlib
import Path
14 from subprocess
import check_output
, run
15 from typing
import Dict
, Final
, List
, Optional
, Set
, Union
, cast
16 from urllib
.request
import urlopen
18 from jinja2
import Environment
19 from packaging
.requirements
import Requirement
21 TEMPLATE
= """# Do not edit manually, run ./update-providers.py
24 version = "{{ version }}";
26 {%- for provider in providers | sort(attribute='domain') %}
27 {{ provider.domain }} = {% if provider.available %}ps: with ps;{% else %}ps:{% endif %} [
28 {%- for requirement in provider.available | sort %}
31 ];{% if provider.missing %} # missing {{ ", ".join(provider.missing) }}{% endif %}
52 "git+https://github.com/MarvinSchenkel/pytube.git": "pytube",
56 def run_sync(cmd
: List
[str]) -> None:
57 print(f
"$ {' '.join(cmd)}")
60 if process
.returncode
!= 0:
64 async def check_async(cmd
: List
[str]) -> str:
65 print(f
"$ {' '.join(cmd)}")
66 process
= await asyncio
.create_subprocess_exec(
67 *cmd
, stdout
=asyncio
.subprocess
.PIPE
, stderr
=asyncio
.subprocess
.PIPE
69 stdout
, stderr
= await process
.communicate()
71 if process
.returncode
!= 0:
72 error
= stderr
.decode()
73 raise RuntimeError(f
"{cmd[0]} failed: {error}")
75 return stdout
.decode().strip()
82 "--extra-experimental-features",
87 async def _run(cls
, args
: List
[str]) -> Optional
[str]:
88 return await check_async(cls
.base_cmd
+ args
)
91 async def eval(cls
, expr
: str) -> Union
[List
, Dict
, int, float, str, bool]:
92 response
= await cls
._run
(["eval", "-f", f
"{ROOT}/default.nix", "--json", expr
])
94 raise RuntimeError("Nix eval expression returned no response")
96 return json
.loads(response
)
97 except (TypeError, ValueError):
98 raise RuntimeError("Nix eval response could not be parsed from JSON")
101 async def get_provider_manifests(version
: str = "master") -> List
:
103 with tempfile
.TemporaryDirectory() as tmp
:
105 f
"https://github.com/music-assistant/music-assistant/archive/refs/tags/{version}.tar.gz"
107 tarfile
.open(fileobj
=BytesIO(response
.read())).extractall(
111 basedir
= Path(os
.path
.join(tmp
, f
"server-{version}"))
112 sys
.path
.append(str(basedir
))
113 from music_assistant
.common
.models
.provider
import ProviderManifest
# type: ignore
115 for fn
in basedir
.glob("**/manifest.json"):
116 manifests
.append(await ProviderManifest
.parse(fn
))
122 def packageset_attributes():
123 output
= check_output(
130 "music-assistant.python.pkgs",
133 "{ allowAliases = false; }",
137 return json
.loads(output
)
140 class TooManyMatches(Exception):
144 class NoMatch(Exception):
148 def resolve_package_attribute(package
: str) -> str:
149 pattern
= re
.compile(rf
"^music-assistant\.python\.pkgs\.{package}$", re
.I
)
150 packages
= packageset_attributes()
152 for attr
in packages
.keys():
153 if pattern
.match(attr
):
154 matches
.append(attr
.split(".")[-1])
157 raise TooManyMatches(
158 f
"Too many matching attributes for {package}: {' '.join(matches)}"
161 raise NoMatch(f
"No matching attribute for {package}")
169 available
: list[str] = field(default_factory
=list)
170 missing
: list[str] = field(default_factory
=list)
172 def __eq__(self
, other
):
173 return self
.domain
== other
.domain
176 return hash(self
.domain
)
179 def resolve_providers(manifests
) -> Set
:
181 for manifest
in manifests
:
182 provider
= Provider(manifest
.domain
)
183 for requirement
in manifest
.requirements
:
184 # allow substituting requirement specifications that packaging cannot parse
185 if requirement
in PACKAGE_MAP
:
186 requirement
= PACKAGE_MAP
[requirement
]
187 requirement
= Requirement(requirement
)
189 provider
.available
.append(resolve_package_attribute(requirement
.name
))
190 except TooManyMatches
as ex
:
191 print(ex
, file=sys
.stderr
)
192 provider
.missing
.append(requirement
.name
)
194 provider
.missing
.append(requirement
.name
)
195 providers
.add(provider
)
199 def render(version
: str, providers
: Set
):
200 path
= os
.path
.join(ROOT
, "pkgs/by-name/mu/music-assistant/providers.nix")
202 template
= env
.from_string(TEMPLATE
)
203 template
.stream(version
=version
, providers
=providers
).dump(path
)
207 version
: str = cast(str, await Nix
.eval("music-assistant.version"))
208 manifests
= await get_provider_manifests(version
)
209 providers
= resolve_providers(manifests
)
210 render(version
, providers
)
213 if __name__
== "__main__":
214 run_sync(["pyright", __file__
])
215 run_sync(["ruff", "check", "--ignore=E501", __file__
])
216 run_sync(["isort", __file__
])
217 run_sync(["ruff", "format", __file__
])