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 mashumaro
.exceptions
import MissingField
20 from packaging
.requirements
import Requirement
22 TEMPLATE
= """# Do not edit manually, run ./update-providers.py
25 version = "{{ version }}";
27 {%- for provider in providers | sort(attribute='domain') %}
28 {{ provider.domain }} = {% if provider.available %}ps: with ps;{% else %}ps:{% endif %} [
29 {%- for requirement in provider.available | sort %}
32 ];{% if provider.missing %} # missing {{ ", ".join(provider.missing) }}{% endif %}
53 "git+https://github.com/MarvinSchenkel/pytube.git": "pytube",
57 def run_sync(cmd
: List
[str]) -> None:
58 print(f
"$ {' '.join(cmd)}")
61 if process
.returncode
!= 0:
65 async def check_async(cmd
: List
[str]) -> str:
66 print(f
"$ {' '.join(cmd)}")
67 process
= await asyncio
.create_subprocess_exec(
68 *cmd
, stdout
=asyncio
.subprocess
.PIPE
, stderr
=asyncio
.subprocess
.PIPE
70 stdout
, stderr
= await process
.communicate()
72 if process
.returncode
!= 0:
73 error
= stderr
.decode()
74 raise RuntimeError(f
"{cmd[0]} failed: {error}")
76 return stdout
.decode().strip()
83 "--extra-experimental-features",
88 async def _run(cls
, args
: List
[str]) -> Optional
[str]:
89 return await check_async(cls
.base_cmd
+ args
)
92 async def eval(cls
, expr
: str) -> Union
[List
, Dict
, int, float, str, bool]:
93 response
= await cls
._run
(["eval", "-f", f
"{ROOT}/default.nix", "--json", expr
])
95 raise RuntimeError("Nix eval expression returned no response")
97 return json
.loads(response
)
98 except (TypeError, ValueError):
99 raise RuntimeError("Nix eval response could not be parsed from JSON")
102 async def get_provider_manifests(version
: str = "master") -> List
:
104 with tempfile
.TemporaryDirectory() as tmp
:
106 f
"https://github.com/music-assistant/music-assistant/archive/refs/tags/{version}.tar.gz"
108 tarfile
.open(fileobj
=BytesIO(response
.read())).extractall(
112 basedir
= Path(os
.path
.join(tmp
, f
"server-{version}"))
113 sys
.path
.append(str(basedir
))
114 from music_assistant
.common
.models
.provider
import ProviderManifest
# type: ignore
116 for fn
in basedir
.glob("**/manifest.json"):
118 manifests
.append(await ProviderManifest
.parse(fn
))
119 except MissingField
as ex
:
120 print(f
"Error parsing {fn}", ex
)
126 def packageset_attributes():
127 output
= check_output(
134 "music-assistant.python.pkgs",
137 "{ allowAliases = false; }",
141 return json
.loads(output
)
144 class TooManyMatches(Exception):
148 class NoMatch(Exception):
152 def resolve_package_attribute(package
: str) -> str:
153 pattern
= re
.compile(rf
"^music-assistant\.python\.pkgs\.{package}$", re
.I
)
154 packages
= packageset_attributes()
156 for attr
in packages
.keys():
157 if pattern
.match(attr
):
158 matches
.append(attr
.split(".")[-1])
161 raise TooManyMatches(
162 f
"Too many matching attributes for {package}: {' '.join(matches)}"
165 raise NoMatch(f
"No matching attribute for {package}")
173 available
: list[str] = field(default_factory
=list)
174 missing
: list[str] = field(default_factory
=list)
176 def __eq__(self
, other
):
177 return self
.domain
== other
.domain
180 return hash(self
.domain
)
183 def resolve_providers(manifests
) -> Set
:
185 for manifest
in manifests
:
186 provider
= Provider(manifest
.domain
)
187 for requirement
in manifest
.requirements
:
188 # allow substituting requirement specifications that packaging cannot parse
189 if requirement
in PACKAGE_MAP
:
190 requirement
= PACKAGE_MAP
[requirement
]
191 requirement
= Requirement(requirement
)
193 provider
.available
.append(resolve_package_attribute(requirement
.name
))
194 except TooManyMatches
as ex
:
195 print(ex
, file=sys
.stderr
)
196 provider
.missing
.append(requirement
.name
)
198 provider
.missing
.append(requirement
.name
)
199 providers
.add(provider
)
203 def render(version
: str, providers
: Set
):
204 path
= os
.path
.join(ROOT
, "pkgs/by-name/mu/music-assistant/providers.nix")
206 template
= env
.from_string(TEMPLATE
)
207 template
.stream(version
=version
, providers
=providers
).dump(path
)
211 version
: str = cast(str, await Nix
.eval("music-assistant.version"))
212 manifests
= await get_provider_manifests(version
)
213 providers
= resolve_providers(manifests
)
214 render(version
, providers
)
217 if __name__
== "__main__":
218 run_sync(["pyright", __file__
])
219 run_sync(["ruff", "check", "--ignore=E501", __file__
])
220 run_sync(["isort", __file__
])
221 run_sync(["ruff", "format", __file__
])