Merge ~jugmac00/lpci:add-conda-build-plugin into lpci:main

Proposed by Jürgen Gmach
Status: Merged
Merged at revision: aceb166cd84dde2d25ecfbb9571097b4ea300c5a
Proposed branch: ~jugmac00/lpci:add-conda-build-plugin
Merge into: lpci:main
Diff against target: 623 lines (+597/-1)
2 files modified
lpcraft/plugin/tests/test_plugins.py (+419/-0)
lpcraft/plugins/plugins.py (+178/-1)
Reviewer Review Type Date Requested Status
Jürgen Gmach Approve
Review via email: mp+423728@code.launchpad.net

Commit message

- Add conda build plugin
- Test existing recipe folder with missing meta.yaml

To post a comment you must log in.
Revision history for this message
Jürgen Gmach (jugmac00) wrote :

self approve - implementation already approved via https://code.launchpad.net/~techalchemy/lpcraft/+git/lpcraft/+merge/423301

I only added one test to cover one previously uncovered line.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lpcraft/plugin/tests/test_plugins.py b/lpcraft/plugin/tests/test_plugins.py
2index fe7d930..e0ec098 100644
3--- a/lpcraft/plugin/tests/test_plugins.py
4+++ b/lpcraft/plugin/tests/test_plugins.py
5@@ -483,3 +483,422 @@ class TestPlugins(CommandBaseTestCase):
6 plugin_match[0].conda_channels,
7 )
8 self.assertEqual(["PYTHON=3.8", "pip"], plugin_match[0].conda_packages)
9+
10+ @patch("lpcraft.commands.run.get_provider")
11+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
12+ def test_conda_build_plugin(
13+ self, mock_get_host_architecture, mock_get_provider
14+ ):
15+ launcher = Mock(spec=launch)
16+ provider = makeLXDProvider(lxd_launcher=launcher)
17+ mock_get_provider.return_value = provider
18+ execute_run = launcher.return_value.execute_run
19+ execute_run.return_value = subprocess.CompletedProcess([], 0)
20+ config = dedent(
21+ """
22+ pipeline:
23+ - build
24+
25+ jobs:
26+ build:
27+ series: focal
28+ architectures: amd64
29+ plugin: conda-build
30+ build-target: info/recipe/parent
31+ conda-channels:
32+ - conda-forge
33+ conda-packages:
34+ - mamba
35+ - pip
36+ conda-python: 3.8
37+ run: |
38+ pip install --upgrade pytest
39+ """
40+ )
41+ Path(".launchpad.yaml").write_text(config)
42+ Path("info/recipe/parent").mkdir(parents=True)
43+ Path("info/recipe/meta.yaml").touch()
44+ Path("info/recipe/parent/meta.yaml").touch()
45+ pre_run_command = dedent(
46+ """
47+ if [ ! -d "$HOME/miniconda3" ]; then
48+ wget -O /tmp/miniconda.sh https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh
49+ chmod +x /tmp/miniconda.sh
50+ /tmp/miniconda.sh -b
51+ fi
52+ export PATH=$HOME/miniconda3/bin:$PATH
53+ conda remove --all -q -y -n $CONDA_ENV
54+ conda create -n $CONDA_ENV -q -y -c conda-forge -c defaults PYTHON=3.8 conda-build mamba pip
55+ source activate $CONDA_ENV
56+ """ # noqa:E501
57+ )
58+ run_command = dedent(
59+ """
60+ export PATH=$HOME/miniconda3/bin:$PATH
61+ source activate $CONDA_ENV
62+ conda-build --no-anaconda-upload --output-folder dist -c conda-forge -c defaults info/recipe/parent
63+ pip install --upgrade pytest
64+ """ # noqa: E501
65+ )
66+ post_run_command = (
67+ "export PATH=$HOME/miniconda3/bin:$PATH; "
68+ "source activate $CONDA_ENV; conda env export"
69+ )
70+
71+ self.run_command("run")
72+
73+ self.assertEqual(
74+ [
75+ call(
76+ ["apt", "update"],
77+ cwd=PosixPath("/root/lpcraft/project"),
78+ env={"CONDA_ENV": "lpci"},
79+ stdout=ANY,
80+ stderr=ANY,
81+ ),
82+ call(
83+ [
84+ "apt",
85+ "install",
86+ "-y",
87+ "git",
88+ "python3-dev",
89+ "python3-pip",
90+ "python3-venv",
91+ "wget",
92+ "automake",
93+ "build-essential",
94+ "cmake",
95+ "gcc",
96+ "g++",
97+ "libc++-dev",
98+ "libc6-dev",
99+ "libffi-dev",
100+ "libjpeg-dev",
101+ "libpng-dev",
102+ "libreadline-dev",
103+ "libsqlite3-dev",
104+ "libtool",
105+ "zlib1g-dev",
106+ ],
107+ cwd=PosixPath("/root/lpcraft/project"),
108+ env={"CONDA_ENV": "lpci"},
109+ stdout=ANY,
110+ stderr=ANY,
111+ ),
112+ call(
113+ [
114+ "bash",
115+ "--noprofile",
116+ "--norc",
117+ "-ec",
118+ pre_run_command,
119+ ],
120+ cwd=PosixPath("/root/lpcraft/project"),
121+ env={"CONDA_ENV": "lpci"},
122+ stdout=ANY,
123+ stderr=ANY,
124+ ),
125+ call(
126+ [
127+ "bash",
128+ "--noprofile",
129+ "--norc",
130+ "-ec",
131+ run_command,
132+ ],
133+ cwd=PosixPath("/root/lpcraft/project"),
134+ env={"CONDA_ENV": "lpci"},
135+ stdout=ANY,
136+ stderr=ANY,
137+ ),
138+ call(
139+ [
140+ "bash",
141+ "--noprofile",
142+ "--norc",
143+ "-ec",
144+ post_run_command,
145+ ],
146+ cwd=PosixPath("/root/lpcraft/project"),
147+ env={"CONDA_ENV": "lpci"},
148+ stdout=ANY,
149+ stderr=ANY,
150+ ),
151+ ],
152+ execute_run.call_args_list,
153+ )
154+
155+ def test_conda_build_plugin_finds_recipe(self):
156+ config = dedent(
157+ """
158+ pipeline:
159+ - build
160+
161+ jobs:
162+ build:
163+ series: focal
164+ architectures: amd64
165+ plugin: conda-build
166+ conda-channels:
167+ - conda-forge
168+ conda-packages:
169+ - mamba
170+ - pip
171+ conda-python: 3.8
172+ run: |
173+ pip install --upgrade pytest
174+ """
175+ )
176+ config_path = Path(".launchpad.yaml")
177+ config_path.write_text(config)
178+ Path("include/fake_subdir").mkdir(parents=True)
179+ meta_yaml = Path("info/recipe/meta.yaml")
180+ meta_yaml.parent.mkdir(parents=True)
181+ meta_yaml.touch()
182+ config_obj = lpcraft.config.Config.load(config_path)
183+ self.assertEqual(config_obj.jobs["build"][0].plugin, "conda-build")
184+ pm = get_plugin_manager(config_obj.jobs["build"][0])
185+ plugins = pm.get_plugins()
186+ plugin_match = [
187+ _ for _ in plugins if _.__class__.__name__ == "CondaBuildPlugin"
188+ ]
189+ self.assertEqual("info/recipe", plugin_match[0].build_target)
190+
191+ def test_conda_build_plugin_finds_recipe_with_fake_parent(self):
192+ config = dedent(
193+ """
194+ pipeline:
195+ - build
196+
197+ jobs:
198+ build:
199+ series: focal
200+ architectures: amd64
201+ plugin: conda-build
202+ conda-channels:
203+ - conda-forge
204+ conda-packages:
205+ - mamba
206+ - pip
207+ conda-python: 3.8
208+ run: |
209+ pip install --upgrade pytest
210+ """
211+ )
212+ config_path = Path(".launchpad.yaml")
213+ config_path.write_text(config)
214+ meta_yaml = Path("info/recipe/meta.yaml")
215+ meta_yaml.parent.mkdir(parents=True)
216+ parent_path = meta_yaml.parent.joinpath("parent")
217+ parent_path.mkdir()
218+ parent_path.joinpath("some_file.yaml").touch()
219+ meta_yaml.touch()
220+ config_obj = lpcraft.config.Config.load(config_path)
221+ self.assertEqual(config_obj.jobs["build"][0].plugin, "conda-build")
222+ pm = get_plugin_manager(config_obj.jobs["build"][0])
223+ plugins = pm.get_plugins()
224+ plugin_match = [
225+ _ for _ in plugins if _.__class__.__name__ == "CondaBuildPlugin"
226+ ]
227+ self.assertEqual("info/recipe", plugin_match[0].build_target)
228+
229+ def test_conda_build_plugin_finds_parent_recipe(self):
230+ config = dedent(
231+ """
232+ pipeline:
233+ - build
234+
235+ jobs:
236+ build:
237+ series: focal
238+ architectures: amd64
239+ plugin: conda-build
240+ conda-channels:
241+ - conda-forge
242+ conda-packages:
243+ - mamba
244+ - pip
245+ conda-python: 3.8
246+ run: |
247+ pip install --upgrade pytest
248+ """
249+ )
250+ config_path = Path(".launchpad.yaml")
251+ config_path.write_text(config)
252+ Path("include/fake_subdir").mkdir(parents=True)
253+ meta_yaml = Path("info/recipe/meta.yaml")
254+ parent_meta_yaml = meta_yaml.parent.joinpath("parent/meta.yaml")
255+ parent_meta_yaml.parent.mkdir(parents=True)
256+ meta_yaml.touch()
257+ parent_meta_yaml.touch()
258+ config_obj = lpcraft.config.Config.load(config_path)
259+ self.assertEqual(config_obj.jobs["build"][0].plugin, "conda-build")
260+ pm = get_plugin_manager(config_obj.jobs["build"][0])
261+ plugins = pm.get_plugins()
262+ plugin_match = [
263+ _ for _ in plugins if _.__class__.__name__ == "CondaBuildPlugin"
264+ ]
265+ self.assertEqual("info/recipe/parent", plugin_match[0].build_target)
266+
267+ def test_conda_build_plugin_uses_child_vars_with_parent_recipe(self):
268+ config = dedent(
269+ """
270+ pipeline:
271+ - build
272+
273+ jobs:
274+ build:
275+ series: focal
276+ architectures: amd64
277+ plugin: conda-build
278+ conda-channels:
279+ - conda-forge
280+ conda-packages:
281+ - mamba
282+ - pip
283+ conda-python: 3.8
284+ run: |
285+ pip install --upgrade pytest
286+ """
287+ )
288+ run_command = dedent(
289+ """
290+ export PATH=$HOME/miniconda3/bin:$PATH
291+ source activate $CONDA_ENV
292+ conda-build --no-anaconda-upload --output-folder dist -c conda-forge -c defaults -m info/recipe/parent/conda_build_config.yaml -m info/recipe/conda_build_config.yaml info/recipe/parent
293+ pip install --upgrade pytest
294+ """ # noqa: E501
295+ )
296+ config_path = Path(".launchpad.yaml")
297+ config_path.write_text(config)
298+ Path("include/fake_subdir").mkdir(parents=True)
299+ meta_yaml = Path("info/recipe/meta.yaml")
300+ variant_config = meta_yaml.with_name("conda_build_config.yaml")
301+ parent_meta_yaml = meta_yaml.parent.joinpath("parent/meta.yaml")
302+ parent_variant_config = parent_meta_yaml.with_name(
303+ "conda_build_config.yaml"
304+ )
305+ parent_meta_yaml.parent.mkdir(parents=True)
306+ meta_yaml.touch()
307+ variant_config.touch()
308+ parent_variant_config.touch()
309+ parent_meta_yaml.touch()
310+ config_obj = lpcraft.config.Config.load(config_path)
311+ self.assertEqual(config_obj.jobs["build"][0].plugin, "conda-build")
312+ pm = get_plugin_manager(config_obj.jobs["build"][0])
313+ plugins = pm.get_plugins()
314+ plugin_match = [
315+ _ for _ in plugins if _.__class__.__name__ == "CondaBuildPlugin"
316+ ]
317+ self.assertEqual(
318+ [parent_variant_config.as_posix(), variant_config.as_posix()],
319+ plugin_match[0].build_configs,
320+ )
321+ self.assertEqual(run_command, plugin_match[0].lpcraft_execute_run())
322+
323+ def test_conda_build_plugin_renames_recipe_templates(self):
324+ config = dedent(
325+ """
326+ pipeline:
327+ - build
328+
329+ jobs:
330+ build:
331+ series: focal
332+ architectures: amd64
333+ plugin: conda-build
334+ conda-channels:
335+ - conda-forge
336+ conda-packages:
337+ - mamba
338+ - pip
339+ conda-python: 3.8
340+ run: |
341+ pip install --upgrade pytest
342+ """
343+ )
344+ config_path = Path(".launchpad.yaml")
345+ config_path.write_text(config)
346+ meta_yaml = Path("info/recipe/meta.yaml")
347+ template_meta_yaml = meta_yaml.with_name("meta.yaml.template")
348+ meta_yaml.parent.mkdir(parents=True)
349+ meta_yaml.touch()
350+ template_meta_yaml.touch()
351+ config_obj = lpcraft.config.Config.load(config_path)
352+ self.assertEqual(config_obj.jobs["build"][0].plugin, "conda-build")
353+ pm = get_plugin_manager(config_obj.jobs["build"][0])
354+ plugins = pm.get_plugins()
355+ plugin_match = [
356+ _ for _ in plugins if _.__class__.__name__ == "CondaBuildPlugin"
357+ ]
358+ self.assertEqual("info/recipe", plugin_match[0].build_target)
359+ self.assertFalse(template_meta_yaml.is_file())
360+
361+ def test_conda_build_plugin_raises_error_if_no_recipe(self):
362+ config = dedent(
363+ """
364+ pipeline:
365+ - build
366+
367+ jobs:
368+ build:
369+ series: focal
370+ architectures: amd64
371+ plugin: conda-build
372+ conda-channels:
373+ - conda-forge
374+ conda-packages:
375+ - mamba
376+ - pip
377+ conda-python: 3.8
378+ run: |
379+ pip install --upgrade pytest
380+ """
381+ )
382+ config_path = Path(".launchpad.yaml")
383+ config_path.write_text(config)
384+ config_obj = lpcraft.config.Config.load(config_path)
385+ self.assertRaisesRegex(
386+ RuntimeError,
387+ "No build target found",
388+ get_plugin_manager,
389+ config_obj.jobs["build"][0],
390+ )
391+
392+ def test_conda_build_plugin_raises_error_if_no_recipe_in_recipe_folder(
393+ self,
394+ ):
395+ config = dedent(
396+ """
397+ pipeline:
398+ - build
399+
400+ jobs:
401+ build:
402+ series: focal
403+ architectures: amd64
404+ plugin: conda-build
405+ conda-channels:
406+ - conda-forge
407+ conda-packages:
408+ - mamba
409+ - pip
410+ conda-python: 3.8
411+ run: |
412+ pip install --upgrade pytest
413+ """
414+ )
415+ config_path = Path(".launchpad.yaml")
416+ config_path.write_text(config)
417+ Path("include/fake_subdir").mkdir(parents=True)
418+ # there is a recipe folder, but no meta.yaml file
419+ meta_yaml = Path("info/recipe/")
420+ meta_yaml.mkdir(parents=True)
421+ config_obj = lpcraft.config.Config.load(config_path)
422+ self.assertRaisesRegex(
423+ RuntimeError,
424+ "No build target found",
425+ get_plugin_manager,
426+ config_obj.jobs["build"][0],
427+ )
428diff --git a/lpcraft/plugins/plugins.py b/lpcraft/plugins/plugins.py
429index c114030..0797fba 100644
430--- a/lpcraft/plugins/plugins.py
431+++ b/lpcraft/plugins/plugins.py
432@@ -3,9 +3,15 @@
433
434 from __future__ import annotations # isort:skip
435
436-__all__ = ["ToxPlugin", "PyProjectBuildPlugin", "MiniCondaPlugin"]
437+__all__ = [
438+ "ToxPlugin",
439+ "PyProjectBuildPlugin",
440+ "MiniCondaPlugin",
441+ "CondaBuildPlugin",
442+]
443
444 import textwrap
445+from pathlib import Path
446 from typing import TYPE_CHECKING, ClassVar, List, Optional, cast
447
448 import pydantic
449@@ -213,3 +219,174 @@ class MiniCondaPlugin(BasePlugin):
450 "export PATH=$HOME/miniconda3/bin:$PATH; "
451 f"source activate $CONDA_ENV; conda env export{run}"
452 )
453+
454+
455+@register(name="conda-build")
456+class CondaBuildPlugin(MiniCondaPlugin):
457+ """Sets up `miniconda3` and performs a `conda-build` on a package.
458+
459+ Usage:
460+ In `.launchpad.yaml`, create the following structure:
461+
462+ .. code-block:: yaml
463+
464+ jobs:
465+ myjob:
466+ plugin: conda-build
467+ build-target: info/recipe/parent
468+ conda-channels:
469+ - conda-forge
470+ - defaults
471+ conda-packages:
472+ - mamba
473+ - numpy=1.17
474+ - scipy
475+ - pip
476+ conda-python: 3.8
477+ run: |
478+ conda install ....
479+ pip install --upgrade pytest
480+ python -m build .
481+ """
482+
483+ class Config(MiniCondaPlugin.Config):
484+ build_target: Optional[StrictStr]
485+ conda_channels: Optional[List[StrictStr]]
486+ conda_packages: Optional[List[StrictStr]]
487+ conda_python: Optional[StrictStr]
488+
489+ DEFAULT_CONDA_PACKAGES = ("conda-build",)
490+
491+ def get_plugin_config(self) -> "CondaBuildPlugin.Config":
492+ return cast(CondaBuildPlugin.Config, self.config.plugin_config)
493+
494+ @staticmethod
495+ def _has_recipe(dir_: Path) -> bool:
496+ return dir_.joinpath("meta.yaml").is_file()
497+
498+ @staticmethod
499+ def _rename_recipe_template(dir_: Path) -> None:
500+ # XXX techalchemy 2022-04-01: conda packages which are already built
501+ # and subsequently downloaded from the anaconda repositories retain
502+ # the templated recipe, at `meta.yaml.template`, but place the
503+ # rendered template at `meta.yaml`. The rendered recipes contain
504+ # hardcoded paths for a specific build environment and, for our
505+ # purposes, are not reusable. We need to render new ones from the
506+ # original templates.
507+ template_path = dir_.joinpath("meta.yaml.template")
508+ if template_path.is_file():
509+ template_path.replace(dir_ / "meta.yaml")
510+
511+ def find_recipe(self) -> Path:
512+ def _find_recipe_dir(path: Path) -> Path:
513+ for subpath in path.iterdir():
514+ if subpath.is_dir():
515+ self._rename_recipe_template(subpath)
516+ if subpath.name == "recipe" and self._has_recipe(subpath):
517+ return subpath
518+ try:
519+ return _find_recipe_dir(subpath)
520+ except FileNotFoundError:
521+ continue
522+ raise FileNotFoundError
523+
524+ return _find_recipe_dir(Path("."))
525+
526+ def find_build_target(self) -> str:
527+ def find_parents(pth: Path) -> Path:
528+ for parent in pth.iterdir():
529+ if parent.is_dir():
530+ self._rename_recipe_template(parent)
531+ if parent.name == "parent" and self._has_recipe(parent):
532+ return parent
533+ raise FileNotFoundError(pth.joinpath("meta.yaml"))
534+
535+ try:
536+ recipe = self.find_recipe()
537+ except FileNotFoundError:
538+ raise RuntimeError("No build target found")
539+ try:
540+ # XXX techalchemy 2022-04-01: Some conda packages are built as
541+ # part of a parent package build process (e.g. `mkl-include` which
542+ # is built by `intel_repack`). If you acquire the child package
543+ # and attempt to build it (`mkl-include` in this case) it will
544+ # fail; you must build the parent instead if it exists
545+ return find_parents(recipe).as_posix()
546+ except FileNotFoundError:
547+ return recipe.as_posix()
548+
549+ @property
550+ def build_configs(self) -> list[str]:
551+ try:
552+ recipe = self.find_recipe()
553+ except FileNotFoundError:
554+ return []
555+ configs = sorted(
556+ recipe.glob("**/conda_build_config.yaml"), reverse=True
557+ )
558+ return [_.as_posix() for _ in configs]
559+
560+ @property
561+ def build_target(self) -> str:
562+ build_target = self.get_plugin_config().build_target
563+ if not build_target:
564+ return self.find_build_target()
565+ return build_target
566+
567+ @hookimpl # type: ignore
568+ def lpcraft_set_environment(self) -> dict[str, str]:
569+ # XXX techalchemy 2022-04-01: mypy is struggling with the super() call
570+ rv: dict[str, str] = super().lpcraft_set_environment()
571+ return rv
572+
573+ @hookimpl # type: ignore
574+ def lpcraft_execute_before_run(self) -> str:
575+ # XXX techalchemy 2022-04-01: mypy is struggling with the super() call
576+ rv: str = super().lpcraft_execute_before_run()
577+ return rv
578+
579+ @hookimpl # type: ignore
580+ def lpcraft_execute_after_run(self) -> str:
581+ # XXX techalchemy 2022-04-01: mypy is struggling with the super() call
582+ rv: str = super().lpcraft_execute_after_run()
583+ return rv
584+
585+ @hookimpl # type: ignore
586+ def lpcraft_install_packages(self) -> list[str]:
587+ # XXX techalchemy 2022-04-01: mypy is struggling with the super() call
588+ base_packages: list[str] = super().lpcraft_install_packages()
589+ base_packages.extend(
590+ [
591+ "automake",
592+ "build-essential",
593+ "cmake",
594+ "gcc",
595+ "g++",
596+ "libc++-dev",
597+ "libc6-dev",
598+ "libffi-dev",
599+ "libjpeg-dev",
600+ "libpng-dev",
601+ "libreadline-dev",
602+ "libsqlite3-dev",
603+ "libtool",
604+ "zlib1g-dev",
605+ ]
606+ )
607+ return base_packages
608+
609+ @hookimpl # type: ignore
610+ def lpcraft_execute_run(self) -> str:
611+ conda_channels = " ".join(f"-c {_}" for _ in self.conda_channels)
612+ conda_channels = f" {conda_channels}" if conda_channels else ""
613+ configs = " ".join(f"-m {_}" for _ in self.build_configs)
614+ configs = f" {configs}" if configs else ""
615+ build_command = "conda-build --no-anaconda-upload --output-folder dist"
616+ run_command = self.config.run or ""
617+ return textwrap.dedent(
618+ f"""
619+ export PATH=$HOME/miniconda3/bin:$PATH
620+ source activate $CONDA_ENV
621+ {build_command}{conda_channels}{configs} {self.build_target}
622+ {run_command}"""
623+ )

Subscribers

People subscribed via source and target branches