11from __future__ import annotations
22
3+ import dataclasses
34import json
45import logging
56import os
67import pathlib
8+ import pprint
79import re
810import shutil
911import subprocess
1012import tomllib
13+ from collections import defaultdict
1114from typing import TYPE_CHECKING
1215
1316import packaging .requirements
@@ -64,42 +67,14 @@ def test_image_pyprojects(subtests: pytest_subtests.plugin.SubTests):
6467 )
6568
6669 with subtests .test (msg = "checking imagestream manifest consistency with pylock.toml" , pyproject = file ):
67- # TODO(jdanek): missing manifests
68- if is_suffix (directory .parts , pathlib .Path ("runtimes/rocm-tensorflow/ubi9-python-3.12" ).parts ):
69- pytest .skip (f"Manifest not implemented { directory .parts } " )
70- if is_suffix (directory .parts , pathlib .Path ("jupyter/rocm/tensorflow/ubi9-python-3.12" ).parts ):
71- pytest .skip (f"Manifest not implemented { directory .parts } " )
72-
73- metadata = manifests .extract_metadata_from_path (directory )
74- manifest_file = manifests .get_source_of_truth_filepath (
75- root_repo_directory = PROJECT_ROOT ,
76- metadata = metadata ,
77- )
78- if not manifest_file .is_file ():
79- raise FileNotFoundError (
80- f"Unable to determine imagestream manifest for '{ directory } '. "
81- f"Computed filepath '{ manifest_file } ' does not exist."
82- )
83-
84- imagestream = yaml .safe_load (manifest_file .read_text ())
85- recommended_tags = [
86- tag
87- for tag in imagestream ["spec" ]["tags" ]
88- if tag ["annotations" ].get ("opendatahub.io/workbench-image-recommended" , None ) == "true"
89- ]
90- assert len (recommended_tags ) <= 1 , "at most one tag may be recommended at a time"
91- assert recommended_tags or len (imagestream ["spec" ]["tags" ]) == 1 , (
92- "Either there has to be recommended image, or there can be only one tag"
93- )
94- current_tag = recommended_tags [0 ] if recommended_tags else imagestream ["spec" ]["tags" ][0 ]
70+ _skip_unimplemented_manifests (directory )
9571
96- sw = json .loads (current_tag ["annotations" ]["opendatahub.io/notebook-software" ])
97- dep = json .loads (current_tag ["annotations" ]["opendatahub.io/notebook-python-dependencies" ])
72+ manifest = load_manifests_file_for (directory )
9873
9974 with subtests .test (msg = "checking the `notebook-software` array" , pyproject = file ):
10075 # TODO(jdanek)
10176 pytest .skip ("checking the `notebook-software` array not yet implemented" )
102- for s in sw :
77+ for s in manifest . sw :
10378 if s .get ("name" ) == "Python" :
10479 assert s .get ("version" ) == f"v{ python } " , (
10580 "Python version in imagestream does not match Pipfile"
@@ -108,7 +83,7 @@ def test_image_pyprojects(subtests: pytest_subtests.plugin.SubTests):
10883 pytest .fail (f"unexpected { s = } " )
10984
11085 with subtests .test (msg = "checking the `notebook-python-dependencies` array" , pyproject = file ):
111- for d in dep :
86+ for d in manifest . dep :
11287 workbench_only_packages = [
11388 "Kfp" ,
11489 "JupyterLab" ,
@@ -155,11 +130,11 @@ def test_image_pyprojects(subtests: pytest_subtests.plugin.SubTests):
155130 }
156131
157132 name = d ["name" ]
158- if name in workbench_only_packages and metadata .type == manifests .NotebookType .RUNTIME :
133+ if name in workbench_only_packages and manifest . metadata .type == manifests .NotebookType .RUNTIME :
159134 continue
160135
161136 # TODO(jdanek): intentional?
162- if metadata .scope == "pytorch+llmcompressor" and name == "Codeflare-SDK" :
137+ if manifest . metadata .scope == "pytorch+llmcompressor" and name == "Codeflare-SDK" :
163138 continue
164139
165140 if name == "ROCm-PyTorch" :
@@ -197,6 +172,70 @@ def test_image_pyprojects(subtests: pytest_subtests.plugin.SubTests):
197172 ), f"{ name } : manifest declares { manifest_version } , but pylock.toml pins { locked_version } "
198173
199174
175+ def test_image_manifests_version_alignment (subtests : pytest_subtests .plugin .SubTests ):
176+ collected_manifests = []
177+ for file in PROJECT_ROOT .glob ("**/pyproject.toml" ):
178+ logging .info (file )
179+ directory = file .parent # "ubi9-python-3.11"
180+ try :
181+ _ubi , _lang , _python = directory .name .split ("-" )
182+ except ValueError :
183+ logging .debug (f"skipping { directory .name } /pyproject.toml as it is not an image directory" )
184+ continue
185+
186+ if _skip_unimplemented_manifests (directory , call_skip = False ):
187+ continue
188+
189+ manifest = load_manifests_file_for (directory )
190+ collected_manifests .append (manifest )
191+
192+ @dataclasses .dataclass
193+ class VersionData :
194+ manifest : Manifest
195+ version : str
196+
197+ packages : dict [str , list [VersionData ]] = defaultdict (list )
198+ for manifest in collected_manifests :
199+ for dep in manifest .dep :
200+ name = dep ["name" ]
201+ version = dep ["version" ]
202+ packages [name ].append (VersionData (manifest = manifest , version = version ))
203+
204+ # TODO(jdanek): review these, if any are unwarranted
205+ ignored_exceptions : tuple [tuple [str , tuple [str , ...]], ...] = (
206+ # ("package name", ("allowed version 1", "allowed version 2", ...))
207+ ("Codeflare-SDK" , ("0.30" , "0.29" )),
208+ ("Scikit-learn" , ("1.7" , "1.6" )),
209+ ("Pandas" , ("2.2" , "1.5" )),
210+ ("Numpy" , ("2.2" , "1.26" )),
211+ ("Tensorboard" , ("2.19" , "2.18" )),
212+ )
213+
214+ for name , data in packages .items ():
215+ versions = [d .version for d in data ]
216+
217+ # if there is only a single version, all is good
218+ if len (set (versions )) == 1 :
219+ continue
220+
221+ mapping = {str (d .manifest .filename .relative_to (PROJECT_ROOT )): d .version for d in data }
222+ with subtests .test (msg = f"checking versions for { name } across the latest tags in all imagestreams" ):
223+ exception = next ((it for it in ignored_exceptions if it [0 ] == name ), None )
224+ if exception :
225+ # exception may save us from failing
226+ if set (versions ) == set (exception [1 ]):
227+ continue
228+ else :
229+ pytest .fail (
230+ f"{ name } is allowed to have { exception } but actually has more versions: { pprint .pformat (mapping )} "
231+ )
232+ # all hope is lost, the check has failed
233+ pytest .fail (f"{ name } has multiple versions: { pprint .pformat (mapping )} " )
234+
235+
236+ # TODO(jdanek): ^^^ should also check pyproject.tomls, in fact checking there is more useful than in manifests
237+
238+
200239def test_files_that_should_be_same_are_same (subtests : pytest_subtests .plugin .SubTests ):
201240 file_groups = {
202241 "ROCm de-vendor script" : [
@@ -239,3 +278,63 @@ def is_suffix[T](main_sequence: Sequence[T], suffix_sequence: Sequence[T]):
239278 if suffix_len > len (main_sequence ):
240279 return False
241280 return main_sequence [- suffix_len :] == suffix_sequence
281+
282+
283+ def _skip_unimplemented_manifests (directory : pathlib .Path , call_skip = True ) -> bool :
284+ # TODO(jdanek): missing manifests
285+ dirs = (
286+ "runtimes/rocm-tensorflow/ubi9-python-3.12" ,
287+ "jupyter/rocm/tensorflow/ubi9-python-3.12" ,
288+ )
289+ for d in dirs :
290+ if is_suffix (directory .parts , pathlib .Path (d ).parts ):
291+ if call_skip :
292+ pytest .skip (f"Manifest not implemented { directory .parts } " )
293+ else :
294+ return True
295+ return False
296+
297+
298+ @dataclasses .dataclass
299+ class Manifest :
300+ filename : pathlib .Path
301+ imagestream : dict [str , Any ]
302+ metadata : manifests .NotebookMetadata
303+ sw : list [dict [str , Any ]]
304+ dep : list [dict [str , Any ]]
305+
306+
307+ def load_manifests_file_for (directory : pathlib .Path ) -> Manifest :
308+ metadata = manifests .extract_metadata_from_path (directory )
309+ manifest_file = manifests .get_source_of_truth_filepath (
310+ root_repo_directory = PROJECT_ROOT ,
311+ metadata = metadata ,
312+ )
313+ if not manifest_file .is_file ():
314+ raise FileNotFoundError (
315+ f"Unable to determine imagestream manifest for '{ directory } '. "
316+ f"Computed filepath '{ manifest_file } ' does not exist."
317+ )
318+
319+ imagestream = yaml .safe_load (manifest_file .read_text ())
320+ recommended_tags = [
321+ tag
322+ for tag in imagestream ["spec" ]["tags" ]
323+ if tag ["annotations" ].get ("opendatahub.io/workbench-image-recommended" , None ) == "true"
324+ ]
325+ assert len (recommended_tags ) <= 1 , "at most one tag may be recommended at a time"
326+ assert recommended_tags or len (imagestream ["spec" ]["tags" ]) == 1 , (
327+ "Either there has to be recommended image, or there can be only one tag"
328+ )
329+ current_tag = recommended_tags [0 ] if recommended_tags else imagestream ["spec" ]["tags" ][0 ]
330+
331+ sw = json .loads (current_tag ["annotations" ]["opendatahub.io/notebook-software" ])
332+ dep = json .loads (current_tag ["annotations" ]["opendatahub.io/notebook-python-dependencies" ])
333+
334+ return Manifest (
335+ filename = manifest_file ,
336+ imagestream = imagestream ,
337+ metadata = metadata ,
338+ sw = sw ,
339+ dep = dep ,
340+ )
0 commit comments