• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

cokelaer / damona / 23594413777

26 Mar 2026 12:29PM UTC coverage: 90.782% (+6.6%) from 84.145%
23594413777

push

github

cokelaer
add isoquant, busco6.0.0 and zenodo new stragey

for the new strategy, we will upload release within their own page
so anybody can upload. No common page (this create restrictions)

9 of 17 new or added lines in 3 files covered. (52.94%)

1 existing line in 1 file now uncovered.

1753 of 1931 relevant lines covered (90.78%)

3.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.5
/damona/script.py
1
###########################################################################
2
# Damona is a project to manage reproducible containers                   #
3
#                                                                         #
4
# Authors: see CONTRIBUTORS.rst                                           #
5
# Copyright © 2020-2021  Institut Pasteur, Paris and CNRS.                #
6
# See the COPYRIGHT file for details                                      #
7
#                                                                         #
8
# Damona is free software: you can redistribute it and/or modify          #
9
# it under the terms of the GNU General Public License as published by    #
10
# the Free Software Foundation, either version 3 of the License, or       #
11
# (at your option) any later version.                                     #
12
#                                                                         #
13
# Damona  is distributed in the hope that it will be useful,              #
14
# but WITHOUT ANY WARRANTY; without even the implied warranty of          #
15
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the           #
16
# GNU General Public License for more details.                            #
17
#                                                                         #
18
# You should have received a copy of the GNU General Public License       #
19
# along with this program (COPYING file).                                 #
20
# If not, see <http://www.gnu.org/licenses/>.                             #
21
###########################################################################
22
""".. rubric:: Standalone application dedicated to Damona"""
4✔
23
import functools
4✔
24
import os
4✔
25
import pathlib
4✔
26
import subprocess
4✔
27
import sys
4✔
28
import time
4✔
29

30
import click
4✔
31
import click_completion
4✔
32
import packaging
4✔
33
import requests
4✔
34
import rich_click as click
4✔
35
from rich.console import Console
4✔
36
from rich.panel import Panel
4✔
37
from rich.table import Table
4✔
38
from rich.text import Text
4✔
39

40
click_completion.init()
4✔
41

42

43
URL = "https://raw.githubusercontent.com/cokelaer/damona/refs/heads/main/damona/software/registry.yaml"
4✔
44

45
from damona import Damona, Environ, Environment, version
4✔
46
from damona.common import BinaryReader, ImageReader, get_container_cmd, get_damona_path
4✔
47
from damona.install import (
4✔
48
    BiocontainersInstaller,
49
    LocalImageInstaller,
50
    RemoteImageInstaller,
51
)
52
from damona.registry import BiocontainersRegistry, Registry
4✔
53

54
click.rich_click.TEXT_MARKUP = "markdown"
4✔
55
click.rich_click.OPTIONS_TABLE_COLUMN_TYPES = ["required", "opt_short", "opt_long", "help"]
4✔
56
click.rich_click.OPTIONS_TABLE_HELP_SECTIONS = ["help", "deprecated", "envvar", "default", "required", "metavar"]
4✔
57
click.rich_click.STYLE_ERRORS_SUGGESTION = "magenta italic"
4✔
58
click.rich_click.SHOW_ARGUMENTS = True
4✔
59
click.rich_click.COMMAND_GROUPS = {
4✔
60
    "damona": [
61
        {
62
            "name": "Environment management",
63
            "commands": ["create", "remove", "rename", "env", "activate", "deactivate"],
64
        },
65
        {
66
            "name": "Package management",
67
            "commands": ["install", "uninstall", "clean", "export", "info"],
68
        },
69
        {
70
            "name": "Registry",
71
            "commands": ["search", "list", "stats"],
72
        },
73
        {
74
            "name": "Developer tools",
75
            "commands": ["check", "build", "catalog"],
76
        },
77
    ]
78
}
79

80
# manager = Damona()
81
def url_exists(url):
4✔
82
    try:
4✔
83
        response = requests.head(url, allow_redirects=True, timeout=5)
4✔
84
        return response.status_code == 200
4✔
85
    except requests.RequestException:
4✔
86
        return False
4✔
87

88

89
__all__ = ["main", "build"]
4✔
90

91
from damona import logger
4✔
92

93
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
4✔
94

95

96
def common_logger(func):
4✔
97
    @click.option(
4✔
98
        "--log-level",
99
        default="INFO",
100
        type=click.Choice(["INFO", "DEBUG", "WARNING", "CRITICAL", "ERROR"]),
101
        help="Set the logging level.",
102
    )
103
    @functools.wraps(func)
4✔
104
    def wrapper(*args, **kwargs):
4✔
105
        from damona import logger
4✔
106

107
        logger.remove()
4✔
108
        logger.add(
4✔
109
            sys.stderr,
110
            level=kwargs.get("log_level", "INFO"),
111
            format="<green>{time}</green> | <level>{level}</level> | <cyan>{message}</cyan>",
112
        )
113
        return func(*args, **kwargs)
4✔
114

115
    return wrapper
4✔
116

117

118
@click.group(context_settings=CONTEXT_SETTINGS)
4✔
119
@click.version_option(version=version)
4✔
120
def main():
4✔
121
    """Damona is an environment manager for singularity containers.
122

123
    It is to singularity container what conda is to packaging.
124

125
    The default environment is called 'base'. You can create and activate
126
    a new environment as follows:
127

128
        damona create --name TEST
129
        damona activate TEST
130

131
    Once an environment is activated, you can install a Damona-registered image
132
    (and its registered binaries):
133

134
        damona install fastqc:0.11.9
135

136
    More information on https://damona.readthedocs.io.
137
    Please report issues on https://github.com/cokelaer/damona
138
    Contact: Thomas Cokelaer at pasteur dot fr
139

140
    "Make everything as simple as possible, but not simpler." -- Albert Einstein
141
    """
142
    ######################## !!!!!!!!!!!! ####################
143
    # this function cannot print anything because the damona
144
    # activate command prints bash commands read by damona.sh
145
    ######################## !!!!!!!!!!!! ####################
146
    pass
4✔
147

148

149
@main.command()
4✔
150
@click.argument("environment", required=True, type=click.STRING)
4✔
151
@click.option("--from-bundle", type=click.STRING, help="A bundle file created with 'damona export --bundle'.")
4✔
152
@click.option("--from-yaml", type=click.STRING, help="A YAML file created with 'damona export --yaml'.")
4✔
153
@click.option(
4✔
154
    "--force",
155
    is_flag=True,
156
    help="When restoring from a bundle or YAML, overwrite existing binaries and images.",
157
)
158
@common_logger
4✔
159
def create(**kwargs):
4✔
160
    """Create a new environment
161

162
    Here we create an environment called TEST:
163

164
        damona create TEST
165

166
    You can then activate it:
167

168
        damona activate TEST
169

170
    You can create an environment from a environment.yaml file that was created with the 'export --yaml' command
171
    or manually built using the following syntax:
172

173

174
        name: sequana_rnaseq
175

176
        images:
177
            - sequana_tools_0.14.5.img
178

179
        binaries:
180
            - bwa
181
            - samtools
182
            - bamtools
183

184
    """
185
    envs = Environ()
4✔
186
    if kwargs["from_bundle"]:
4✔
187
        envs.create_from_bundle(kwargs["environment"], bundle=kwargs["from_bundle"], force=kwargs["force"])
4✔
188
    elif kwargs["from_yaml"]:
4✔
189
        envs.create_from_yaml(kwargs["environment"], yaml=kwargs["from_yaml"], force=kwargs["force"])
4✔
190
    else:
191
        envs.create(kwargs["environment"])
4✔
192

193

194
@main.command()
4✔
195
@click.argument("environment", required=True, type=click.STRING)
4✔
196
@click.option("--force", is_flag=True, help="Remove without asking for confirmation.")
4✔
197
@common_logger
4✔
198
def remove(**kwargs):
4✔
199
    """Remove an environment and all its binaries.
200

201
    Remove the environment named TEST:
202

203
        damona remove TEST
204

205
    To remove a package (binary + image) from the active environment, use:
206

207
        damona uninstall fastqc
208

209
    """
210
    env = Environ()
4✔
211
    env.delete(kwargs["environment"], force=kwargs["force"])
4✔
212

213

214
@main.command()
4✔
215
@click.argument("environment", required=True, type=click.STRING)
4✔
216
@click.option("--new-name", required=True, type=click.STRING, help="New name for the environment.")
4✔
217
@common_logger
4✔
218
def rename(**kwargs):
4✔
219
    """Rename an existing environment."""
220
    env = Environment(kwargs["environment"])
4✔
221
    env.rename(kwargs["new_name"])
4✔
222

223

224
# =================================================================== env
225
@main.command()
4✔
226
@common_logger
4✔
227
def env(**kwargs):
4✔
228
    """List all environments with their size and binary counts.
229

230
    Print information about current environments:
231

232
        damona env
233

234
    The currently active environment is marked with a checkmark.
235
    """
236
    envs = Environ()
4✔
237
    console = Console()
4✔
238

239
    table = Table(show_header=True, header_style="bold cyan", box=None, pad_edge=False)
4✔
240
    table.add_column("Environment", style="bold", min_width=20)
4✔
241
    table.add_column("Info")
4✔
242

243
    current_env = envs.get_current_env_name()
4✔
244
    if envs.N != 0:
4✔
245
        for this in envs.environments:
4✔
246
            name = this.name
4✔
247
            marker = " ✓" if name == current_env else ""
4✔
248
            table.add_row(name + marker, str(this))
4✔
249

250
    console.print(f"\nThere are currently [bold]{envs.N}[/bold] Damona environment(s):\n")
4✔
251
    console.print(table)
4✔
252
    console.print(f"\nYour current env is [bold green]'{current_env}'[/bold green].\n")
4✔
253

254

255
# =================================================================== activate
256
@main.command()
4✔
257
@click.argument("name", required=True, type=click.STRING)
4✔
258
def activate(**kwargs):
4✔
259
    """Activate a damona environment.
260

261
    The main Damona environment can be activated using:
262

263
        damona activate base
264

265
    Then, activation of a specific environment is done as:
266

267
        damona activate my_favorite_env
268

269
    """
270
    # DO NOT PRINT ANYTHING HERE OTHERWISE YOU'LL BREAK
271
    # DAMONA BASH EXPORT.If yo do, use # as commented text
272
    envs = Environ()
4✔
273
    envs.activate(kwargs["name"])
4✔
274

275

276
# =================================================================== deactivate
277
@main.command()
4✔
278
@click.argument("name", required=False, type=click.STRING, default=None)
4✔
279
@common_logger
4✔
280
def deactivate(**kwargs):
4✔
281
    """Deactivate the current Damona environment.
282

283
    deactivate the current environment:
284

285
        damona deactivate
286

287
    """
288
    # DO NOT PRINT ANYTHING HERE OTHERWISE YOU'LL BREAK
289
    # DAMONA BASH EXPORT.If yo do, use # as commented text
290
    env = Environ()
4✔
291
    env.deactivate(kwargs["name"])
4✔
292

293

294
# =================================================================== install
295
@main.command()
4✔
296
@click.argument("image", required=True, type=click.STRING)
4✔
297
@click.option("--force-image", is_flag=True, help="Overwrite the image if it already exists.")
4✔
298
@click.option("--force", is_flag=True, help="Overwrite both the image and its binaries.")
4✔
299
@click.option("--force-binaries", is_flag=True, help="Overwrite binaries even if they already exist.")
4✔
300
@click.option(
4✔
301
    "--local-registry-only", is_flag=True, default=False, help="Use the local registry only, ignore the online URL."
302
)
303
@click.option(
4✔
304
    "--registry",
305
    default=URL,
306
    help="URL of the online registry file. Override to use a custom registry.",
307
)
308
@click.option(
4✔
309
    "--binaries",
310
    default=None,
311
    help="Comma-separated list of binary names to install. Defaults to the image name.",
312
)
313
@common_logger
4✔
314
def install(**kwargs):
4✔
315
    """Download and install an image and its binaries into the active environment.
316

317
    Install a registered image by name, optionally specifying a version:
318

319
        damona install fastqc
320
        damona install fastqc:0.11.9
321

322
    If the version is omitted, the latest available version is installed.
323

324
    You may also install a local image file. By convention, image filenames must
325
    follow the pattern NAME_[v]x.y.z[_info].img (extension can be .img or .sif).
326
    The binary name defaults to the image name, but can be overridden:
327

328
        damona install fastqc_0.11.9.img
329
        damona install tool_0.4.2.img --binaries fastqc,tool2
330

331
    Images are stored in ~/.config/damona/images/ (or the directory set by
332
    the DAMONA_PATH environment variable).
333

334
    """
335
    logger.debug(kwargs)
4✔
336

337
    env = Environ()
4✔
338
    cenv = env.get_current_env()
4✔
339

340
    image_path = pathlib.Path(kwargs["image"]).absolute()
4✔
341

342
    force_image = kwargs["force_image"]
4✔
343
    force_binaries = kwargs["force_binaries"]
4✔
344

345
    if kwargs["force"]:
4✔
346
        force_image, force_binaries = True, True
4✔
347

348
    if kwargs["binaries"]:
4✔
349
        if "," in kwargs["binaries"]:
4✔
350
            binaries = kwargs["binaries"].split(",")
4✔
351
        else:
352
            binaries = [kwargs["binaries"]]
4✔
353
    else:
354
        binaries = None
4✔
355

356
    if kwargs["image"].startswith("biocontainers/"):
4✔
357
        p = BiocontainersInstaller(kwargs["image"], binaries=binaries)
4✔
358
        p.pull_image(force=force_image)
4✔
359
        p.install_binaries(force=force_binaries)
4✔
360
    elif os.path.exists(image_path) is False:
4✔
361
        url = kwargs["registry"]
4✔
362
        if url_exists(url) and kwargs["local_registry_only"] is False:
4✔
363
            logger.info(f"Installing from online registry ({url})")
4✔
364
            registry = Registry(from_url=url)
4✔
365
            p = RemoteImageInstaller(kwargs["image"], from_url=kwargs["registry"], cmd=sys.argv, binaries=binaries)
4✔
366
        else:
367
            logger.info("Installing from local registry")
4✔
368
            registry = Registry(from_url=None)
4✔
369
            p = RemoteImageInstaller(kwargs["image"], from_url=None, cmd=sys.argv, binaries=binaries)
4✔
370

371
        if p.is_valid():
4✔
372
            p.pull_image(force=force_image)
4✔
373
            p.install_binaries(force=force_binaries)
4✔
374
            with open(cenv / "history.log", "a+") as fout:
4✔
375
                cmd = " ".join(["damona"] + sys.argv[1:])
4✔
376
                fout.write(f"\n{time.asctime()}: {cmd}")
4✔
377
        else:
378
            logger.critical("Something wrong with your image/binaries. See message above")
×
379
            sys.exit(1)
×
380
    else:
381
        # This install the image and associated binary/binaries
382
        logger.info(f"Installing local container in {cenv}")
4✔
383

384
        lii = LocalImageInstaller(image_path, cmd=sys.argv, binaries=binaries)
4✔
385
        if lii.is_valid():
4✔
386
            lii.install_image(force=force_image)
4✔
387
            lii.install_binaries(force=force_binaries)
4✔
388
            with open(cenv / "history.log", "a+") as fout:
4✔
389
                cmd = " ".join(["damona"] + sys.argv[1:])
4✔
390
                fout.write(f"\n{time.asctime()}: {cmd}")
4✔
391
        else:
392
            logger.critical("Something wrong with your image/binaries. See message above")
4✔
393
            sys.exit(1)
4✔
394

395

396
# =================================================================== uninstall
397
@main.command()
4✔
398
@click.argument("name", required=True, type=click.STRING)
4✔
399
@click.option(
4✔
400
    "--environment", type=click.STRING, default=None, help="Target environment. Defaults to the currently active one."
401
)
402
# @click.option("--force", is_flag=True, help="force the removal of binaries or images")
403
@common_logger
4✔
404
def uninstall(**kwargs):
4✔
405
    """Uninstall a binary or an image from an environment.
406

407
    To uninstall an image (identified by the .img extension), pass its filename:
408

409
        damona uninstall fastqc_0.11.8.img
410

411
    To uninstall a binary from the active environment (and the image if it becomes orphaned):
412

413
        damona uninstall fastqc
414

415
    An image is only deleted from disk when it is no longer referenced by any environment.
416
    """
417
    # First, let us figure out the current or user-defined environment
418
    envs = Environ()
4✔
419
    env_name = kwargs["environment"]
4✔
420
    if not env_name:
4✔
421
        env_name = envs.get_current_env_name(warning=False)
4✔
422
        if env_name is None:
4✔
423
            logger.error(
4✔
424
                "You must activate a damina environment or use the --environment to define one where binary:image will be removed."
425
            )
426
            sys.exit(1)
4✔
427

428
    env = Environment(env_name)
4✔
429
    dam = Damona()
4✔
430

431
    # then, let us figure out what the user wants (remove a binary or image ?) and do it
432
    name = kwargs["name"]
4✔
433
    if kwargs["name"].endswith(".img"):
4✔
434
        # we delete the image if it is an orphan
435
        p = get_damona_path() / "images" / kwargs["name"]
4✔
436
        if p.exists():
4✔
437
            ir = ImageReader(p)
×
438
            ir.delete()
×
439
        else:
440
            logger.warning(f"input file {p} does not exists")
4✔
441

442
    else:
443
        # Search for the name in the installed binaries
444
        if name in env:
4✔
445
            logger.info(f"Removing binary {name}")
4✔
446
            binary = [x for x in env.get_installed_binaries() if x.name == name]
4✔
447
            binary = binary[0]
4✔
448
            br = BinaryReader(binary)
4✔
449
            br.image
4✔
450

451
            # keep this info before deleting the file
452
            image_name = br.get_image()
4✔
453

454
            # we now delete the executable
455
            binary.unlink()
4✔
456

457
            # and the image if required
458
            p = get_damona_path() / "images" / (image_name.replace(":", "_") + ".img")
4✔
459
            ir = ImageReader(p)
4✔
460
            ir.delete()
4✔
461
        else:
462
            logger.warning(f"{name} was not found in the environment {env_name}. Not removed")
4✔
463

464
    with open(env.path / "history.log", "a+") as fout:
4✔
465
        cmd = " ".join(["damona"] + sys.argv[1:])
4✔
466
        fout.write(f"\n{time.asctime()}: {cmd}")
4✔
467

468

469
# =================================================================== clean
470
@main.command()
4✔
471
@click.option(
4✔
472
    "--do-remove", is_flag=True, help="Actually delete the orphaned binaries and images (dry-run by default)."
473
)
474
@common_logger
4✔
475
def clean(**kwargs):
4✔
476
    """Find and remove orphaned images and binaries across all environments.
477

478
    An orphaned binary points to a missing image; an orphaned image has no
479
    binary referencing it in any environment. This can happen after upgrades.
480

481
    By default this is a dry run — use --do-remove to actually delete:
482

483
        damona clean
484
        damona clean --do-remove
485

486
    To remove an entire environment, use:
487

488
        damona remove NAME
489

490
    """
491
    logger.debug(kwargs)
4✔
492

493
    dmn = Damona()
4✔
494

495
    # First we deal with orphans from the binaries directories
496
    orphans = dmn.find_orphan_binaries()
4✔
497
    if len(orphans) == 0:
4✔
498
        # nothing to do
499
        logger.info("No binary orphan found")
4✔
500
    else:
501
        logger.info(f"Found {len(orphans)} binary orphans.")
×
502
        if kwargs["do_remove"]:
×
503
            for x in orphans:
×
504
                os.remove(os.path.expanduser(x))
×
505
                logger.info(f"Removed {x}")
×
506
        else:
507
            logger.warning("Please use --do-remove to confirm that you want to remove the orphans")
×
508

509
    # Second, we find images that have no more binaries
510
    orphans = dmn.find_orphan_images()
4✔
511
    if len(orphans) == 0:
4✔
512
        logger.info("No orphan images found")
4✔
513
    else:
514
        logger.info(f"Found {len(orphans)} image orphans.")
4✔
515

516
        if kwargs["do_remove"]:  # pragma: no cover
517
            for x in orphans:
518
                os.remove(os.path.expanduser(x))
519
                logger.info(f"Removed {x}")
520
        else:
521
            logger.warning("Please use --do-remove to confirm that you want to remove the orphans")
×
522

523

524
# =================================================================== search
525
@main.command()
4✔
526
@click.argument("pattern", required=True, type=click.STRING)
4✔
527
@click.option("--images-only", is_flag=True, default=False, help="Show matching images only, not binaries.")
4✔
528
@click.option("--include-biocontainers", is_flag=True, default=False, help="Also search the BioContainers registry.")
4✔
529
@click.option(
4✔
530
    "--local-registry-only", is_flag=True, default=False, help="Use the local registry only, ignore the online URL."
531
)
532
@click.option("--binaries-only", is_flag=True, default=False, help="Show matching binaries only, not images.")
4✔
533
@click.option(
4✔
534
    "--registry",
535
    default=URL,
536
    show_default=True,
537
    help="URL of the online registry file. Override to use a custom registry.",
538
)
539
@common_logger
4✔
540
def search(**kwargs):
4✔
541
    """Search the registry for a container image or binary.
542

543
    Search by name in the official Damona registry:
544

545
        damona search fastqc
546

547
    Use `"*"` to list all available software and versions:
548

549
        damona search "*"
550

551
    On fish shells, quote the wildcard differently:
552

553
        damona search '"*"'
554

555
    You can define a custom registry in ~/.config/damona/damona.cfg:
556

557
        [urls]
558
        alias=https://example.com/damona/registry.yaml
559

560
    Then pass it via --registry or its alias. To also search BioContainers:
561

562
        damona search fastqc --include-biocontainers
563

564
    """
565
    url = kwargs.get("registry")
4✔
566

567
    if kwargs["pattern"] == "*":
4✔
568
        pattern = None
4✔
569
    else:
570
        pattern = kwargs["pattern"]
4✔
571

572
    if url_exists(url) and kwargs["local_registry_only"] is False:
4✔
573
        logger.info(f"Searching online registry ({url})")
×
574
        registry = Registry(from_url=url)
×
575
    else:
576
        logger.info("Searching local registry")
4✔
577
        registry = Registry(from_url=None)
4✔
578

579
    console = Console()
4✔
580
    recommended = None
4✔
581
    recommended_url = None
4✔
582
    recommended_size = None
4✔
583

584
    if not kwargs["binaries_only"]:
4✔
585
        modules = registry.get_list(pattern=pattern)
4✔
586
        table = Table(show_header=True, header_style="bold cyan", box=None, pad_edge=False)
4✔
587
        table.add_column("Release", style="bold", min_width=25)
4✔
588
        table.add_column("Size", justify="right", min_width=8)
4✔
589
        table.add_column("URL")
4✔
590
        for mod in modules:
4✔
591
            name, version = mod.split(":")
4✔
592
            dl_url = registry.registry[mod]._data[name]["releases"][version]["download"]
4✔
593
            try:
4✔
594
                size = registry.registry[mod]._data[name]["releases"][version]["filesize"]
4✔
595
                if size > 1e9:
4✔
596
                    size_str = f"{round(size / 1e9, 2)}G"
4✔
597
                else:
598
                    size_str = f"{round(size / 1e6, 2)}M"
4✔
599
            except Exception:
4✔
600
                logger.warning(f"{mod}. could not extract filesize")
4✔
601
                size_str = "-1"
4✔
602

603
            table.add_row(mod, size_str, dl_url)
4✔
604
            if not recommended:
4✔
605
                recommended = mod
4✔
606
                recommended_url = dl_url
4✔
607
                recommended_size = size_str
4✔
608
            else:
609
                recommended_version = recommended.split(":")[1]
4✔
610
                try:
4✔
611
                    if packaging.version.parse(version) > packaging.version.parse(recommended_version):
4✔
612
                        recommended = mod
4✔
613
                        recommended_url = dl_url
4✔
614
                        recommended_size = size_str
4✔
615
                except packaging.version.InvalidVersion:
4✔
616
                    pass
4✔
617

618
        console.print(f"\nPattern '[bold]{pattern}[/bold]' found in these releases:")
4✔
619
        console.print(table)
4✔
620

621
    if not kwargs["images_only"]:
4✔
622
        modules = registry.get_binaries(pattern=pattern)
4✔
623
        table = Table(show_header=True, header_style="bold cyan", box=None, pad_edge=False)
4✔
624
        table.add_column("Release", style="bold", min_width=25)
4✔
625
        table.add_column("Binaries")
4✔
626
        table.add_column("Size", justify="right", min_width=8)
4✔
627
        for mod in sorted(modules.keys()):
4✔
628
            v = modules[mod]
4✔
629
            name, version = mod.split(":")
4✔
630
            try:
4✔
631
                size = registry.registry[mod]._data[name]["releases"][version]["filesize"]
4✔
632
                if size > 1e9:
4✔
633
                    size_str = f"{round(size / 1e9, 2)}G"
4✔
634
                else:
635
                    size_str = f"{round(size / 1e6, 2)}M"
4✔
636
            except Exception:
4✔
637
                logger.warning(f"{mod}. could not extract filesize")
4✔
638
                size_str = "-1"
4✔
639

640
            table.add_row(mod, ", ".join(v), size_str)
4✔
641

642
        console.print(f"\nPattern '[bold]{pattern}[/bold]' found as binaries:")
4✔
643
        console.print(table)
4✔
644

645
    if kwargs["include_biocontainers"]:
4✔
646
        console.print("\n[bold]Searching biocontainers:[/bold]")
×
647
        br = BiocontainersRegistry()
×
648
        for name, data in br.data.items():
×
649
            if pattern in name:
×
650
                console.print(f"Pattern '[bold]{name}[/bold]' Found in these releases:")
×
651
                for version, location in data["releases"].items():
×
652
                    download = f"{location['download']})"
×
653
                    download = download.replace("docker://quay.io/", "").split("--")[0]
×
654
                    download = download.replace("docker://", "").split("--")[0]
×
655
                    install = f"(damona install {download})"
×
656
                    console.print(f" - {name}:{version}: {install} ")
×
657
            elif pattern is None:
×
658
                console.print(f" - {name}")
×
659

660
    if recommended:
4✔
661
        content = f"[bold green]damona install {recommended}[/bold green]"
4✔
662
        if recommended_size:
4✔
663
            content += f"  [dim]({recommended_size})[/dim]"
4✔
664
        if recommended_url:
4✔
665
            content += f"\n[dim italic]For your information, url is {recommended_url}[/dim italic]"
4✔
666
        console.print(
4✔
667
            Panel(
668
                content,
669
                title="ℹ️  Recommended installation (latest version and dedicated container)",
670
                border_style="green",
671
            )
672
        )
673

674

675
# ============================================================  export
676
@main.command()
4✔
677
@click.argument("environment", required=True, type=click.STRING)
4✔
678
@common_logger
4✔
679
def info(**kwargs):
4✔
680
    """Show images and binaries installed in an environment.
681

682
    damona info base
683
    damona info myenv
684

685
    """
686
    logger.debug(kwargs)
4✔
687
    envname = kwargs["environment"]
4✔
688

689
    manager = Environ()
4✔
690

691
    x = [ee for ee in manager.environments if ee.name == envname]
4✔
692
    if len(x) == 0:
4✔
693
        logger.error(f"Environment '{envname}' does not exist. Use 'damona env' to get the list")
4✔
694
        sys.exit(1)
4✔
695
    else:
696
        environ = x[0]
4✔
697
        console = Console()
4✔
698
        console.print(f"\n[bold]Environment:[/bold] {envname}\n")
4✔
699

700
        img_table = Table(show_header=True, header_style="bold cyan", box=None, pad_edge=False)
4✔
701
        img_table.add_column("Images", style="dim", min_width=30)
4✔
702
        for item in sorted(environ.get_images()):
4✔
703
            img_table.add_row(pathlib.Path(item).name)
4✔
704
        console.print(img_table)
4✔
705

706
        bin_table = Table(show_header=True, header_style="bold cyan", box=None, pad_edge=False)
4✔
707
        bin_table.add_column("Binaries", min_width=30)
4✔
708
        for item in sorted(environ.get_installed_binaries()):
4✔
709
            bin_table.add_row(pathlib.Path(item).name)
4✔
710
        console.print(bin_table)
4✔
711

712

713
# ============================================================  export
714
@main.command()
4✔
715
@click.argument("environment", required=True, type=click.STRING)
4✔
716
@click.option("--yaml", help="Output YAML file path.")
4✔
717
@click.option("--bundle", default=None, help="Output tar bundle file path.")
4✔
718
@common_logger
4✔
719
def export(**kwargs):
4✔
720
    """Export an environment as a YAML file or a tar bundle.
721

722
    A YAML file records the image and binary names (lightweight, no data):
723

724
        damona export TEST --yaml test_env.yaml
725

726
    A tar bundle copies the actual image files (portable, larger):
727

728
        damona export TEST --bundle test_bundle.tar
729

730
    The exported file can be used to recreate the environment:
731

732
        damona create TEST1 --from-yaml   test_env.yaml
733
        damona create TEST1 --from-bundle test_bundle.tar
734

735
    """
736
    from damona import Environment
4✔
737

738
    logger.debug(kwargs)
4✔
739

740
    environment = kwargs["environment"]
4✔
741
    envname = kwargs["environment"]
4✔
742

743
    # TODO This should be based on the binaries of the environment, not the images
744
    # to do so, we'll need an installed.txt file
745

746
    env = Environment(envname)
4✔
747
    if kwargs["bundle"]:
4✔
748
        bundle_file = kwargs["bundle"]
4✔
749
        output = env.create_bundle(output_name=kwargs["bundle"])
4✔
750
        logger.info(
4✔
751
            f"Use this command to recreate the environment: \n\n\tdamona create NEW_NAME --from-bundle {bundle_file}"
752
        )
753
    elif kwargs["yaml"]:
4✔
754
        yaml_file = kwargs["yaml"]
4✔
755
        env.create_yaml(output_name=yaml_file)
4✔
756
        logger.info(
4✔
757
            f"Use this command to recreate the environment: \n\n\tdamona create NEW_NAME --from-yaml {yaml_file}"
758
        )
759
    else:
760
        raise click.UsageError("Please specify --yaml or --bundle. See 'damona export --help'.")
4✔
761

762

763
# ============================================================  stats
764

765

766
@main.command()
4✔
767
@click.option("--include-biocontainers", is_flag=True, help="Also count BioContainers entries (experimental).")
4✔
768
@click.option("--include-downloads", is_flag=True, help="Fetch and show download counts from Zenodo (slow).")
4✔
769
@common_logger
4✔
770
def stats(**kwargs):
4✔
771
    """Show registry statistics and local installation summary.
772

773
    Prints the number of containers, versions, and unique binaries in the
774
    registry, plus how many images are installed locally and their disk usage:
775

776
        damona stats
777

778
    """
779
    import contextlib
4✔
780
    import io
4✔
781

782
    from damona import admin
4✔
783

784
    console = Console()
4✔
785
    with contextlib.redirect_stdout(io.StringIO()):
4✔
786
        data = admin.stats()
4✔
787
    if kwargs["include_biocontainers"]:
4✔
788
        with contextlib.redirect_stdout(io.StringIO()):
4✔
789
            bc_data = admin.stats(True)
4✔
790

791
    table = Table(show_header=False, box=None, pad_edge=False)
4✔
792
    table.add_column("Key", style="bold cyan", min_width=25)
4✔
793
    table.add_column("Value", justify="right")
4✔
794
    table.add_row("Containers", str(data["software"]))
4✔
795
    table.add_row("Versions", str(data["version"]))
4✔
796
    table.add_row("Unique binaries", str(data["unique_binaries"]))
4✔
797
    if kwargs["include_biocontainers"]:
4✔
798
        table.add_row("Biocontainers", str(bc_data.get("software", "N/A")))
4✔
799
    console.print(Panel(table, title="[bold]Damona Registry Stats[/bold]", border_style="cyan"))
4✔
800

801
    if kwargs["include_downloads"]:
4✔
802
        console.print("\n[bold]Detailed summary of downloads for each container:[/bold]")
×
803
        from damona import admin, zenodo
×
804

NEW
805
        all_software = sorted(admin.get_software_names())
×
806
        N = 0
×
NEW
807
        console.print(f"{'Software':<25} {'Downloads':>10}")
×
NEW
808
        console.print("-" * 36)
×
NEW
809
        for software in all_software:
×
810
            downloads = zenodo.get_stats_software(software)
×
NEW
811
            console.print(f"{software:<25} {str(downloads):>10}")
×
812
            try:
×
NEW
813
                N += int(str(downloads).replace(",", ""))
×
814
            except (AttributeError, ValueError):
×
NEW
815
                pass
×
NEW
816
        console.print("-" * 36)
×
UNCOV
817
        console.print(f"[bold]Total:[/bold] {N}")
×
818

819
    envs = Environ()
4✔
820
    N = len(envs.images)
4✔
821
    usage = envs.images.get_disk_usage()
4✔
822
    console.print(
4✔
823
        Panel(
824
            f"[bold]{N}[/bold] image(s) installed, using [bold]{usage} Mb[/bold] of disk space.",
825
            title="[bold]Local Installation[/bold]",
826
            border_style="cyan",
827
        )
828
    )
829

830

831
# ===================================================================  list
832

833

834
@main.command()
4✔
835
@common_logger
4✔
836
def list(**kwargs):
4✔
837
    """List all containers available in the local registry."""
838
    r = Registry()
4✔
839
    console = Console()
4✔
840
    table = Table(show_header=True, header_style="bold cyan", box=None, pad_edge=False)
4✔
841
    table.add_column("Name", style="bold", min_width=20)
4✔
842
    table.add_column("Version", min_width=10)
4✔
843
    for entry in sorted(r.get_list()):
4✔
844
        name, version = entry.split(":")
4✔
845
        table.add_row(name, version)
4✔
846
    console.print(table)
4✔
847

848

849
# ===================================================================  catalog
850

851

852
def _get_base_image(name, version, damona_root):
4✔
853
    """Return a short base-image label extracted from the Singularity definition file."""
854
    import pathlib as _pathlib
4✔
855

856
    sif_path = _pathlib.Path(damona_root) / "software" / name / f"Singularity.{name}_{version}"
4✔
857
    if not sif_path.exists():
4✔
858
        return "?"
4✔
859

860
    bootstrap = None
4✔
861
    from_line = None
4✔
862
    with open(sif_path) as fh:
4✔
863
        for line in fh:
4✔
864
            stripped = line.strip()
4✔
865
            if stripped.lower().startswith("bootstrap:"):
4✔
866
                bootstrap = stripped.split(":", 1)[1].strip().lower()
4✔
867
            elif stripped.lower().startswith("from:"):
4✔
868
                from_line = stripped.split(":", 1)[1].strip()
4✔
869
                break
4✔
870

871
    if from_line is None:
4✔
872
        return "?"
×
873

874
    from_lower = from_line.lower()
4✔
875
    # localimage pointing to a library .img file
876
    if bootstrap == "localimage" or (bootstrap is None and from_line.endswith(".img")):
4✔
877
        stem = _pathlib.Path(from_line).stem  # e.g. micromamba_1.5.8
4✔
878
        return stem.rsplit("_", 1)[0] if "_" in stem else stem
4✔
879

880
    # docker / library bootstrap — keep registry-prefix stripped
881
    label = from_line.split("/")[-1]  # drop registry host and org
4✔
882
    return label
4✔
883

884

885
@main.command(hidden=True)
4✔
886
@click.option(
4✔
887
    "--sort",
888
    default="name",
889
    type=click.Choice(["name", "size", "base"], case_sensitive=False),
890
    show_default=True,
891
    help="Sort rows by software name, download size, or base image.",
892
)
893
@common_logger
4✔
894
def catalog(**kwargs):
4✔
895
    """Show a developer overview: latest version, size, and base image for every container.
896

897
    Iterates the local registry and, for each software, reports the latest
898
    available version, its download size, and the underlying base image
899
    inferred from the Singularity definition file:
900

901
        damona catalog
902

903
    Sort by size to quickly spot heavy containers:
904

905
        damona catalog --sort size
906

907
    Sort by base image to group containers sharing the same base:
908

909
        damona catalog --sort base
910

911
    Useful for spotting containers that use heavy bases (e.g. micromamba) vs
912
    lean ones (alpine, debian-slim) and for auditing image sizes at a glance.
913
    """
914
    import pathlib as _pathlib
4✔
915

916
    import packaging.version as _pv
4✔
917

918
    from damona import __path__ as _damona_path
4✔
919

920
    damona_root = _damona_path[0]
4✔
921
    registry = Registry(from_url=None)
4✔
922
    console = Console()
4✔
923

924
    # Group versions by software name and collect all row data first
925
    software_versions: dict = {}
4✔
926
    for key in registry.get_list():
4✔
927
        sw_name, ver = key.split(":")
4✔
928
        software_versions.setdefault(sw_name, []).append(ver)
4✔
929

930
    rows = []
4✔
931
    for sw_name in software_versions:
4✔
932
        versions = software_versions[sw_name]
4✔
933
        try:
4✔
934
            latest = str(max(versions, key=lambda v: _pv.parse(v)))
4✔
935
        except Exception:
4✔
936
            latest = versions[-1]
4✔
937

938
        key = f"{sw_name}:{latest}"
4✔
939
        try:
4✔
940
            size = registry.registry[key]._data[sw_name]["releases"][latest]["filesize"]
4✔
941
            size_str = f"{round(size / 1e9, 2)}G" if size > 1e9 else f"{round(size / 1e6, 2)}M"
4✔
942
        except Exception:
4✔
943
            size = 0
4✔
944
            size_str = "?"
4✔
945

946
        base = _get_base_image(sw_name, latest, damona_root)
4✔
947
        rows.append((sw_name, latest, size_str, base, size))
4✔
948

949
    sort_key = kwargs["sort"].lower()
4✔
950
    if sort_key == "size":
4✔
951
        rows.sort(key=lambda r: r[4])
4✔
952
    elif sort_key == "base":
4✔
953
        rows.sort(key=lambda r: r[3].lower())
4✔
954
    else:
955
        rows.sort(key=lambda r: r[0].lower())
4✔
956

957
    table = Table(show_header=True, header_style="bold cyan", box=None, pad_edge=False)
4✔
958
    table.add_column("Software", style="bold", min_width=22)
4✔
959
    table.add_column("Latest", min_width=12)
4✔
960
    table.add_column("Size", justify="right", min_width=8)
4✔
961
    table.add_column("Base image", min_width=24)
4✔
962

963
    for sw_name, latest, size_str, base, _ in rows:
4✔
964
        table.add_row(sw_name, latest, size_str, base)
4✔
965

966
    console.print(table)
4✔
967

968

969
# ============================================================  HIDDEN commands
970
# ============================================================  zenodo-upload
971

972

973
@main.command(hidden=True)
4✔
974
@click.argument("filename", required=True)
4✔
975
@click.option(
4✔
976
    "--token",
977
    default=None,
978
    help="""A valid zenodo (or sandbox zenodo) token (see damona zenodo --help for details).""",
979
)
980
@click.option(
4✔
981
    "--mode", default="sandbox.zenodo", help="Upload target: 'zenodo' for production, 'sandbox.zenodo' for testing."
982
)
983
@click.option(
4✔
984
    "--no-check", default=False, is_flag=True, help="Skip the python/bash availability check inside the container."
985
)
986
@common_logger
4✔
987
def upload(**kwargs):  # pragma: no cover
988
    """Upload a Singularity image to Zenodo. FOR DEVELOPERS ONLY.
989

990
    Test the upload on the Zenodo sandbox first:
991

992
        damona upload file_1.0.0.img --mode sandbox.zenodo
993

994
    Once satisfied, publish to production Zenodo:
995

996
        damona upload file_1.0.0.img --mode zenodo
997

998
    If no registry.yaml is found in the local directory, it is created.
999
    Otherwise, it is updated. The changes are also printed on the stdout.
1000

1001
    You can set the token in your home/.config/damona/damona.cfg that looks like
1002

1003
        [general]
1004
        quiet=False
1005

1006
        [urls]
1007
        damona=https://biomics.pasteur.fr/salsa/damona/registry.txt
1008

1009
        [zenodo]
1010
        token=APmm6p...
1011
        orcid=0000-0001-...
1012
        affiliation=Your Institute
1013
        name=Surname, firstname
1014

1015
        [sandbox.zenodo]
1016
        token=FFmbAE...
1017
        orcid=0000-0001-...
1018
        affiliation=Your Institute
1019
        name=Surname, firstname
1020

1021
    """
1022
    from damona.zenodo import Zenodo
1023

1024
    # some aliases
1025
    token = kwargs["token"]
1026
    mode = kwargs["mode"]
1027
    filename = kwargs["filename"]
1028

1029
    # check that python and bash are available in the container.
1030
    status = subprocess.run(f"{get_container_cmd()} exec {filename} python --version".split(), stdout=subprocess.PIPE)
1031
    if status.returncode:
1032
        click.echo("Damona Warning: could not find **python** command in the container")
1033
        proceed = click.prompt("Do you want to proceed ?")
1034
        if proceed:
1035
            click.echo("Uploading without Python found in the container.")
1036
        else:
1037
            click.echo("Exiting...")
1038
            sys.exit(1)
1039

1040
    status = subprocess.run(f"{get_container_cmd()} exec {filename} bash --version".split(), stdout=subprocess.PIPE)
1041
    if status.returncode:
1042
        click.echo("Damona ERROR: could not find **bash** command in the container", err=True)
1043
        sys.exit(1)
1044

1045
    #
1046
    z = Zenodo(mode, token)
1047
    logger.info(f"Uploading to {mode}")
1048
    z._upload(filename)
1049

1050

1051
# =================================================================== check
1052
@main.command()
4✔
1053
@click.argument("image", type=click.Path(exists=True))
4✔
1054
@click.option(
4✔
1055
    "--binaries",
1056
    default=None,
1057
    help="Comma-separated list of binaries to check. Defaults to those listed in the local registry.",
1058
)
1059
@common_logger
4✔
1060
def check(**kwargs):
4✔
1061
    """Check that all binaries in a built image are functional.
1062

1063
    Given a local Singularity image, run each registered binary inside the
1064
    container and report whether it is found and executable.  Useful after
1065
    building a new image to catch missing or broken tools before uploading.
1066

1067
    Check all binaries declared in the registry for fastqc:
1068

1069
        damona check ~/.config/damona/images/fastqc_0.11.9.img
1070

1071
    Override the binary list manually:
1072

1073
        damona check fastqc_0.11.9.img --binaries fastqc
1074

1075
    Exit code is 0 if all binaries pass, 1 if any fail.
1076
    """
1077
    from damona.common import get_container_cmd
4✔
1078
    from damona.registry import Registry
4✔
1079

1080
    image = pathlib.Path(kwargs["image"]).resolve()
4✔
1081
    console = Console()
4✔
1082

1083
    # Determine binary list
1084
    if kwargs["binaries"]:
4✔
1085
        binaries = [b.strip() for b in kwargs["binaries"].split(",")]
4✔
1086
    else:
1087
        # Try to infer from the local registry using the image filename
1088
        reader = ImageReader(image)
4✔
1089
        name = reader.guessed_executable
4✔
1090
        version = reader.version
4✔
1091
        reg = Registry(from_url=None)
4✔
1092
        key = f"{name}:{version}"
4✔
1093
        if key in reg.registry:
4✔
1094
            binaries = reg.registry[key].binaries
×
1095
        else:
1096
            logger.critical(f"Could not find '{key}' in the local registry. Use --binaries to specify them explicitly.")
4✔
1097
            raise SystemExit(1)
4✔
1098

1099
    container_cmd = get_container_cmd()
4✔
1100
    results = []
4✔
1101

1102
    for binary in binaries:
4✔
1103
        found = False
4✔
1104
        version_str = ""
4✔
1105

1106
        for args in ["--version", "-v", ""]:
4✔
1107
            cmd = f"{container_cmd} exec {image} {binary} {args}".strip()
4✔
1108
            proc = subprocess.run(cmd, shell=True, capture_output=True)
4✔
1109
            stdout = proc.stdout.decode(errors="replace").strip()
4✔
1110
            stderr = proc.stderr.decode(errors="replace").strip()
4✔
1111
            output = stdout or stderr
4✔
1112

1113
            if proc.returncode == 0:
4✔
1114
                found = True
4✔
1115
                version_str = output.splitlines()[0][:60] if output else ""
4✔
1116
                break
4✔
1117
            elif "executable file not found" in stderr or "not found" in stderr:
4✔
1118
                break
4✔
1119
            else:
1120
                # Non-zero exit but binary ran (e.g. printed usage to stderr)
1121
                found = True
×
1122
                version_str = output.splitlines()[0][:60] if output else ""
×
1123
                break
×
1124

1125
        results.append((binary, found, version_str))
4✔
1126

1127
    table = Table(title=f"Binary check: {image.name}", show_lines=False)
4✔
1128
    table.add_column("Binary", style="cyan", no_wrap=True)
4✔
1129
    table.add_column("Status", justify="center")
4✔
1130
    table.add_column("Output", style="dim")
4✔
1131

1132
    all_ok = True
4✔
1133
    for binary, ok, version_str in results:
4✔
1134
        status = Text("PASS", style="bold green") if ok else Text("FAIL", style="bold red")
4✔
1135
        table.add_row(binary, status, version_str)
4✔
1136
        if not ok:
4✔
1137
            all_ok = False
4✔
1138

1139
    console.print(table)
4✔
1140

1141
    if not all_ok:
4✔
1142
        raise SystemExit(1)
4✔
1143

1144

1145
# =================================================================== build
1146
@main.command()
4✔
1147
@click.argument("filename", required=True, type=click.STRING)
4✔
1148
@click.option(
4✔
1149
    "--destination",
1150
    default=None,
1151
    help="Output image filename (required when the source has no version, e.g. docker:// URLs).",
1152
)
1153
@click.option("--force", is_flag=True, help="Overwrite the output image if it already exists.")
4✔
1154
@common_logger
4✔
1155
def build(**kwargs):  # pragma: no cover
1156
    """Build a Singularity image from a local recipe, a Damona recipe, or a Docker image.
1157

1158
    From a local Singularity recipe (filename must follow Singularity.NAME_x.y.z):
1159

1160
    \b
1161
        damona build Singularity.salmon_1.3.0
1162

1163
    From a Damona-registered recipe (listed by 'damona list'):
1164

1165
        damona build salmon:1.3.0
1166

1167
    From a Docker Hub image:
1168

1169
        damona build docker://biocontainers/bowtie2:v2.4.1_cv1
1170

1171
    When the source URL carries no version, provide the output name explicitly:
1172

1173
        damona build docker://kapeel/hisat2 --destination hisat2_v2.0.0.img
1174

1175
    """
1176
    logger.debug(kwargs)
1177
    filename = kwargs["filename"]
1178
    force = kwargs["force"]
1179
    destination = kwargs["destination"]
1180

1181
    if destination:
1182
        if destination.endswith(".img") is False:
1183
            logger.warning("You should end your image with the .img extension")
1184
        if "_" not in destination:
1185
            logger.warning("You should name your image as NAME_x.y.z")
1186

1187
    if os.path.exists(filename) and os.path.isdir(filename) is False:
1188
        # TODO check that it starts with Singularity
1189
        # local recipes ?
1190
        from damona.builders import BuilderFromSingularityRecipe
1191

1192
        builder = BuilderFromSingularityRecipe()
1193
        builder.build(filename, destination=destination, force=force)
1194
    elif kwargs["filename"].startswith("docker://"):
1195
        from damona.builders import BuilderFromDocker
1196

1197
        builder = BuilderFromDocker()
1198
        filename = filename.replace("docker://", "")
1199
        builder.build(filename, destination=destination, force=force)
1200
    else:  # could be a damona recipes
1201
        logger.info("Not a docker URL, nor a local file.")
1202

1203

1204
if __name__ == "__main__":  # pragma: no cover
1205
    main()
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc