Skip to content

Commit 280187f

Browse files
Improve build tools (#9)
* Improve build tools * Move build dir to temp * Bump core version * Fix compiler version dependency * Fix error messages * Bump libraries * Reorder libraries
1 parent 483796d commit 280187f

26 files changed

+467
-136
lines changed

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
# Auto detect text files and perform LF normalization
22
* text=auto
3+
src/amulet/game/_version.py export-subst

.github/actions/install-dependencies/action.yml

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ runs:
5252
echo "nbt-password is empty"
5353
exit 1
5454
fi
55-
55+
5656
if [ -z "${{ inputs.core-password }}" ]; then
5757
echo "core-password is empty"
5858
exit 1
@@ -63,33 +63,16 @@ runs:
6363
exit 1
6464
fi
6565
66-
- name: Set up Python ${{ inputs.python-version }}
66+
- name: Set up Python
6767
uses: actions/setup-python@v5
6868
with:
6969
python-version: ${{ inputs.python-version }}
7070

7171
- name: Install dependencies
7272
shell: bash
7373
run: |
74-
pip cache purge
75-
python -m pip install --upgrade pip
7674
pip install build twine packaging
7775
78-
- name: Get Dependencies
79-
id: dep
80-
shell: bash
81-
run: |
82-
mkdir -p build
83-
pip install --dry-run --report build/install.json .
84-
io=$(python -c "import json; f = open('build/install.json', encoding='utf-8'); print(next(l['metadata']['version'] for l in json.load(f)['install'] if l['metadata']['name'] == 'amulet-io'))")
85-
echo "io=$io" >> "$GITHUB_OUTPUT"
86-
zlib=$(python -c "import json; f = open('build/install.json', encoding='utf-8'); print(next(l['metadata']['version'] for l in json.load(f)['install'] if l['metadata']['name'] == 'amulet-zlib'))")
87-
echo "zlib=$zlib" >> "$GITHUB_OUTPUT"
88-
nbt=$(python -c "import json; f = open('build/install.json', encoding='utf-8'); print(next(l['metadata']['version'] for l in json.load(f)['install'] if l['metadata']['name'] == 'amulet-nbt'))")
89-
echo "nbt=$nbt" >> "$GITHUB_OUTPUT"
90-
core=$(python -c "import json; f = open('build/install.json', encoding='utf-8'); print(next(l['metadata']['version'] for l in json.load(f)['install'] if l['metadata']['name'] == 'amulet-core'))")
91-
echo "core=$core" >> "$GITHUB_OUTPUT"
92-
9376
- name: Clone Amulet-Compiler-Version
9477
uses: actions/checkout@v4
9578
with:
@@ -104,19 +87,44 @@ runs:
10487
twine-username: ${{ inputs.username }}
10588
twine-password: ${{ inputs.compiler-version-password }}
10689

90+
- name: Get Dependencies
91+
id: dep
92+
shell: bash
93+
env:
94+
REST_TOKEN: ${{ inputs.rest-token }}
95+
run: |
96+
python -c "import sys; sys.path.append(r'${{ github.action_path }}'); import dependency_resolver; import requirements; dependency_resolver.find_and_save_compatible_libraries([('amulet-core', 'Amulet-Team/Amulet-Core'), ('amulet-nbt', 'Amulet-Team/Amulet-NBT'), ('amulet-zlib', 'Amulet-Team/Amulet-zlib')], requirements.get_runtime_dependencies())"
97+
pybind11=$(python -c "import os; f = open(os.path.join(r'${{ github.action_path }}', 'libraries.json'), encoding='utf-8'); import json; print(json.load(f)['pybind11'])")
98+
echo "pybind11=$pybind11" >> "$GITHUB_OUTPUT"
99+
pybind11_extensions=$(python -c "import os; f = open(os.path.join(r'${{ github.action_path }}', 'libraries.json'), encoding='utf-8'); import json; print(json.load(f)['amulet-pybind11-extensions'])")
100+
echo "pybind11_extensions=$pybind11_extensions" >> "$GITHUB_OUTPUT"
101+
io=$(python -c "import os; f = open(os.path.join(r'${{ github.action_path }}', 'libraries.json'), encoding='utf-8'); import json; print(json.load(f)['amulet-io'])")
102+
echo "io=$io" >> "$GITHUB_OUTPUT"
103+
zlib=$(python -c "import os; f = open(os.path.join(r'${{ github.action_path }}', 'libraries.json'), encoding='utf-8'); import json; print(json.load(f)['amulet-zlib'])")
104+
echo "zlib=$zlib" >> "$GITHUB_OUTPUT"
105+
nbt=$(python -c "import os; f = open(os.path.join(r'${{ github.action_path }}', 'libraries.json'), encoding='utf-8'); import json; print(json.load(f)['amulet-nbt'])")
106+
echo "nbt=$nbt" >> "$GITHUB_OUTPUT"
107+
core=$(python -c "import os; f = open(os.path.join(r'${{ github.action_path }}', 'libraries.json'), encoding='utf-8'); import json; print(json.load(f)['amulet-core'])")
108+
echo "core=$core" >> "$GITHUB_OUTPUT"
109+
107110
- name: Specialise Specifiers
108111
id: dep2
109112
shell: bash
110113
run: |
111-
io=$(python -c "import requirements; print(requirements.get_specifier_set(\"${{ steps.dep.outputs.io }}\"))")
114+
pybind11=$(python -c "import requirements; print(requirements.get_specifier_set('${{ steps.dep.outputs.pybind11 }}'))")
115+
echo "pybind11=$pybind11" >> "$GITHUB_OUTPUT"
116+
pybind11_extensions=$(python -c "import requirements; print(requirements.get_specifier_set('${{ steps.dep.outputs.pybind11_extensions }}'))")
117+
echo "pybind11_extensions=$pybind11_extensions" >> "$GITHUB_OUTPUT"
118+
io=$(python -c "import requirements; print(requirements.get_specifier_set('${{ steps.dep.outputs.io }}'))")
112119
echo "io=$io" >> "$GITHUB_OUTPUT"
113-
zlib=$(python -c "import requirements; print(requirements.get_specifier_set(\"${{ steps.dep.outputs.zlib }}\"))")
120+
zlib=$(python -c "import requirements; print(requirements.get_specifier_set('${{ steps.dep.outputs.zlib }}'))")
114121
echo "zlib=$zlib" >> "$GITHUB_OUTPUT"
115-
nbt=$(python -c "import requirements; print(requirements.get_specifier_set(\"${{ steps.dep.outputs.nbt }}\"))")
122+
nbt=$(python -c "import requirements; print(requirements.get_specifier_set('${{ steps.dep.outputs.nbt }}'))")
116123
echo "nbt=$nbt" >> "$GITHUB_OUTPUT"
117-
core=$(python -c "import requirements; print(requirements.get_specifier_set(\"${{ steps.dep.outputs.core }}\"))")
124+
core=$(python -c "import requirements; print(requirements.get_specifier_set('${{ steps.dep.outputs.core }}'))")
118125
echo "core=$core" >> "$GITHUB_OUTPUT"
119126
127+
120128
- name: Clone Amulet-zlib
121129
uses: Amulet-Team/checkout-pep440@v1
122130
with:
@@ -131,6 +139,8 @@ runs:
131139
twine-username: ${{ inputs.username }}
132140
twine-password: ${{ inputs.zlib-password }}
133141
compiler-specifier: '==${{ steps.compiler.outputs.version }}'
142+
pybind11-specifier: ${{ steps.dep2.outputs.pybind11 }}
143+
pybind11-extensions-specifier: ${{ steps.dep2.outputs.pybind11_extensions }}
134144
zlib-specifier: ${{ steps.dep2.outputs.zlib }}
135145

136146
- name: Clone Amulet-NBT
@@ -147,6 +157,8 @@ runs:
147157
twine-username: ${{ inputs.username }}
148158
twine-password: ${{ inputs.nbt-password }}
149159
compiler-specifier: '==${{ steps.compiler.outputs.version }}'
160+
pybind11-specifier: ${{ steps.dep2.outputs.pybind11 }}
161+
pybind11-extensions-specifier: ${{ steps.dep2.outputs.pybind11_extensions }}
150162
io-specifier: ${{ steps.dep2.outputs.io }}
151163
zlib-specifier: ${{ steps.dep2.outputs.zlib }}
152164
nbt-specifier: ${{ steps.dep2.outputs.nbt }}
@@ -165,6 +177,10 @@ runs:
165177
twine-username: ${{ inputs.username }}
166178
twine-password: ${{ inputs.core-password }}
167179
compiler-specifier: '==${{ steps.compiler.outputs.version }}'
180+
pybind11-specifier: ${{ steps.dep2.outputs.pybind11 }}
181+
pybind11-extensions-specifier: ${{ steps.dep2.outputs.pybind11_extensions }}
168182
io-specifier: ${{ steps.dep2.outputs.io }}
183+
zlib-specifier: ${{ steps.dep2.outputs.zlib }}
169184
nbt-specifier: ${{ steps.dep2.outputs.nbt }}
170185
core-specifier: ${{ steps.dep2.outputs.core }}
186+
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import os
2+
import json
3+
import itertools
4+
from typing import Any
5+
from types import MappingProxyType
6+
from collections.abc import Iterable, Mapping, Iterator
7+
import urllib.request
8+
import urllib.parse
9+
from dataclasses import dataclass
10+
from packaging.version import Version, InvalidVersion
11+
from packaging.specifiers import SpecifierSet
12+
from packaging.requirements import Requirement
13+
from functools import lru_cache
14+
15+
16+
github_api_url = os.environ.get("GITHUB_API_URL", "https://api.github.com")
17+
18+
19+
@lru_cache(maxsize=None)
20+
def _get_github_releases(repo: str, page: int):
21+
print(f"Github REST request {repo} page {page}")
22+
query = urllib.parse.urlencode({"per_page": 100, "page": page})
23+
url = f"{github_api_url}/repos/{repo}/releases?{query}"
24+
headers = {"Accept": "application/vnd.github+json"}
25+
if os.environ.get("REST_TOKEN"):
26+
headers["Authorization"] = f"token {os.environ.get('REST_TOKEN')}"
27+
req = urllib.request.Request(url, headers=headers)
28+
with urllib.request.urlopen(req) as resp:
29+
data = json.loads(resp.read().decode())
30+
return data
31+
32+
33+
@lru_cache(maxsize=None)
34+
def _get_tags(repo: str) -> Iterator[Version]:
35+
for page in itertools.count(1):
36+
data = _get_github_releases(repo, page)
37+
if not data:
38+
break
39+
for release in data:
40+
try:
41+
v = Version(release["tag_name"])
42+
except InvalidVersion:
43+
continue
44+
else:
45+
yield v
46+
47+
48+
def _get_compatible_tags(repo: str, specifier: SpecifierSet) -> Iterator[Version]:
49+
return (v for v in _get_tags(repo) if v in specifier)
50+
51+
52+
@dataclass(frozen=True)
53+
class Library:
54+
lib_name: str
55+
repo_name: str
56+
57+
58+
@lru_cache(maxsize=None)
59+
def _exec_module(module_str: str) -> dict[str, Any]:
60+
m = {}
61+
exec(module_str, m, m)
62+
return m
63+
64+
65+
@lru_cache(maxsize=None)
66+
def _get_requirements_module(repo: str, tag: str) -> dict[str, Any]:
67+
url = f"https://raw.githubusercontent.com/{repo}/{tag}/requirements.py"
68+
with urllib.request.urlopen(url) as resp:
69+
module_str = resp.read().decode()
70+
return _exec_module(module_str)
71+
72+
73+
def parse_requirements(requirements: Iterable[str]) -> Mapping[str, SpecifierSet]:
74+
split_requirements = {}
75+
for req_s in requirements:
76+
req = Requirement(req_s)
77+
split_requirements[_fix_library_name(req.name)] = req.specifier
78+
return MappingProxyType(split_requirements)
79+
80+
81+
@lru_cache(maxsize=None)
82+
def _get_requirements(repo: str, tag: str) -> Mapping[str, SpecifierSet]:
83+
m = _get_requirements_module(repo, tag)
84+
return parse_requirements(m["get_runtime_dependencies"]())
85+
86+
87+
@lru_cache(maxsize=None)
88+
def _get_pypi_releases(lib_name: str) -> MappingProxyType[str, Any]:
89+
print(f"Getting PyPI releases for {lib_name}")
90+
url = f"https://pypi.org/pypi/{lib_name}/json"
91+
with urllib.request.urlopen(url) as resp:
92+
data = json.loads(resp.read().decode())
93+
return data["releases"]
94+
95+
96+
class NoValidVersion(Exception):
97+
pass
98+
99+
100+
@lru_cache(maxsize=None)
101+
def _get_pypi_release(lib_name: str, specifier: SpecifierSet) -> Version:
102+
releases = _get_pypi_releases(lib_name)
103+
for version_str, files in releases.items():
104+
version = Version(version_str)
105+
# release must match the specifier and have a source distribution
106+
if version in specifier and any(
107+
file.get("packagetype", None) == "sdist" for file in files
108+
):
109+
return version
110+
raise NoValidVersion
111+
112+
113+
def _fix_library_name(name: str) -> str:
114+
return name.replace("_", "-")
115+
116+
117+
def _find_compatible_libraries(
118+
libraries: tuple[Library, ...],
119+
requirements: Mapping[str, SpecifierSet],
120+
libraries_todo: tuple[Library, ...],
121+
libraries_frozen: Mapping[str, Version] = MappingProxyType({}),
122+
) -> Mapping[str, Version]:
123+
# Get the library to freeze and the libraries left to freeze
124+
library_freeze = libraries_todo[0]
125+
libraries_todo = tuple(libraries_todo[1:])
126+
127+
# Verify that this library has not already been frozen
128+
if library_freeze.lib_name in libraries_frozen:
129+
raise RuntimeError(f"Library {library_freeze.lib_name} listed more than once.")
130+
131+
processed_requirement_configurations = []
132+
133+
# Iterate through all versions that match the specifier
134+
for v in _get_compatible_tags(
135+
library_freeze.repo_name,
136+
requirements.get(library_freeze.lib_name, SpecifierSet()),
137+
):
138+
print(f"Trying {library_freeze.lib_name}=={v}")
139+
# Get the requirements this library adds
140+
library_requirements = _get_requirements(library_freeze.repo_name, str(v))
141+
142+
# If the library_requirements match a previous version, skip
143+
if library_requirements in processed_requirement_configurations:
144+
continue
145+
else:
146+
processed_requirement_configurations.append(library_requirements)
147+
148+
# Extend the existing requirements.
149+
new_requirements = dict(requirements)
150+
for name, specifier in library_requirements.items():
151+
if name in new_requirements:
152+
specifier = new_requirements[name] & specifier
153+
154+
# check the frozen requirements are still valid.
155+
if name in libraries_frozen and libraries_frozen[name] not in specifier:
156+
raise NoValidVersion
157+
158+
new_requirements[name] = specifier
159+
160+
# Add the library to the frozen libraries
161+
new_libraries_frozen = {**libraries_frozen, library_freeze.lib_name: v}
162+
163+
if libraries_todo:
164+
# if we have more libraries to freeze go to the next one
165+
try:
166+
return _find_compatible_libraries(
167+
libraries,
168+
MappingProxyType(new_requirements),
169+
libraries_todo,
170+
MappingProxyType(new_libraries_frozen),
171+
)
172+
except NoValidVersion:
173+
# If dependency resolution failed below this, try the next version of the library
174+
continue
175+
else:
176+
# Make sure all libraries have a compatible pypi release
177+
try:
178+
for name, specifier in new_requirements.items():
179+
if name not in new_libraries_frozen:
180+
new_libraries_frozen[name] = _get_pypi_release(name, specifier)
181+
except NoValidVersion:
182+
continue
183+
# No more libraries to freeze.
184+
return MappingProxyType(new_libraries_frozen)
185+
# Raise if no version matched
186+
raise NoValidVersion
187+
188+
189+
def find_compatible_libraries(
190+
libraries: Iterable[tuple[str, str]], requirements: Iterable[str]
191+
) -> Mapping[str, Version]:
192+
libraries_ = tuple(
193+
Library(_fix_library_name(lib_name), repo_name)
194+
for lib_name, repo_name in libraries
195+
)
196+
return _find_compatible_libraries(
197+
libraries_,
198+
parse_requirements(requirements),
199+
libraries_,
200+
)
201+
202+
203+
def find_and_save_compatible_libraries(
204+
compiled_library_data: Iterable[tuple[str, str]], requirements: Iterable[str]
205+
) -> None:
206+
libraries = find_compatible_libraries(compiled_library_data, requirements)
207+
print(libraries)
208+
with open(
209+
os.path.join(os.path.dirname(__file__), "libraries.json"), "w", encoding="utf-8"
210+
) as f:
211+
json.dump({name: str(specifier) for name, specifier in libraries.items()}, f)

0 commit comments

Comments
 (0)