craft-application 2.0.0__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. {craft-application-2.0.0 → craft-application-2.2.0}/PKG-INFO +2 -2
  2. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/__init__.py +1 -1
  3. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/_version.py +2 -2
  4. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/application.py +101 -26
  5. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/errors.py +2 -2
  6. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/launchpad/models/recipe.py +53 -14
  7. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/services/package.py +6 -0
  8. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/util/yaml.py +1 -0
  9. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application.egg-info/PKG-INFO +2 -2
  10. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application.egg-info/SOURCES.txt +4 -0
  11. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application.egg-info/requires.txt +1 -1
  12. {craft-application-2.0.0 → craft-application-2.2.0}/pyproject.toml +1 -1
  13. craft-application-2.2.0/tests/integration/data/valid_projects/grammar/src/on-amd64-to-amd64/hello.txt +1 -0
  14. craft-application-2.2.0/tests/integration/data/valid_projects/grammar/src/on-amd64-to-arm64/hello.txt +1 -0
  15. craft-application-2.2.0/tests/integration/data/valid_projects/grammar/stderr +1 -0
  16. craft-application-2.2.0/tests/integration/data/valid_projects/grammar/testcraft.yaml +11 -0
  17. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/test_application.py +5 -5
  18. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/launchpad/test_launchpad.py +62 -0
  19. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/test_application.py +331 -26
  20. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/util/test_yaml.py +10 -0
  21. {craft-application-2.0.0 → craft-application-2.2.0}/.editorconfig +0 -0
  22. {craft-application-2.0.0 → craft-application-2.2.0}/.github/.jira_sync_config.yaml +0 -0
  23. {craft-application-2.0.0 → craft-application-2.2.0}/.github/ISSUE_TEMPLATE/bug.yaml +0 -0
  24. {craft-application-2.0.0 → craft-application-2.2.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  25. {craft-application-2.0.0 → craft-application-2.2.0}/.github/ISSUE_TEMPLATE/task.yaml +0 -0
  26. {craft-application-2.0.0 → craft-application-2.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  27. {craft-application-2.0.0 → craft-application-2.2.0}/.github/release-drafter.yaml +0 -0
  28. {craft-application-2.0.0 → craft-application-2.2.0}/.github/release-drafter.yml +0 -0
  29. {craft-application-2.0.0 → craft-application-2.2.0}/.github/renovate.json5 +0 -0
  30. {craft-application-2.0.0 → craft-application-2.2.0}/.github/workflows/cla-check.yaml +0 -0
  31. {craft-application-2.0.0 → craft-application-2.2.0}/.github/workflows/docs.yaml +0 -0
  32. {craft-application-2.0.0 → craft-application-2.2.0}/.github/workflows/release-drafter.yaml +0 -0
  33. {craft-application-2.0.0 → craft-application-2.2.0}/.github/workflows/tests.yaml +0 -0
  34. {craft-application-2.0.0 → craft-application-2.2.0}/.gitignore +0 -0
  35. {craft-application-2.0.0 → craft-application-2.2.0}/.pre-commit-config.yaml +0 -0
  36. {craft-application-2.0.0 → craft-application-2.2.0}/.readthedocs.yaml +0 -0
  37. {craft-application-2.0.0 → craft-application-2.2.0}/.yamllint.yaml +0 -0
  38. {craft-application-2.0.0 → craft-application-2.2.0}/HACKING.rst +0 -0
  39. {craft-application-2.0.0 → craft-application-2.2.0}/LICENSE +0 -0
  40. {craft-application-2.0.0 → craft-application-2.2.0}/README.rst +0 -0
  41. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/commands/__init__.py +0 -0
  42. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/commands/base.py +0 -0
  43. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/commands/lifecycle.py +0 -0
  44. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/commands/other.py +0 -0
  45. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/grammar.py +0 -0
  46. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/launchpad/__init__.py +0 -0
  47. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/launchpad/errors.py +0 -0
  48. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/launchpad/launchpad.py +0 -0
  49. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/launchpad/models/__init__.py +0 -0
  50. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/launchpad/models/base.py +0 -0
  51. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/launchpad/models/build.py +0 -0
  52. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/launchpad/models/code.py +0 -0
  53. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/launchpad/models/distro.py +0 -0
  54. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/launchpad/models/project.py +0 -0
  55. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/launchpad/util.py +0 -0
  56. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/misc/instance_bashrc +0 -0
  57. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/models/__init__.py +0 -0
  58. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/models/base.py +0 -0
  59. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/models/constraints.py +0 -0
  60. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/models/grammar.py +0 -0
  61. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/models/metadata.py +0 -0
  62. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/models/project.py +0 -0
  63. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/py.typed +0 -0
  64. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/remote/__init__.py +0 -0
  65. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/remote/errors.py +0 -0
  66. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/remote/git.py +0 -0
  67. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/remote/utils.py +0 -0
  68. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/remote/worktree.py +0 -0
  69. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/secrets.py +0 -0
  70. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/services/__init__.py +0 -0
  71. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/services/base.py +0 -0
  72. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/services/lifecycle.py +0 -0
  73. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/services/provider.py +0 -0
  74. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/services/remotebuild.py +0 -0
  75. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/services/request.py +0 -0
  76. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/services/service_factory.py +0 -0
  77. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/util/__init__.py +0 -0
  78. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/util/callbacks.py +0 -0
  79. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/util/error_formatting.py +0 -0
  80. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/util/logging.py +0 -0
  81. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/util/paths.py +0 -0
  82. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/util/platforms.py +0 -0
  83. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/util/repositories.py +0 -0
  84. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/util/snap_config.py +0 -0
  85. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application/util/string.py +0 -0
  86. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application.egg-info/dependency_links.txt +0 -0
  87. {craft-application-2.0.0 → craft-application-2.2.0}/craft_application.egg-info/top_level.txt +0 -0
  88. {craft-application-2.0.0 → craft-application-2.2.0}/docs/_static/css/custom.css +0 -0
  89. {craft-application-2.0.0 → craft-application-2.2.0}/docs/conf.py +0 -0
  90. {craft-application-2.0.0 → craft-application-2.2.0}/docs/explanation/index.rst +0 -0
  91. {craft-application-2.0.0 → craft-application-2.2.0}/docs/howto/index.rst +0 -0
  92. {craft-application-2.0.0 → craft-application-2.2.0}/docs/index.rst +0 -0
  93. {craft-application-2.0.0 → craft-application-2.2.0}/docs/reference/index.rst +0 -0
  94. {craft-application-2.0.0 → craft-application-2.2.0}/docs/tutorials/index.rst +0 -0
  95. {craft-application-2.0.0 → craft-application-2.2.0}/setup.cfg +0 -0
  96. {craft-application-2.0.0 → craft-application-2.2.0}/tests/__init__.py +0 -0
  97. {craft-application-2.0.0 → craft-application-2.2.0}/tests/conftest.py +0 -0
  98. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/__init__.py +0 -0
  99. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/conftest.py +0 -0
  100. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/data/build-secrets/secret-source-folder/source-file.txt +0 -0
  101. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/data/build-secrets/testcraft.yaml +0 -0
  102. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/data/invalid_projects/build-error/testcraft.yaml +0 -0
  103. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/data/valid_projects/adoption/stderr +0 -0
  104. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/data/valid_projects/adoption/testcraft.yaml +0 -0
  105. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/data/valid_projects/basic/stderr +0 -0
  106. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/data/valid_projects/basic/testcraft.yaml +0 -0
  107. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/data/valid_projects/environment/stderr +0 -0
  108. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/data/valid_projects/environment/testcraft.yaml +0 -0
  109. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/launchpad/__init__.py +0 -0
  110. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/launchpad/conftest.py +0 -0
  111. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/launchpad/test_anonymous_access.py +0 -0
  112. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/services/__init__.py +0 -0
  113. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/services/test_lifecycle.py +0 -0
  114. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/services/test_provider.py +0 -0
  115. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/services/test_request.py +0 -0
  116. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/services/test_service_factory.py +0 -0
  117. {craft-application-2.0.0 → craft-application-2.2.0}/tests/integration/test_version.py +0 -0
  118. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/__init__.py +0 -0
  119. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/commands/__init__.py +0 -0
  120. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/commands/test_base.py +0 -0
  121. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/commands/test_lifecycle.py +0 -0
  122. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/commands/test_other.py +0 -0
  123. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/conftest.py +0 -0
  124. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/launchpad/__init__.py +0 -0
  125. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/launchpad/conftest.py +0 -0
  126. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/launchpad/models/__init__.py +0 -0
  127. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/launchpad/models/test_base.py +0 -0
  128. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/launchpad/models/test_code.py +0 -0
  129. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/launchpad/test_util.py +0 -0
  130. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/models/__init__.py +0 -0
  131. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/models/project_models/basic_project.yaml +0 -0
  132. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/models/project_models/full_project.yaml +0 -0
  133. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/models/project_models/invalid_project.yaml +0 -0
  134. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/models/test_constraints.py +0 -0
  135. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/models/test_project.py +0 -0
  136. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/remote/__init__.py +0 -0
  137. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/remote/conftest.py +0 -0
  138. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/remote/test_errors.py +0 -0
  139. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/remote/test_git.py +0 -0
  140. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/remote/test_utils.py +0 -0
  141. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/remote/test_worktree.py +0 -0
  142. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/services/__init__.py +0 -0
  143. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/services/conftest.py +0 -0
  144. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/services/test_lifecycle.py +0 -0
  145. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/services/test_package.py +0 -0
  146. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/services/test_provider.py +0 -0
  147. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/services/test_remotebuild.py +0 -0
  148. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/services/test_repositories.py +0 -0
  149. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/services/test_request.py +0 -0
  150. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/services/test_service_factory.py +0 -0
  151. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/test_errors.py +0 -0
  152. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/test_grammar.py +0 -0
  153. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/test_nothing.py +0 -0
  154. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/test_secrets.py +0 -0
  155. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/util/__init__.py +0 -0
  156. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/util/invalid_yaml/_README +0 -0
  157. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/util/invalid_yaml/duplicate_second_level.yaml-invalid +0 -0
  158. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/util/invalid_yaml/duplicate_top_level.yaml-invalid +0 -0
  159. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/util/invalid_yaml/unhashable.yaml-invalid +0 -0
  160. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/util/test_error_formatting.py +0 -0
  161. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/util/test_logging.py +0 -0
  162. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/util/test_paths.py +0 -0
  163. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/util/test_snap_config.py +0 -0
  164. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/util/test_string.py +0 -0
  165. {craft-application-2.0.0 → craft-application-2.2.0}/tests/unit/util/valid_yaml/empty.yaml +0 -0
  166. {craft-application-2.0.0 → craft-application-2.2.0}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: craft-application
3
- Version: 2.0.0
3
+ Version: 2.2.0
4
4
  Summary: A framework for *craft applications.
5
5
  Classifier: Development Status :: 5 - Production/Stable
6
6
  Classifier: Environment :: Console
@@ -35,7 +35,7 @@ Requires-Dist: hypothesis>=6.0; extra == "dev"
35
35
  Requires-Dist: pyfakefs~=5.3; extra == "dev"
36
36
  Requires-Dist: pytest==8.1.1; extra == "dev"
37
37
  Requires-Dist: pytest-check==2.3.1; extra == "dev"
38
- Requires-Dist: pytest-cov==4.1.0; extra == "dev"
38
+ Requires-Dist: pytest-cov==5.0.0; extra == "dev"
39
39
  Requires-Dist: pytest-mock==3.12.0; extra == "dev"
40
40
  Requires-Dist: pytest-rerunfailures==13.0; extra == "dev"
41
41
  Requires-Dist: pytest-time>=0.3.1; extra == "dev"
@@ -32,7 +32,7 @@ except ImportError: # pragma: no cover
32
32
  from importlib.metadata import version, PackageNotFoundError
33
33
 
34
34
  try:
35
- __version__ = version("craft-archives")
35
+ __version__ = version("craft-application")
36
36
  except PackageNotFoundError:
37
37
  __version__ = "dev"
38
38
 
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '2.0.0'
16
- __version_tuple__ = version_tuple = (2, 0, 0)
15
+ __version__ = version = '2.2.0'
16
+ __version_tuple__ = version_tuple = (2, 2, 0)
@@ -125,6 +125,7 @@ class Application:
125
125
  self._command_groups: list[craft_cli.CommandGroup] = []
126
126
  self._global_arguments: list[craft_cli.GlobalArgument] = [GLOBAL_VERSION]
127
127
  self._cli_loggers = DEFAULT_CLI_LOGGERS | set(extra_loggers)
128
+ self._full_build_plan: list[models.BuildInfo] = []
128
129
  self._build_plan: list[models.BuildInfo] = []
129
130
  # When build_secrets are enabled, this contains the secret info to pass to
130
131
  # managed instances.
@@ -235,6 +236,7 @@ class Application:
235
236
  """
236
237
  if project_dir is None:
237
238
  project_dir = self.project_dir
239
+
238
240
  return (project_dir / f"{self.app.name}.yaml").resolve(strict=True)
239
241
 
240
242
  def get_project(
@@ -255,18 +257,29 @@ class Application:
255
257
  if self.__project is not None:
256
258
  return self.__project
257
259
 
258
- project_path = self._resolve_project_path(self.project_dir)
260
+ try:
261
+ project_path = self._resolve_project_path(self.project_dir)
262
+ except FileNotFoundError as err:
263
+ raise errors.ProjectFileMissingError(
264
+ f"Project file '{self.app.name}.yaml' not found in '{self.project_dir}'.",
265
+ details="The project file could not be found.",
266
+ resolution="Ensure the project file exists.",
267
+ retcode=66, # EX_NOINPUT from sysexits.h
268
+ ) from err
259
269
  craft_cli.emit.debug(f"Loading project file '{project_path!s}'")
260
270
 
261
271
  with project_path.open() as file:
262
272
  yaml_data = util.safe_yaml_load(file)
263
273
 
274
+ host_arch = util.get_host_architecture()
264
275
  build_planner = self.app.BuildPlannerClass.unmarshal(yaml_data)
265
- full_build_plan = build_planner.get_build_plan()
266
- self._build_plan = _filter_plan(full_build_plan, platform, build_for)
276
+ self._full_build_plan = build_planner.get_build_plan()
277
+ self._build_plan = filter_plan(
278
+ self._full_build_plan, platform, build_for, host_arch
279
+ )
267
280
 
268
- if platform:
269
- all_platforms = {b.platform: b for b in full_build_plan}
281
+ if platform and not build_for:
282
+ all_platforms = {b.platform: b for b in self._full_build_plan}
270
283
  if platform not in all_platforms:
271
284
  raise errors.InvalidPlatformError(platform, list(all_platforms.keys()))
272
285
  build_for = all_platforms[platform].build_for
@@ -274,7 +287,7 @@ class Application:
274
287
  # validate project grammar
275
288
  GrammarAwareProject.validate_grammar(yaml_data)
276
289
 
277
- build_on = util.get_host_architecture()
290
+ build_on = host_arch
278
291
  yaml_data = self._transform_project_yaml(yaml_data, build_on, build_for)
279
292
  self.__project = self.app.ProjectClass.from_yaml_data(yaml_data, project_path)
280
293
 
@@ -310,7 +323,10 @@ class Application:
310
323
  if build_for and build_for != build_info.build_for:
311
324
  continue
312
325
 
313
- env = {"CRAFT_PLATFORM": build_info.platform}
326
+ env = {
327
+ "CRAFT_PLATFORM": build_info.platform,
328
+ "CRAFT_VERBOSITY_LEVEL": craft_cli.emit.get_mode().name,
329
+ }
314
330
 
315
331
  if self.app.features.build_secrets:
316
332
  # If using build secrets, put them in the environment of the managed
@@ -351,12 +367,7 @@ class Application:
351
367
 
352
368
  :returns: A ready-to-run Dispatcher object
353
369
  """
354
- dispatcher = craft_cli.Dispatcher(
355
- self.app.name,
356
- self.command_groups,
357
- summary=str(self.app.summary),
358
- extra_global_args=self._global_arguments,
359
- )
370
+ dispatcher = self._create_dispatcher()
360
371
 
361
372
  try:
362
373
  craft_cli.emit.trace("pre-parsing arguments...")
@@ -371,8 +382,8 @@ class Application:
371
382
  global_args = dispatcher.pre_parse_args(sys.argv[1:])
372
383
 
373
384
  if global_args.get("version"):
385
+ craft_cli.emit.message(f"{self.app.name} {self.app.version}")
374
386
  craft_cli.emit.ended_ok()
375
- print(f"{self.app.name} {self.app.version}")
376
387
  sys.exit(0)
377
388
  except craft_cli.ProvideHelpException as err:
378
389
  print(err, file=sys.stderr) # to stderr, as argparse normally does
@@ -400,6 +411,19 @@ class Application:
400
411
 
401
412
  return dispatcher
402
413
 
414
+ def _create_dispatcher(self) -> craft_cli.Dispatcher:
415
+ """Create the Dispatcher that will run the application's command.
416
+
417
+ Subclasses can override this if they need to create a Dispatcher with
418
+ different parameters.
419
+ """
420
+ return craft_cli.Dispatcher(
421
+ self.app.name,
422
+ self.command_groups,
423
+ summary=str(self.app.summary),
424
+ extra_global_args=self._global_arguments,
425
+ )
426
+
403
427
  def _get_app_plugins(self) -> dict[str, PluginType]:
404
428
  """Get the plugins for this application.
405
429
 
@@ -490,6 +514,7 @@ class Application:
490
514
  return_code = 128 + signal.SIGINT
491
515
  except craft_cli.CraftError as err:
492
516
  self._emit_error(err)
517
+ return_code = err.retcode
493
518
  except craft_parts.PartsError as err:
494
519
  self._emit_error(
495
520
  craft_cli.CraftError(
@@ -549,7 +574,20 @@ class Application:
549
574
  self._render_secrets(yaml_data)
550
575
 
551
576
  # Expand grammar.
552
- if build_for and "parts" in yaml_data:
577
+ if "parts" in yaml_data:
578
+ if not build_for:
579
+ # At the moment there is no perfect solution for what do to do
580
+ # resolve the grammar if there's no explicitly-provided target
581
+ # arch. However, we must resolve it with *something* otherwise
582
+ # we might have an invalid parts definition full of grammar
583
+ # declarations.
584
+ if self._build_plan:
585
+ # If we have a build plan use one of its items, because
586
+ # it's likely contemplated on the grammar.
587
+ build_for = self._build_plan[0].build_for
588
+ else:
589
+ # As a last resort, default to the same arch as the host.
590
+ build_for = build_on
553
591
  craft_cli.emit.debug(f"Processing grammar (on {build_on} for {build_for})")
554
592
  yaml_data["parts"] = grammar.process_parts(
555
593
  parts_yaml_data=yaml_data["parts"],
@@ -623,32 +661,69 @@ class Application:
623
661
  # Set the logging level to DEBUG for all craft-libraries. This is OK even if
624
662
  # the specific application doesn't use a specific library, the call does not
625
663
  # import the package.
664
+ emitter_mode: craft_cli.EmitterMode = craft_cli.EmitterMode.BRIEF
665
+ invalid_emitter_level = False
626
666
  util.setup_loggers(*self._cli_loggers)
627
667
 
668
+ # environment variable takes precedence over the default
669
+ emitter_verbosity_level_env = os.environ.get("CRAFT_VERBOSITY_LEVEL", None)
670
+
671
+ if emitter_verbosity_level_env:
672
+ try:
673
+ emitter_mode = craft_cli.EmitterMode[
674
+ emitter_verbosity_level_env.strip().upper()
675
+ ]
676
+ except KeyError:
677
+ invalid_emitter_level = True
678
+
628
679
  craft_cli.emit.init(
629
- mode=craft_cli.EmitterMode.BRIEF,
680
+ mode=emitter_mode,
630
681
  appname=self.app.name,
631
682
  greeting=f"Starting {self.app.name}, version {self.app.version}",
632
683
  log_filepath=self.log_path,
633
684
  streaming_brief=True,
634
685
  )
635
686
 
687
+ craft_cli.emit.debug(f"Log verbosity level set to {emitter_mode.name}")
688
+
689
+ if invalid_emitter_level:
690
+ craft_cli.emit.progress(
691
+ f"Invalid verbosity level '{emitter_verbosity_level_env}', using default 'BRIEF'.\n"
692
+ f"Valid levels are: {', '.join(emitter.name for emitter in craft_cli.EmitterMode)}",
693
+ permanent=True,
694
+ )
695
+
636
696
 
637
- def _filter_plan(
697
+ def filter_plan(
638
698
  build_plan: list[BuildInfo],
639
699
  platform: str | None,
640
700
  build_for: str | None,
701
+ host_arch: str | None,
641
702
  ) -> list[BuildInfo]:
642
- """Filter out builds not matching build-on and build-for."""
643
- host_arch = util.get_host_architecture()
703
+ """Filter out build plans that are not matching build-on, build-for, and platform.
704
+
705
+ If the host_arch is None, ignore the build-on check for remote builds.
706
+ """
707
+ new_plan_matched_build_for: list[BuildInfo] = []
708
+ new_plan_matched_platform_name: list[BuildInfo] = []
644
709
 
645
- plan: list[BuildInfo] = []
646
710
  for build_info in build_plan:
647
- platform_matches = not platform or build_info.platform == platform
648
- build_on_matches = build_info.build_on == host_arch
649
- build_for_matches = not build_for or build_info.build_for == build_for
711
+ if platform and build_info.platform != platform:
712
+ continue
713
+
714
+ if host_arch and build_info.build_on != host_arch:
715
+ continue
716
+
717
+ if build_for and build_info.build_for != build_for:
718
+ continue
719
+
720
+ if build_for and build_info.platform == build_for:
721
+ # prioritize platform name if matched build_for
722
+ new_plan_matched_platform_name.append(build_info)
723
+ continue
650
724
 
651
- if platform_matches and build_on_matches and build_for_matches:
652
- plan.append(build_info)
725
+ new_plan_matched_build_for.append(build_info)
653
726
 
654
- return plan
727
+ if new_plan_matched_platform_name:
728
+ return new_plan_matched_platform_name
729
+ return new_plan_matched_build_for
@@ -126,7 +126,7 @@ class EmptyBuildPlanError(CraftError):
126
126
 
127
127
  def __init__(self) -> None:
128
128
  message = "No build matches the current platform."
129
- resolution = 'Check the "--platform" parameter.'
129
+ resolution = 'Check the "--platform" and "--build-for" parameters.'
130
130
 
131
131
  super().__init__(message=message, resolution=resolution)
132
132
 
@@ -136,7 +136,7 @@ class MultipleBuildsError(CraftError):
136
136
 
137
137
  def __init__(self) -> None:
138
138
  message = "Multiple builds match the current platform."
139
- resolution = 'Check the "--platform" parameter.'
139
+ resolution = 'Check the "--platform" and "--bulld-for" parameters.'
140
140
 
141
141
  super().__init__(message=message, resolution=resolution)
142
142
 
@@ -34,6 +34,7 @@ from collections.abc import Collection, Iterable
34
34
  from typing import TYPE_CHECKING
35
35
 
36
36
  import lazr.restfulclient.errors # type: ignore[import-untyped]
37
+ from craft_cli import emit
37
38
  from typing_extensions import Any, Self, TypedDict, override
38
39
 
39
40
  from .. import errors, util
@@ -43,6 +44,8 @@ from .base import LaunchpadObject, Pocket
43
44
  if TYPE_CHECKING:
44
45
  from .. import Launchpad
45
46
 
47
+ _ATTEMPT_COUNT = 4
48
+
46
49
 
47
50
  class RecipeType(enum.Enum):
48
51
  """The type of recipe."""
@@ -244,13 +247,31 @@ class SnapRecipe(_StoreRecipe):
244
247
  "An auto-build archive may only be provided if auto-build is enabled.",
245
248
  )
246
249
 
247
- snap_entry = lp.lp.snaps.new(
248
- name=name,
249
- owner=util.get_person_link(owner),
250
- store_upload=bool(store_name),
251
- auto_build=auto_build,
252
- **kwargs,
253
- )
250
+ snap_entry = None
251
+
252
+ for attempt in range(_ATTEMPT_COUNT):
253
+ emit.debug(
254
+ f"Trying to create snap recipe {name!r} (attempt {attempt + 1}/{_ATTEMPT_COUNT})"
255
+ )
256
+ try:
257
+ snap_entry = lp.lp.snaps.new(
258
+ name=name,
259
+ owner=util.get_person_link(owner),
260
+ store_upload=bool(store_name),
261
+ auto_build=auto_build,
262
+ **kwargs,
263
+ )
264
+ except lazr.restfulclient.errors.BadRequest as err:
265
+ emit.debug(str(err))
266
+ if attempt >= _ATTEMPT_COUNT - 1:
267
+ raise
268
+ time.sleep(3)
269
+ continue
270
+ else:
271
+ break
272
+
273
+ if not snap_entry:
274
+ raise ValueError("Failed to create snap recipe")
254
275
 
255
276
  return cls(lp, lp_obj=snap_entry)
256
277
 
@@ -370,13 +391,31 @@ class CharmRecipe(_StoreRecipe):
370
391
  )
371
392
  cls._fill_repo_info(kwargs, git_ref=git_ref)
372
393
 
373
- charm_entry = lp.lp.charm_recipes.new(
374
- name=name,
375
- owner=util.get_person_link(owner),
376
- project=f"/{project}",
377
- auto_build=auto_build,
378
- **kwargs,
379
- )
394
+ charm_entry = None
395
+
396
+ for attempt in range(_ATTEMPT_COUNT):
397
+ emit.debug(
398
+ f"Trying to create charm recipe {name!r} (attempt {attempt + 1}/{_ATTEMPT_COUNT})"
399
+ )
400
+ try:
401
+ charm_entry = lp.lp.charm_recipes.new(
402
+ name=name,
403
+ owner=util.get_person_link(owner),
404
+ project=f"/{project}",
405
+ auto_build=auto_build,
406
+ **kwargs,
407
+ )
408
+ except lazr.restfulclient.errors.BadRequest as err:
409
+ emit.debug(str(err))
410
+ if attempt >= _ATTEMPT_COUNT - 1:
411
+ raise
412
+ time.sleep(3)
413
+ continue
414
+ else:
415
+ break
416
+
417
+ if not charm_entry:
418
+ raise ValueError("Failed to create charm recipe")
380
419
 
381
420
  return cls(lp, charm_entry)
382
421
 
@@ -57,6 +57,9 @@ class PackageService(base.ProjectService):
57
57
  emit.debug(f"Update project variables: {update_vars}")
58
58
  self._project.__dict__.update(update_vars)
59
59
 
60
+ # Give subclasses a chance to update the project with their own logic
61
+ self._extra_project_updates()
62
+
60
63
  unset_fields = [
61
64
  field
62
65
  for field in self._app.mandatory_adoptable_fields
@@ -78,3 +81,6 @@ class PackageService(base.ProjectService):
78
81
  """
79
82
  path.mkdir(parents=True, exist_ok=True)
80
83
  self.metadata.to_yaml_file(path / "metadata.yaml")
84
+
85
+ def _extra_project_updates(self) -> None:
86
+ """Perform domain-specific updates to the project before packing."""
@@ -132,6 +132,7 @@ def dump_yaml(data: Any, stream: TextIO | None = None, **kwargs: Any) -> str | N
132
132
  str, _repr_str, Dumper=cast(type[yaml.Dumper], yaml.SafeDumper)
133
133
  )
134
134
  kwargs.setdefault("sort_keys", False)
135
+ kwargs.setdefault("allow_unicode", True)
135
136
  return cast( # This cast is needed for pyright but not mypy
136
137
  str | None, yaml.dump(data, stream, Dumper=yaml.SafeDumper, **kwargs)
137
138
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: craft-application
3
- Version: 2.0.0
3
+ Version: 2.2.0
4
4
  Summary: A framework for *craft applications.
5
5
  Classifier: Development Status :: 5 - Production/Stable
6
6
  Classifier: Environment :: Console
@@ -35,7 +35,7 @@ Requires-Dist: hypothesis>=6.0; extra == "dev"
35
35
  Requires-Dist: pyfakefs~=5.3; extra == "dev"
36
36
  Requires-Dist: pytest==8.1.1; extra == "dev"
37
37
  Requires-Dist: pytest-check==2.3.1; extra == "dev"
38
- Requires-Dist: pytest-cov==4.1.0; extra == "dev"
38
+ Requires-Dist: pytest-cov==5.0.0; extra == "dev"
39
39
  Requires-Dist: pytest-mock==3.12.0; extra == "dev"
40
40
  Requires-Dist: pytest-rerunfailures==13.0; extra == "dev"
41
41
  Requires-Dist: pytest-time>=0.3.1; extra == "dev"
@@ -99,6 +99,10 @@ tests/integration/data/valid_projects/basic/stderr
99
99
  tests/integration/data/valid_projects/basic/testcraft.yaml
100
100
  tests/integration/data/valid_projects/environment/stderr
101
101
  tests/integration/data/valid_projects/environment/testcraft.yaml
102
+ tests/integration/data/valid_projects/grammar/stderr
103
+ tests/integration/data/valid_projects/grammar/testcraft.yaml
104
+ tests/integration/data/valid_projects/grammar/src/on-amd64-to-amd64/hello.txt
105
+ tests/integration/data/valid_projects/grammar/src/on-amd64-to-arm64/hello.txt
102
106
  tests/integration/launchpad/__init__.py
103
107
  tests/integration/launchpad/conftest.py
104
108
  tests/integration/launchpad/test_anonymous_access.py
@@ -22,7 +22,7 @@ hypothesis>=6.0
22
22
  pyfakefs~=5.3
23
23
  pytest==8.1.1
24
24
  pytest-check==2.3.1
25
- pytest-cov==4.1.0
25
+ pytest-cov==5.0.0
26
26
  pytest-mock==3.12.0
27
27
  pytest-rerunfailures==13.0
28
28
  pytest-time>=0.3.1
@@ -50,7 +50,7 @@ dev = [
50
50
  "pyfakefs~=5.3",
51
51
  "pytest==8.1.1",
52
52
  "pytest-check==2.3.1",
53
- "pytest-cov==4.1.0",
53
+ "pytest-cov==5.0.0",
54
54
  "pytest-mock==3.12.0",
55
55
  "pytest-rerunfailures==13.0",
56
56
  "pytest-time>=0.3.1",
@@ -0,0 +1 @@
1
+ Packed package_1.0.tar.zst
@@ -0,0 +1,11 @@
1
+ name: empty
2
+ title: A most basic project
3
+ version: 1.0
4
+ base: ["ubuntu", "22.04"]
5
+
6
+ parts:
7
+ hello-world:
8
+ plugin: dump
9
+ source:
10
+ - on amd64 to amd64: src/on-amd64-to-amd64
11
+ - on amd64 to arm64: src/on-amd64-to-arm64
@@ -103,7 +103,7 @@ INVALID_PROJECTS_DIR = TEST_DATA_DIR / "invalid_projects"
103
103
  (["-h"], "", BASIC_USAGE, 0),
104
104
  (["--version"], VERSION_INFO, "", 0),
105
105
  (["-V"], VERSION_INFO, "", 0),
106
- (["-q", "--version"], VERSION_INFO, "", 0),
106
+ (["-q", "--version"], "", "", 0),
107
107
  (["--invalid-parameter"], "", BASIC_USAGE, 64),
108
108
  (["non-command"], "", INVALID_COMMAND, 64),
109
109
  ],
@@ -214,15 +214,15 @@ def test_non_lifecycle_command_does_not_require_project(monkeypatch, app):
214
214
 
215
215
 
216
216
  @pytest.mark.parametrize("cmd", ["clean", "pull", "build", "stage", "prime", "pack"])
217
- def test_run_always_load_project(monkeypatch, app, cmd):
217
+ def test_run_always_load_project(capsys, monkeypatch, app, cmd):
218
218
  """Run a lifecycle command without having a project shall fail."""
219
219
  monkeypatch.setenv("CRAFT_DEBUG", "1")
220
220
  monkeypatch.setattr("sys.argv", ["testcraft", cmd])
221
221
 
222
- with pytest.raises(FileNotFoundError) as raised:
223
- app.run()
222
+ assert app.run() == 66 # noqa: PLR2004
224
223
 
225
- assert str(raised.value).endswith("/testcraft.yaml'") is True
224
+ captured = capsys.readouterr()
225
+ assert "'testcraft.yaml' not found" in captured.err
226
226
 
227
227
 
228
228
  @pytest.mark.parametrize("help_param", ["-h", "--help"])
@@ -22,9 +22,11 @@ from unittest import mock
22
22
 
23
23
  import launchpadlib.launchpad
24
24
  import launchpadlib.uris
25
+ import lazr.restfulclient.errors
25
26
  import pytest
26
27
  from craft_application import launchpad
27
28
  from craft_application.launchpad import models
29
+ from lazr.restfulclient.resource import Entry
28
30
 
29
31
 
30
32
  def flatten_enum(e: type[enum.Enum]) -> list:
@@ -180,6 +182,36 @@ def test_get_recipe_snap(fake_launchpad, type_, owner):
180
182
  )
181
183
 
182
184
 
185
+ def test_recipe_snap_new_retry(emitter, mocker):
186
+ """Test that a SnapRecipe is retried when it fails to create."""
187
+ mocker.patch("time.sleep")
188
+ response = mocker.Mock(
189
+ status=400, reason="Bad Request", items=mocker.Mock(return_value=[])
190
+ )
191
+ mock_launchpad = mock.Mock()
192
+
193
+ mock_entry = mock.Mock(spec=Entry)
194
+ mock_entry.resource_type_link = "http://blah/#snap"
195
+ mock_entry.name = "test-snap"
196
+
197
+ mock_launchpad.lp.snaps.new = mock.Mock(
198
+ side_effect=[
199
+ lazr.restfulclient.errors.BadRequest(response=response, content=""),
200
+ mock_entry,
201
+ ]
202
+ )
203
+
204
+ recipe = launchpad.models.SnapRecipe.new(
205
+ mock_launchpad, "my_recipe", "test_user", git_ref="my_ref"
206
+ )
207
+
208
+ assert isinstance(recipe, models.SnapRecipe)
209
+ assert mock_launchpad.lp.snaps.new.call_count == 2 # noqa: PLR2004
210
+
211
+ emitter.assert_debug("Trying to create snap recipe 'my_recipe' (attempt 1/4)")
212
+ emitter.assert_debug("Trying to create snap recipe 'my_recipe' (attempt 2/4)")
213
+
214
+
183
215
  @pytest.mark.parametrize("type_", ["charm", "Charm", models.RecipeType.CHARM])
184
216
  def test_get_recipe_charm(fake_launchpad, type_):
185
217
  with mock.patch("craft_application.launchpad.models.CharmRecipe.get") as mock_get:
@@ -200,6 +232,36 @@ def test_get_recipe_charm_has_project(fake_launchpad):
200
232
  fake_launchpad.get_recipe(models.RecipeType.CHARM, "my_recipe")
201
233
 
202
234
 
235
+ def test_recipe_charm_new_retry(emitter, mocker):
236
+ """Test that a CharmRecipe is retried when it fails to create."""
237
+ mocker.patch("time.sleep")
238
+ response = mocker.Mock(
239
+ status=400, reason="Bad Request", items=mocker.Mock(return_value=[])
240
+ )
241
+
242
+ mock_entry = mock.Mock(spec=Entry)
243
+ mock_entry.resource_type_link = "http://whatever/#charm_recipe"
244
+ mock_entry.name = "test-charm"
245
+
246
+ mock_launchpad = mock.Mock()
247
+ mock_launchpad.lp.charm_recipes.new = mock.Mock(
248
+ side_effect=[
249
+ lazr.restfulclient.errors.BadRequest(response=response, content=""),
250
+ mock_entry,
251
+ ]
252
+ )
253
+
254
+ recipe = launchpad.models.CharmRecipe.new(
255
+ mock_launchpad, "my_recipe", "test_user", "test_project", git_ref="my_ref"
256
+ )
257
+
258
+ assert isinstance(recipe, models.CharmRecipe)
259
+ assert mock_launchpad.lp.charm_recipes.new.call_count == 2 # noqa: PLR2004
260
+
261
+ emitter.assert_debug("Trying to create charm recipe 'my_recipe' (attempt 1/4)")
262
+ emitter.assert_debug("Trying to create charm recipe 'my_recipe' (attempt 2/4)")
263
+
264
+
203
265
  @pytest.mark.parametrize(
204
266
  ("name", "owner", "project", "path"),
205
267
  [