antsibull-nox 0.1.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

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 (41) hide show
  1. antsibull_nox/__init__.py +17 -14
  2. antsibull_nox/_pydantic.py +98 -0
  3. antsibull_nox/ansible.py +260 -0
  4. antsibull_nox/cli.py +132 -0
  5. antsibull_nox/collection/__init__.py +56 -0
  6. antsibull_nox/collection/data.py +106 -0
  7. antsibull_nox/collection/extract.py +23 -0
  8. antsibull_nox/collection/install.py +523 -0
  9. antsibull_nox/collection/search.py +460 -0
  10. antsibull_nox/config.py +378 -0
  11. antsibull_nox/data/action-groups.py +3 -3
  12. antsibull_nox/data/antsibull-nox-lint-config.py +29 -0
  13. antsibull_nox/data/antsibull_nox_data_util.py +91 -0
  14. antsibull_nox/data/license-check.py +6 -2
  15. antsibull_nox/data/no-unwanted-files.py +5 -1
  16. antsibull_nox/data/plugin-yamllint.py +247 -0
  17. antsibull_nox/data_util.py +0 -77
  18. antsibull_nox/init.py +83 -0
  19. antsibull_nox/interpret_config.py +244 -0
  20. antsibull_nox/lint_config.py +113 -0
  21. antsibull_nox/paths.py +19 -0
  22. antsibull_nox/python.py +81 -0
  23. antsibull_nox/sessions/__init__.py +70 -0
  24. antsibull_nox/sessions/ansible_lint.py +58 -0
  25. antsibull_nox/sessions/ansible_test.py +568 -0
  26. antsibull_nox/sessions/build_import_check.py +147 -0
  27. antsibull_nox/sessions/collections.py +137 -0
  28. antsibull_nox/sessions/docs_check.py +78 -0
  29. antsibull_nox/sessions/extra_checks.py +127 -0
  30. antsibull_nox/sessions/license_check.py +73 -0
  31. antsibull_nox/sessions/lint.py +627 -0
  32. antsibull_nox/sessions/utils.py +206 -0
  33. antsibull_nox/utils.py +85 -0
  34. {antsibull_nox-0.1.0.dist-info → antsibull_nox-0.3.0.dist-info}/METADATA +4 -2
  35. antsibull_nox-0.3.0.dist-info/RECORD +40 -0
  36. antsibull_nox-0.3.0.dist-info/entry_points.txt +2 -0
  37. antsibull_nox/collection.py +0 -545
  38. antsibull_nox/sessions.py +0 -840
  39. antsibull_nox-0.1.0.dist-info/RECORD +0 -14
  40. {antsibull_nox-0.1.0.dist-info → antsibull_nox-0.3.0.dist-info}/WHEEL +0 -0
  41. {antsibull_nox-0.1.0.dist-info → antsibull_nox-0.3.0.dist-info}/licenses/LICENSES/GPL-3.0-or-later.txt +0 -0
@@ -0,0 +1,460 @@
1
+ # Author: Felix Fontein <felix@fontein.de>
2
+ # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or
3
+ # https://www.gnu.org/licenses/gpl-3.0.txt)
4
+ # SPDX-License-Identifier: GPL-3.0-or-later
5
+ # SPDX-FileCopyrightText: 2025, Ansible Project
6
+
7
+ """
8
+ Handle Ansible collections.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import threading
16
+ import typing as t
17
+ from collections.abc import Collection, Iterator, Sequence
18
+ from contextlib import contextmanager
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from antsibull_fileutils.yaml import load_yaml_file
23
+
24
+ from .data import CollectionData
25
+
26
+ # Function that runs a command (and fails on non-zero return code)
27
+ # and returns a tuple (stdout, stderr)
28
+ Runner = t.Callable[[list[str]], tuple[bytes, bytes]]
29
+
30
+
31
+ GALAXY_YML = "galaxy.yml"
32
+ MANIFEST_JSON = "MANIFEST.json"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class _GlobalCache:
37
+ root: Path
38
+ download_cache: Path
39
+ extracted_cache: Path
40
+
41
+ @classmethod
42
+ def create(cls, *, root: Path) -> _GlobalCache:
43
+ """
44
+ Create a global cache object.
45
+ """
46
+ return cls(
47
+ root=root,
48
+ download_cache=root / "downloaded",
49
+ extracted_cache=root / "extracted",
50
+ )
51
+
52
+
53
+ def _load_galaxy_yml(galaxy_yml: Path) -> dict[str, t.Any]:
54
+ try:
55
+ data = load_yaml_file(galaxy_yml)
56
+ except Exception as exc:
57
+ raise ValueError(f"Cannot parse {galaxy_yml}: {exc}") from exc
58
+ if not isinstance(data, dict):
59
+ raise ValueError(f"{galaxy_yml} is not a dictionary")
60
+ return data
61
+
62
+
63
+ def _load_manifest_json_collection_info(manifest_json: Path) -> dict[str, t.Any]:
64
+ try:
65
+ with open(manifest_json, "br") as f:
66
+ data = json.load(f)
67
+ except Exception as exc:
68
+ raise ValueError(f"Cannot parse {manifest_json}: {exc}") from exc
69
+ ci = data.get("collection_info")
70
+ if not isinstance(ci, dict):
71
+ raise ValueError(f"{manifest_json} does not contain collection_info")
72
+ return ci
73
+
74
+
75
+ def load_collection_data_from_disk(
76
+ path: Path,
77
+ *,
78
+ namespace: str | None = None,
79
+ name: str | None = None,
80
+ root: Path | None = None,
81
+ current: bool = False,
82
+ accept_manifest: bool = True,
83
+ ) -> CollectionData:
84
+ """
85
+ Load collection data from disk.
86
+ """
87
+ galaxy_yml = path / GALAXY_YML
88
+ manifest_json = path / MANIFEST_JSON
89
+ found: Path
90
+ if galaxy_yml.is_file():
91
+ found = galaxy_yml
92
+ data = _load_galaxy_yml(galaxy_yml)
93
+ elif not accept_manifest:
94
+ raise ValueError(f"Cannot find {GALAXY_YML} in {path}")
95
+ elif manifest_json.is_file():
96
+ found = manifest_json
97
+ data = _load_manifest_json_collection_info(manifest_json)
98
+ else:
99
+ raise ValueError(f"Cannot find {GALAXY_YML} or {MANIFEST_JSON} in {path}")
100
+
101
+ ns = data.get("namespace")
102
+ if not isinstance(ns, str):
103
+ raise ValueError(f"{found} does not contain a namespace")
104
+ n = data.get("name")
105
+ if not isinstance(n, str):
106
+ raise ValueError(f"{found} does not contain a name")
107
+ v = data.get("version")
108
+ if not isinstance(v, str):
109
+ v = None
110
+ d = data.get("dependencies") or {}
111
+ if not isinstance(d, dict):
112
+ raise ValueError(f"{found}'s dependencies is not a mapping")
113
+
114
+ if namespace is not None and ns != namespace:
115
+ raise ValueError(
116
+ f"{found} contains namespace {ns!r}, but was hoping for {namespace!r}"
117
+ )
118
+ if name is not None and n != name:
119
+ raise ValueError(f"{found} contains name {n!r}, but was hoping for {name!r}")
120
+ return CollectionData(
121
+ collections_root_path=root,
122
+ path=path,
123
+ namespace=ns,
124
+ name=n,
125
+ full_name=f"{ns}.{n}",
126
+ version=v,
127
+ dependencies=d,
128
+ current=current,
129
+ )
130
+
131
+
132
+ def _list_adjacent_collections_ansible_collections_tree(
133
+ root: Path,
134
+ *,
135
+ directories_to_ignore: Collection[Path] | None = None,
136
+ ) -> Iterator[CollectionData]:
137
+ directories_to_ignore = directories_to_ignore or ()
138
+ for namespace in root.iterdir(): # pylint: disable=too-many-nested-blocks
139
+ try:
140
+ if namespace.is_dir() or namespace.is_symlink():
141
+ for name in namespace.iterdir():
142
+ if name in directories_to_ignore:
143
+ continue
144
+ try:
145
+ if name.is_dir() or name.is_symlink():
146
+ yield load_collection_data_from_disk(
147
+ name,
148
+ namespace=namespace.name,
149
+ name=name.name,
150
+ root=root,
151
+ )
152
+ except Exception: # pylint: disable=broad-exception-caught
153
+ # If name doesn't happen to be a (symlink to a) directory,
154
+ # is not readable, ...
155
+ pass
156
+ except Exception: # pylint: disable=broad-exception-caught
157
+ # If namespace doesn't happen to be a (symlink to a) directory, is not readable, ...
158
+ pass
159
+
160
+
161
+ def _list_adjacent_collections_outside_tree(
162
+ directory: Path,
163
+ *,
164
+ directories_to_ignore: Collection[Path] | None = None,
165
+ ) -> Iterator[CollectionData]:
166
+ directories_to_ignore = directories_to_ignore or ()
167
+ for collection_dir in directory.iterdir():
168
+ if collection_dir in directories_to_ignore:
169
+ continue
170
+ if not collection_dir.is_dir() and not collection_dir.is_symlink():
171
+ continue
172
+ parts = collection_dir.name.split(".")
173
+ if len(parts) != 2:
174
+ continue
175
+ namespace, name = parts
176
+ if not namespace.isidentifier() or not name.isidentifier():
177
+ continue
178
+ try:
179
+ yield load_collection_data_from_disk(
180
+ collection_dir,
181
+ namespace=namespace,
182
+ name=name,
183
+ )
184
+ except Exception: # pylint: disable=broad-exception-caught
185
+ # If collection_dir doesn't happen to be a (symlink to a) directory, ...
186
+ pass
187
+
188
+
189
+ def _fs_list_local_collections() -> Iterator[CollectionData]:
190
+ root: Path | None = None
191
+
192
+ # Determine potential root
193
+ cwd = Path.cwd()
194
+ parents: Sequence[Path] = cwd.parents
195
+ if len(parents) > 2 and parents[1].name == "ansible_collections":
196
+ root = parents[1]
197
+
198
+ # Current collection
199
+ try:
200
+ current = load_collection_data_from_disk(cwd, root=root, current=True)
201
+ if root and current.namespace == parents[0].name and current.name == cwd.name:
202
+ yield current
203
+ else:
204
+ root = None
205
+ current = load_collection_data_from_disk(cwd, current=True)
206
+ yield current
207
+ except Exception as exc:
208
+ raise ValueError(
209
+ f"Cannot load current collection's info from {cwd}: {exc}"
210
+ ) from exc
211
+
212
+ # Search tree
213
+ if root:
214
+ yield from _list_adjacent_collections_ansible_collections_tree(
215
+ root, directories_to_ignore=(cwd,)
216
+ )
217
+ elif len(parents) > 0:
218
+ yield from _list_adjacent_collections_outside_tree(
219
+ parents[0], directories_to_ignore=(cwd,)
220
+ )
221
+ else:
222
+ # Only happens if cwd == "/"
223
+ pass # pragma: no cover
224
+
225
+
226
+ def _fs_list_global_cache(global_cache_dir: Path) -> Iterator[CollectionData]:
227
+ if not global_cache_dir.is_dir():
228
+ return
229
+
230
+ yield from _list_adjacent_collections_outside_tree(global_cache_dir)
231
+
232
+
233
+ def _galaxy_list_collections(runner: Runner) -> Iterator[CollectionData]:
234
+ try:
235
+ stdout, _ = runner(["ansible-galaxy", "collection", "list", "--format", "json"])
236
+ data = json.loads(stdout)
237
+ for collections_root_path, collections in data.items():
238
+ root = Path(collections_root_path)
239
+ for collection in collections:
240
+ namespace, name = collection.split(".", 1)
241
+ try:
242
+ yield load_collection_data_from_disk(
243
+ root / namespace / name,
244
+ namespace=namespace,
245
+ name=name,
246
+ root=root,
247
+ current=False,
248
+ )
249
+ except: # noqa: E722, pylint: disable=bare-except
250
+ # Looks like Ansible passed crap on to us...
251
+ pass
252
+ except Exception as exc:
253
+ raise ValueError(f"Error while loading collection list: {exc}") from exc
254
+
255
+
256
+ @dataclass
257
+ class CollectionList:
258
+ """
259
+ A list of Ansible collections.
260
+ """
261
+
262
+ collections: list[CollectionData]
263
+ collection_map: dict[str, CollectionData]
264
+ current: CollectionData
265
+
266
+ @classmethod
267
+ def create(cls, collections_map: dict[str, CollectionData]):
268
+ """
269
+ Given a dictionary mapping collection names to collection data, creates a CollectionList.
270
+
271
+ One of the collections must have the ``current`` flag set.
272
+ """
273
+ collections = sorted(collections_map.values(), key=lambda cli: cli.full_name)
274
+ current = next(c for c in collections if c.current)
275
+ return cls(
276
+ collections=collections,
277
+ collection_map=collections_map,
278
+ current=current,
279
+ )
280
+
281
+ @classmethod
282
+ def collect(cls, *, runner: Runner, global_cache: _GlobalCache) -> CollectionList:
283
+ """
284
+ Search for a list of collections. The result is not cached.
285
+ """
286
+ found_collections = {}
287
+ for collection_data in _fs_list_local_collections():
288
+ found_collections[collection_data.full_name] = collection_data
289
+ if os.environ.get("ANTSIBULL_NOX_IGNORE_INSTALLED_COLLECTIONS") != "true":
290
+ for collection_data in _galaxy_list_collections(runner):
291
+ # Similar to Ansible, we use the first match
292
+ if collection_data.full_name not in found_collections:
293
+ found_collections[collection_data.full_name] = collection_data
294
+ for collection_data in _fs_list_global_cache(global_cache.extracted_cache):
295
+ # Similar to Ansible, we use the first match
296
+ if collection_data.full_name not in found_collections:
297
+ found_collections[collection_data.full_name] = collection_data
298
+ return cls.create(found_collections)
299
+
300
+ def find(self, name: str) -> CollectionData | None:
301
+ """
302
+ Find a collection for a given name.
303
+ """
304
+ return self.collection_map.get(name)
305
+
306
+ def clone(self) -> CollectionList:
307
+ """
308
+ Create a clone of this list.
309
+ """
310
+ return CollectionList(
311
+ collections=list(self.collections),
312
+ collection_map=dict(self.collection_map),
313
+ current=self.current,
314
+ )
315
+
316
+ def _add(self, collection: CollectionData, *, force: bool = True) -> bool:
317
+ if not force and collection.full_name in self.collection_map:
318
+ return False
319
+ self.collections.append(collection)
320
+ self.collection_map[collection.full_name] = collection
321
+ return True
322
+
323
+
324
+ class _CollectionListUpdater:
325
+ def __init__(
326
+ self, *, owner: "_CollectionListSingleton", collection_list: CollectionList
327
+ ) -> None:
328
+ self._owner = owner
329
+ self._collection_list = collection_list
330
+
331
+ def find(self, name: str) -> CollectionData | None:
332
+ """
333
+ Find a collection for a given name.
334
+ """
335
+ return self._collection_list.find(name)
336
+
337
+ def add_collection(
338
+ self, *, directory: Path, namespace: str, name: str
339
+ ) -> CollectionData:
340
+ """
341
+ Add a new collection to the cache.
342
+ """
343
+ # pylint: disable-next=protected-access
344
+ return self._owner._add_collection(
345
+ directory=directory, namespace=namespace, name=name
346
+ )
347
+
348
+ def get_global_cache(self) -> _GlobalCache:
349
+ """
350
+ Get the global cache object.
351
+ """
352
+ return self._owner._get_global_cache() # pylint: disable=protected-access
353
+
354
+
355
+ class _CollectionListSingleton:
356
+ _lock = threading.Lock()
357
+
358
+ _global_cache_dir: Path | None = None
359
+ _collection_list: CollectionList | None = None
360
+
361
+ def setup(self, *, global_cache_dir: Path) -> None:
362
+ """
363
+ Setup data.
364
+ """
365
+ with self._lock:
366
+ if (
367
+ self._global_cache_dir is not None
368
+ and self._global_cache_dir != global_cache_dir
369
+ ):
370
+ raise ValueError(
371
+ "Setup mismatch: global cache dir cannot be both"
372
+ f" {self._global_cache_dir} and {global_cache_dir}"
373
+ )
374
+ self._global_cache_dir = global_cache_dir
375
+
376
+ def clear(self) -> None:
377
+ """
378
+ Clear collection cache.
379
+ """
380
+ with self._lock:
381
+ self._collection_list = None
382
+
383
+ def get_cached(self) -> CollectionList | None:
384
+ """
385
+ Return cached list of collections, if present.
386
+ Do not modify the result!
387
+ """
388
+ return self._collection_list
389
+
390
+ def get(self, *, runner: Runner) -> CollectionList:
391
+ """
392
+ Search for a list of collections. The result is cached.
393
+ """
394
+ with self._lock:
395
+ if self._global_cache_dir is None:
396
+ raise ValueError("Internal error: global cache dir not setup")
397
+ result = self._collection_list
398
+ if result is None:
399
+ result = CollectionList.collect(
400
+ runner=runner,
401
+ global_cache=_GlobalCache.create(root=self._global_cache_dir),
402
+ )
403
+ self._collection_list = result
404
+ return result.clone()
405
+
406
+ def _get_global_cache(self) -> _GlobalCache:
407
+ """
408
+ Returns the global cache dir.
409
+ """
410
+ if self._global_cache_dir is None:
411
+ raise ValueError("Internal error: global cache dir not setup")
412
+ return _GlobalCache.create(root=self._global_cache_dir)
413
+
414
+ def _add_collection(
415
+ self, *, directory: Path, namespace: str, name: str
416
+ ) -> CollectionData:
417
+ """
418
+ Add collection in directory if the collection list has been cached.
419
+ """
420
+ if not self._collection_list:
421
+ raise ValueError("Internal error: collections not listed")
422
+ data = load_collection_data_from_disk(directory, namespace=namespace, name=name)
423
+ self._collection_list._add(data) # pylint: disable=protected-access
424
+ return data
425
+
426
+ @contextmanager
427
+ def _update_collection_list(self) -> t.Iterator[_CollectionListUpdater]:
428
+ with self._lock:
429
+ if not self._collection_list or self._global_cache_dir is None:
430
+ raise ValueError(
431
+ "Internal error: collections not listed or global cache not setup"
432
+ )
433
+ yield _CollectionListUpdater(
434
+ owner=self, collection_list=self._collection_list
435
+ )
436
+
437
+
438
+ _COLLECTION_LIST = _CollectionListSingleton()
439
+
440
+
441
+ @contextmanager
442
+ def _update_collection_list() -> t.Iterator[_CollectionListUpdater]:
443
+ # pylint: disable-next=protected-access
444
+ with _COLLECTION_LIST._update_collection_list() as result:
445
+ yield result
446
+
447
+
448
+ def get_collection_list(*, runner: Runner, global_cache_dir: Path) -> CollectionList:
449
+ """
450
+ Search for a list of collections. The result is cached.
451
+ """
452
+ _COLLECTION_LIST.setup(global_cache_dir=global_cache_dir)
453
+ return _COLLECTION_LIST.get(runner=runner)
454
+
455
+
456
+ __all__ = [
457
+ "CollectionList",
458
+ "get_collection_list",
459
+ "load_collection_data_from_disk",
460
+ ]