micro-reader 0.0.2__tar.gz → 0.0.3__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 (41) hide show
  1. {micro_reader-0.0.2 → micro_reader-0.0.3}/PKG-INFO +49 -1
  2. {micro_reader-0.0.2 → micro_reader-0.0.3}/README.md +48 -0
  3. {micro_reader-0.0.2 → micro_reader-0.0.3}/pyproject.toml +1 -1
  4. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/__init__.py +8 -5
  5. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/_base.py +104 -12
  6. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/_errors.py +11 -0
  7. micro_reader-0.0.3/src/micro_reader/_fileset.py +358 -0
  8. micro_reader-0.0.3/src/micro_reader/_outline.py +71 -0
  9. micro_reader-0.0.3/src/micro_reader/_plate.py +150 -0
  10. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/_registry.py +23 -9
  11. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/backends/_czi_metadata.py +45 -0
  12. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/backends/_tiff_metadata.py +78 -26
  13. micro_reader-0.0.3/src/micro_reader/backends/companion.py +624 -0
  14. micro_reader-0.0.3/src/micro_reader/backends/cv7000.py +265 -0
  15. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/backends/czi.py +55 -7
  16. micro_reader-0.0.3/src/micro_reader/backends/harmony.py +206 -0
  17. micro_reader-0.0.3/src/micro_reader/backends/incell.py +173 -0
  18. micro_reader-0.0.3/src/micro_reader/backends/metaxpress.py +208 -0
  19. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/backends/tiff.py +74 -6
  20. {micro_reader-0.0.2 → micro_reader-0.0.3}/.gitignore +0 -0
  21. {micro_reader-0.0.2 → micro_reader-0.0.3}/LICENSE +0 -0
  22. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/_axes.py +0 -0
  23. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/_codecs.py +0 -0
  24. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/_index.py +0 -0
  25. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/_io.py +0 -0
  26. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/_jpeg_restarts.py +0 -0
  27. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/_memory.py +0 -0
  28. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/_metadata.py +0 -0
  29. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/_pool.py +0 -0
  30. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/_stream.py +0 -0
  31. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/backends/__init__.py +0 -0
  32. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/backends/_lif_metadata.py +0 -0
  33. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/backends/_nd2_legacy.py +0 -0
  34. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/backends/_nd2_metadata.py +0 -0
  35. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/backends/_nd2_structure.py +0 -0
  36. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/backends/ims.py +0 -0
  37. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/backends/lif.py +0 -0
  38. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/backends/mrc.py +0 -0
  39. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/backends/nd2.py +0 -0
  40. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/backends/oir.py +0 -0
  41. {micro_reader-0.0.2 → micro_reader-0.0.3}/src/micro_reader/backends/raster.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: micro-reader
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: Low-level, memory-bounded pixel reading for microscopy formats: read exactly the rectangle you ask for.
5
5
  Project-URL: Homepage, https://github.com/bugraoezdemir/micro-reader
6
6
  Project-URL: Source, https://github.com/bugraoezdemir/micro-reader
@@ -192,6 +192,10 @@ This returns the images matching the requested indices. For example, if a file c
192
192
  `micro-reader` uses these index names when a format provides the corresponding dimensions:
193
193
 
194
194
  ```text
195
+ plate
196
+ acquisition
197
+ well
198
+ field
195
199
  scene
196
200
  tile
197
201
  rotation
@@ -203,6 +207,7 @@ emission
203
207
  excitation
204
208
  lifetime
205
209
  sequence
210
+ part
206
211
  resolution
207
212
  ```
208
213
 
@@ -425,6 +430,37 @@ The tiles are composed according to the format's stitching information.
425
430
 
426
431
  Currently, stitching is supported for **CZI only**.
427
432
 
433
+ ## Plates
434
+
435
+ High-content screening plates are opened through their index file, for example `Index.idx.xml` for Harmony. Each field is one image, indexed by `plate`, `acquisition`, `well` and `field`.
436
+
437
+ The plate layout is available through:
438
+
439
+ ```python
440
+ plate = f.plates[0]
441
+ plate.wells[0].name
442
+ # "B2"
443
+ ```
444
+
445
+ Images can be reached through the wells, or selected by well:
446
+
447
+ ```python
448
+ image = plate.wells[0].fields[0].image
449
+ f.select(well=plate.well_index("B2"))
450
+ ```
451
+
452
+ `f.outline()` shows the plates, wells and fields of a file.
453
+
454
+ Zeiss CZI plates are read as plates with `plates=True`, and as scenes otherwise:
455
+
456
+ ```python
457
+ f = micro_reader.open("plate.czi", plates=True)
458
+ ```
459
+
460
+ Missing plane files are read as zeros, with a `MissingImagesWarning`. A field without any of its files is left out.
461
+
462
+ When the channels of a field have different z sampling, `image.parts` gives one image per group of channels that share it.
463
+
428
464
  ## Metadata
429
465
 
430
466
  Pixel and channel metadata are available through:
@@ -489,12 +525,14 @@ The position is omitted when it cannot be determined reliably.
489
525
  | Format | Notes |
490
526
  | -------------------- | ----------------------------------------------------- |
491
527
  | **TIFF / BigTIFF** | TIFF, OME-TIFF, ImageJ, LSM, STK and TIFF pyramids |
528
+ | **OME companion** | `.companion.ome` sets of OME-TIFF files |
492
529
  | **CZI** | Zeiss CZI, including mosaic tiles and stitched scenes |
493
530
  | **ND2** | Nikon ND2, including legacy JPEG 2000 ND2 |
494
531
  | **Leica** | LIF, LOF, LIFEXT, XLIF, XLEF, XLCF |
495
532
  | **OIR** | Olympus / Evident FluoView |
496
533
  | **Imaris** | `.ims`, with the `[ims]` extra |
497
534
  | **MRC** | Common MRC modes and extended headers |
535
+ | **Plates (HCS)** | Harmony, MetaXpress, IN Cell and CellVoyager plates |
498
536
  | **JPEG / PNG / BMP** | Common raster image formats |
499
537
 
500
538
  All formats use the same `File`, `Image`, axes, indexing, metadata, and reading interface wherever possible.
@@ -534,6 +572,16 @@ Additional codecs such as JPEG XR and LERC are available with:
534
572
  pip install "micro-reader[codecs]"
535
573
  ```
536
574
 
575
+ ### OME companion files
576
+
577
+ An OME companion file (`.companion.ome`) describes images stored in separate TIFF files. Opening the companion reads all those images and groups them together as one `t c z y x` image:
578
+
579
+ ```python
580
+ f = micro_reader.open("data.companion.ome")
581
+ ```
582
+
583
+ Opening one of the TIFF files on its own reads only that file, with its pixel sizes, channels and position taken from the companion. If the companion is missing, the file is read without pixel sizes, with a `CompanionWarning`.
584
+
537
585
  ### CZI
538
586
 
539
587
  CZI files support:
@@ -140,6 +140,10 @@ This returns the images matching the requested indices. For example, if a file c
140
140
  `micro-reader` uses these index names when a format provides the corresponding dimensions:
141
141
 
142
142
  ```text
143
+ plate
144
+ acquisition
145
+ well
146
+ field
143
147
  scene
144
148
  tile
145
149
  rotation
@@ -151,6 +155,7 @@ emission
151
155
  excitation
152
156
  lifetime
153
157
  sequence
158
+ part
154
159
  resolution
155
160
  ```
156
161
 
@@ -373,6 +378,37 @@ The tiles are composed according to the format's stitching information.
373
378
 
374
379
  Currently, stitching is supported for **CZI only**.
375
380
 
381
+ ## Plates
382
+
383
+ High-content screening plates are opened through their index file, for example `Index.idx.xml` for Harmony. Each field is one image, indexed by `plate`, `acquisition`, `well` and `field`.
384
+
385
+ The plate layout is available through:
386
+
387
+ ```python
388
+ plate = f.plates[0]
389
+ plate.wells[0].name
390
+ # "B2"
391
+ ```
392
+
393
+ Images can be reached through the wells, or selected by well:
394
+
395
+ ```python
396
+ image = plate.wells[0].fields[0].image
397
+ f.select(well=plate.well_index("B2"))
398
+ ```
399
+
400
+ `f.outline()` shows the plates, wells and fields of a file.
401
+
402
+ Zeiss CZI plates are read as plates with `plates=True`, and as scenes otherwise:
403
+
404
+ ```python
405
+ f = micro_reader.open("plate.czi", plates=True)
406
+ ```
407
+
408
+ Missing plane files are read as zeros, with a `MissingImagesWarning`. A field without any of its files is left out.
409
+
410
+ When the channels of a field have different z sampling, `image.parts` gives one image per group of channels that share it.
411
+
376
412
  ## Metadata
377
413
 
378
414
  Pixel and channel metadata are available through:
@@ -437,12 +473,14 @@ The position is omitted when it cannot be determined reliably.
437
473
  | Format | Notes |
438
474
  | -------------------- | ----------------------------------------------------- |
439
475
  | **TIFF / BigTIFF** | TIFF, OME-TIFF, ImageJ, LSM, STK and TIFF pyramids |
476
+ | **OME companion** | `.companion.ome` sets of OME-TIFF files |
440
477
  | **CZI** | Zeiss CZI, including mosaic tiles and stitched scenes |
441
478
  | **ND2** | Nikon ND2, including legacy JPEG 2000 ND2 |
442
479
  | **Leica** | LIF, LOF, LIFEXT, XLIF, XLEF, XLCF |
443
480
  | **OIR** | Olympus / Evident FluoView |
444
481
  | **Imaris** | `.ims`, with the `[ims]` extra |
445
482
  | **MRC** | Common MRC modes and extended headers |
483
+ | **Plates (HCS)** | Harmony, MetaXpress, IN Cell and CellVoyager plates |
446
484
  | **JPEG / PNG / BMP** | Common raster image formats |
447
485
 
448
486
  All formats use the same `File`, `Image`, axes, indexing, metadata, and reading interface wherever possible.
@@ -482,6 +520,16 @@ Additional codecs such as JPEG XR and LERC are available with:
482
520
  pip install "micro-reader[codecs]"
483
521
  ```
484
522
 
523
+ ### OME companion files
524
+
525
+ An OME companion file (`.companion.ome`) describes images stored in separate TIFF files. Opening the companion reads all those images and groups them together as one `t c z y x` image:
526
+
527
+ ```python
528
+ f = micro_reader.open("data.companion.ome")
529
+ ```
530
+
531
+ Opening one of the TIFF files on its own reads only that file, with its pixel sizes, channels and position taken from the companion. If the companion is missing, the file is read without pixel sizes, with a `CompanionWarning`.
532
+
485
533
  ### CZI
486
534
 
487
535
  CZI files support:
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "micro-reader"
9
- version = "0.0.2"
9
+ version = "0.0.3"
10
10
  description = "Low-level, memory-bounded pixel reading for microscopy formats: read exactly the rectangle you ask for."
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.11"
@@ -4,22 +4,25 @@
4
4
  >>> with micro_reader.open("image.mrc") as f:
5
5
  ... block = f.images[0].read(z=0, y=slice(0, 512))
6
6
  """
7
- from ._base import INDEX_NAMES, SAMPLED_INDICES, File, Image, ReadStats
7
+ from ._base import INDEX_NAMES, PLATE_INDICES, SAMPLED_INDICES, File, Image, ReadStats
8
8
  from ._metadata import Channel, Metadata, Quantity
9
- from ._errors import CorruptFileError, MicroReaderError, UnsupportedFormatError
9
+ from ._plate import Acquisition, Field, Plate, Well
10
+ from ._errors import (CompanionWarning, CorruptFileError, MicroReaderError,
11
+ MissingImagesWarning, UnsupportedFormatError)
10
12
  from ._memory import MemoryLimitError, memory_limit, set_memory_limit
11
13
  from ._io import cache_limit, cached_bytes, set_cache_limit
12
14
  from ._pool import decode_threads, set_decode_threads
13
15
  from ._registry import BACKENDS, open # noqa: A004 - the public entry point
14
16
  from .backends.czi import DamagedFileWarning
15
17
 
16
- __version__ = "0.0.1"
18
+ __version__ = "0.0.3"
17
19
 
18
20
  __all__ = [
19
- "open", "File", "Image", "INDEX_NAMES", "SAMPLED_INDICES", "ReadStats", "BACKENDS",
20
- "Metadata", "Channel", "Quantity",
21
+ "open", "File", "Image", "INDEX_NAMES", "PLATE_INDICES", "SAMPLED_INDICES", "ReadStats",
22
+ "BACKENDS", "Metadata", "Channel", "Quantity", "Plate", "Well", "Field", "Acquisition",
21
23
  "decode_threads", "set_decode_threads", "memory_limit", "set_memory_limit",
22
24
  "cache_limit", "set_cache_limit", "cached_bytes",
23
25
  "MicroReaderError", "UnsupportedFormatError", "CorruptFileError", "MemoryLimitError", "DamagedFileWarning",
26
+ "MissingImagesWarning", "CompanionWarning",
24
27
  "__version__",
25
28
  ]
@@ -65,9 +65,12 @@ class Image:
65
65
  self.name = name if name is not None else f"image{position}"
66
66
  #: which image of the file: {"scene": .., "tile": .., "rotation": ..,
67
67
  #: "view": .., "resolution": ..} -- only the indices the file has
68
- #: ("resolution" only when opened with resolutions="all")
68
+ #: (plates: "well", "field", ... instead of "scene"; see INDEX_NAMES)
69
69
  self.index = dict(index) if index is not None else {"scene": self._position}
70
70
  self._resolutions: Optional[list] = None
71
+ #: a ragged image's rectangular parts / a part's whole (see ``parts``)
72
+ self._parts: Optional[list] = None
73
+ self._whole: Optional["Image"] = None
71
74
  self._metadata = None # backends set a _metadata.Metadata
72
75
  #: None: any rectangle is read exactly. Otherwise the smallest block
73
76
  #: the backend reads at once (e.g. a compressed tile or strip).
@@ -108,6 +111,55 @@ class Image:
108
111
  (``resolutions`` holds more than the image itself)."""
109
112
  return len(self.resolutions) > 1
110
113
 
114
+ # An image is either a pyramid (the logical image: level 0, also when the
115
+ # file stores no lower levels) or a lower resolution level of one.
116
+
117
+ @property
118
+ def is_pyramid(self) -> bool:
119
+ """Whether this image is a pyramid, the image itself at full
120
+ resolution, rather than a lower resolution level of one. True for
121
+ every image without lower levels too."""
122
+ return self.pyramid is self
123
+
124
+ @property
125
+ def level(self) -> int:
126
+ """This image's resolution level in its pyramid: 0 for the pyramid
127
+ itself (full resolution), 1, 2, ... for lower levels."""
128
+ for n, member in enumerate(self.resolutions):
129
+ if member is self:
130
+ return n
131
+ raise RuntimeError(f"{self.name!r} is not in its own pyramid")
132
+
133
+ @property
134
+ def pyramid(self) -> "Image":
135
+ """The pyramid this image belongs to: itself at level 0, the
136
+ full-resolution image for a lower level."""
137
+ return self.resolutions[0]
138
+
139
+ # A ragged image: its channels do not share one sampling (e.g. one channel
140
+ # a z-stack, the others single planes; docs/AXES.md). It is a whole made of
141
+ # rectangular parts, each an ordinary lazy Image with its own z / t and
142
+ # metadata. The whole reads as one array, padded with zeros, when the
143
+ # parts lie on a common grid; otherwise reading it raises.
144
+
145
+ @property
146
+ def parts(self) -> list:
147
+ """This image's rectangular parts, channel groups that share one
148
+ sampling: several for a ragged image, just this image otherwise."""
149
+ return list(self._parts) if self._parts else [self]
150
+
151
+ @property
152
+ def is_ragged(self) -> bool:
153
+ """Whether this image's channels do not share one sampling
154
+ (``parts`` holds more than one part)."""
155
+ return len(self.parts) > 1
156
+
157
+ @property
158
+ def whole(self) -> "Image":
159
+ """The image this part belongs to: itself for a whole (or an image
160
+ that is not ragged)."""
161
+ return self._whole if self._whole is not None else self
162
+
111
163
  @property
112
164
  def resolutions(self) -> list:
113
165
  """This image's pyramid, full resolution first: one image per
@@ -177,12 +229,18 @@ class Image:
177
229
 
178
230
  #: the indices that say which image of a file an image is, outermost first.
179
231
  #: Arrays have only t c z y x (+ s); every other dimension is an index:
180
- #: illumination (light-sheet sides), phase, block (CZI), emission / excitation
181
- #: (spectral scans; the wavelength in the image's metadata), lifetime (FLIM
182
- #: bins) and sequence (a dimension the file does not name; open(...,
232
+ #: plate, acquisition (imaging run), well and field for plates (docs/HCS.md;
233
+ #: on a plate, well and field take the place of scene), illumination
234
+ #: (light-sheet sides), phase, block (CZI), emission / excitation (spectral
235
+ #: scans; the wavelength in the image's metadata), lifetime (FLIM bins) and
236
+ #: sequence (a dimension the file does not name; open(...,
183
237
  #: rename={"sequence": "z"}) makes it an axis)
184
- INDEX_NAMES = ("scene", "tile", "rotation", "view", "illumination", "phase", "block",
185
- "emission", "excitation", "lifetime", "sequence", "resolution")
238
+ INDEX_NAMES = ("plate", "acquisition", "well", "field",
239
+ "scene", "tile", "rotation", "view", "illumination", "phase", "block",
240
+ "emission", "excitation", "lifetime", "sequence", "part", "resolution")
241
+ #: the indices of plates (docs/HCS.md), on every image of a plate;
242
+ #: ``plate`` and ``acquisition`` are 0 when a file holds one of each
243
+ PLATE_INDICES = ("plate", "acquisition", "well", "field")
186
244
  #: the indices that are not frames of the image but dimensions sampled at the
187
245
  #: same points (what other readers keep as array axes)
188
246
  SAMPLED_INDICES = ("illumination", "phase", "block", "emission", "excitation", "lifetime",
@@ -198,13 +256,24 @@ class File:
198
256
 
199
257
  backend: str = "base"
200
258
 
201
- def __init__(self, path: str, images: Sequence[Image]):
259
+ def __init__(self, path: str, images: Sequence[Image], plates: Sequence = ()):
202
260
  self.path = str(path)
203
261
  self.images = list(images)
262
+ #: the plates the images belong to (``_plate.Plate``), like ``images``
263
+ #: a list; empty for a file that is not a plate (docs/HCS.md)
264
+ self.plates = list(plates)
204
265
  if not getattr(self, "format", None):
205
266
  #: the format variant; the backend's name unless it reads several
206
267
  self.format = self.backend
207
268
  self._index_resolutions()
269
+ self._bind_plates()
270
+
271
+ def _bind_plates(self) -> None:
272
+ """Fill in each plate's wells -> fields -> images from ``images``
273
+ (again whenever ``images`` is replaced, e.g. by ``open(axes=...)``)."""
274
+ if self.plates:
275
+ from ._plate import navigate
276
+ self.plates = navigate(self.plates, self.images)
208
277
 
209
278
  def _index_resolutions(self) -> None:
210
279
  """``resolution`` is in every index when the file stores a pyramid of
@@ -235,9 +304,10 @@ class File:
235
304
  values it takes, e.g. ``{"scene": 2, "tile": 5}``. Not a total and
236
305
  not a shape: with 3 tiles in scene 0 and 5 in scene 1, tile indices
237
306
  run 0-2 and 0-4, so the range is 5 (not 8, and scene 0 has only 3).
238
- Tile, rotation and view indices count from 0, so the values are
239
- ``range(n)``; scene indices are the file's own (a CZI's may skip
240
- numbers), so for scenes it is the number of distinct values."""
307
+ Tile, rotation, view, well and field indices count from 0, so the
308
+ values are ``range(n)``; scene indices are the file's own (a CZI's
309
+ may skip numbers), so for scenes it is the number of distinct
310
+ values. A well index is a place in ``plates[p].wells``."""
241
311
  counts = {}
242
312
  for name in INDEX_NAMES:
243
313
  values = {image.index[name] for image in self.images if name in image.index}
@@ -263,6 +333,28 @@ class File:
263
333
  def __exit__(self, *exc) -> None:
264
334
  self.close()
265
335
 
266
- def __repr__(self) -> str:
336
+ def outline(self) -> str:
337
+ """The file's structure (docs/AXES.md): its plates, wells and fields,
338
+ or its scenes, each with its tiles, the orthogonal indices that vary
339
+ in it (``x view 2``), the image shape and the pyramid levels."""
340
+ from ._outline import outline_lines
341
+ return "\n".join([self._header()] + [" " + line for line in outline_lines(self)])
342
+
343
+ def _header(self) -> str:
344
+ plates = f" plates={len(self.plates)}" if self.plates else ""
267
345
  return (f"<micro_reader.File {self.path!r} backend={self.backend!r} "
268
- f"format={self.format!r} images={len(self.images)}>")
346
+ f"format={self.format!r} images={len(self.images)}{plates}>")
347
+
348
+ #: outline lines shown by ``repr``; ``outline()`` shows all
349
+ REPR_LINES = 6
350
+
351
+ def __repr__(self) -> str:
352
+ from ._outline import outline_lines
353
+ try:
354
+ lines = outline_lines(self)
355
+ except Exception: # a repr never fails
356
+ return self._header()
357
+ shown = lines[:self.REPR_LINES]
358
+ if len(lines) > len(shown):
359
+ shown.append(f"... {len(lines) - len(shown)} more lines: .outline()")
360
+ return "\n".join([self._header()] + [" " + line for line in shown])
@@ -12,3 +12,14 @@ class UnsupportedFormatError(MicroReaderError):
12
12
 
13
13
  class CorruptFileError(MicroReaderError):
14
14
  """The file is recognised but inconsistent, e.g. truncated."""
15
+
16
+
17
+ class MissingImagesWarning(UserWarning):
18
+ """A file set lists images whose files are missing; they are left out
19
+ (docs/HCS.md)."""
20
+
21
+
22
+ class CompanionWarning(UserWarning):
23
+ """An OME companion set's metadata came from somewhere other than the file
24
+ opened, or a member path had to be resolved differently than written
25
+ (docs/COMPANION.md)."""