mirror of
https://github.com/jupyter/docker-stacks.git
synced 2025-10-07 10:04:03 +00:00
Better tagging directory structure (#2228)
This commit is contained in:
@@ -78,7 +78,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Write tags file 🏷
|
- name: Write tags file 🏷
|
||||||
run: >
|
run: >
|
||||||
python3 -m tagging.write_tags_file
|
python3 -m tagging.apps.write_tags_file
|
||||||
--registry ${{ env.REGISTRY }}
|
--registry ${{ env.REGISTRY }}
|
||||||
--owner ${{ env.OWNER }}
|
--owner ${{ env.OWNER }}
|
||||||
--short-image-name ${{ inputs.image }}
|
--short-image-name ${{ inputs.image }}
|
||||||
@@ -94,7 +94,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Write manifest and build history file 🏷
|
- name: Write manifest and build history file 🏷
|
||||||
run: >
|
run: >
|
||||||
python3 -m tagging.write_manifest
|
python3 -m tagging.apps.write_manifest
|
||||||
--registry ${{ env.REGISTRY }}
|
--registry ${{ env.REGISTRY }}
|
||||||
--owner ${{ env.OWNER }}
|
--owner ${{ env.OWNER }}
|
||||||
--short-image-name ${{ inputs.image }}
|
--short-image-name ${{ inputs.image }}
|
||||||
|
2
.github/workflows/docker-merge-tags.yml
vendored
2
.github/workflows/docker-merge-tags.yml
vendored
@@ -68,7 +68,7 @@ jobs:
|
|||||||
- name: Merge tags for the images 🔀
|
- name: Merge tags for the images 🔀
|
||||||
if: env.PUSH_TO_REGISTRY == 'true'
|
if: env.PUSH_TO_REGISTRY == 'true'
|
||||||
run: >
|
run: >
|
||||||
python3 -m tagging.merge_tags
|
python3 -m tagging.apps.merge_tags
|
||||||
--short-image-name ${{ inputs.image }}
|
--short-image-name ${{ inputs.image }}
|
||||||
--variant ${{ inputs.variant }}
|
--variant ${{ inputs.variant }}
|
||||||
--tags-dir /tmp/jupyter/tags/
|
--tags-dir /tmp/jupyter/tags/
|
||||||
|
2
.github/workflows/docker-tag-push.yml
vendored
2
.github/workflows/docker-tag-push.yml
vendored
@@ -62,7 +62,7 @@ jobs:
|
|||||||
path: /tmp/jupyter/tags/
|
path: /tmp/jupyter/tags/
|
||||||
- name: Apply tags to the loaded image 🏷
|
- name: Apply tags to the loaded image 🏷
|
||||||
run: >
|
run: >
|
||||||
python3 -m tagging.apply_tags
|
python3 -m tagging.apps.apply_tags
|
||||||
--registry ${{ env.REGISTRY }}
|
--registry ${{ env.REGISTRY }}
|
||||||
--owner ${{ env.OWNER }}
|
--owner ${{ env.OWNER }}
|
||||||
--short-image-name ${{ inputs.image }}
|
--short-image-name ${{ inputs.image }}
|
||||||
|
6
.github/workflows/docker-wiki-update.yml
vendored
6
.github/workflows/docker-wiki-update.yml
vendored
@@ -37,12 +37,12 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
repository: ${{ github.repository }}.wiki
|
repository: ${{ github.repository }}.wiki
|
||||||
path: wiki/
|
path: wiki_src/
|
||||||
|
|
||||||
- name: Update wiki 🏷
|
- name: Update wiki 🏷
|
||||||
run: >
|
run: >
|
||||||
python3 -m tagging.update_wiki
|
python3 -m wiki.update_wiki
|
||||||
--wiki-dir wiki/
|
--wiki-dir wiki_src/
|
||||||
--hist-lines-dir /tmp/jupyter/hist_lines/
|
--hist-lines-dir /tmp/jupyter/hist_lines/
|
||||||
--manifests-dir /tmp/jupyter/manifests/
|
--manifests-dir /tmp/jupyter/manifests/
|
||||||
--repository ${{ github.repository }}
|
--repository ${{ github.repository }}
|
||||||
|
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -30,6 +30,7 @@ on:
|
|||||||
- "!tagging/README.md"
|
- "!tagging/README.md"
|
||||||
- "tests/**"
|
- "tests/**"
|
||||||
- "!tests/README.md"
|
- "!tests/README.md"
|
||||||
|
- "wiki/**"
|
||||||
- "requirements-dev.txt"
|
- "requirements-dev.txt"
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -50,6 +51,7 @@ on:
|
|||||||
- "!tagging/README.md"
|
- "!tagging/README.md"
|
||||||
- "tests/**"
|
- "tests/**"
|
||||||
- "!tests/README.md"
|
- "!tests/README.md"
|
||||||
|
- "wiki/**"
|
||||||
- "requirements-dev.txt"
|
- "requirements-dev.txt"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
@@ -3,6 +3,12 @@
|
|||||||
This changelog only contains breaking and/or significant changes manually introduced to this repository (using Pull Requests).
|
This changelog only contains breaking and/or significant changes manually introduced to this repository (using Pull Requests).
|
||||||
All image manifests can be found in [the wiki](https://github.com/jupyter/docker-stacks/wiki).
|
All image manifests can be found in [the wiki](https://github.com/jupyter/docker-stacks/wiki).
|
||||||
|
|
||||||
|
## 2025-02-21
|
||||||
|
|
||||||
|
Affected: all images.
|
||||||
|
|
||||||
|
- **Non-breaking:**: Better tagging directory structure ([#2228](https://github.com/jupyter/docker-stacks/pull/2228)).
|
||||||
|
|
||||||
## 2025-02-18
|
## 2025-02-18
|
||||||
|
|
||||||
Affected: all images.
|
Affected: all images.
|
||||||
|
6
Makefile
6
Makefile
@@ -78,20 +78,20 @@ linkcheck-docs: ## check broken links
|
|||||||
|
|
||||||
hook/%: VARIANT?=default
|
hook/%: VARIANT?=default
|
||||||
hook/%: ## run post-build hooks for an image
|
hook/%: ## run post-build hooks for an image
|
||||||
python3 -m tagging.write_tags_file \
|
python3 -m tagging.apps.write_tags_file \
|
||||||
--registry "$(REGISTRY)" \
|
--registry "$(REGISTRY)" \
|
||||||
--owner "$(OWNER)" \
|
--owner "$(OWNER)" \
|
||||||
--short-image-name "$(notdir $@)" \
|
--short-image-name "$(notdir $@)" \
|
||||||
--variant "$(VARIANT)" \
|
--variant "$(VARIANT)" \
|
||||||
--tags-dir /tmp/jupyter/tags/
|
--tags-dir /tmp/jupyter/tags/
|
||||||
python3 -m tagging.write_manifest \
|
python3 -m tagging.apps.write_manifest \
|
||||||
--registry "$(REGISTRY)" \
|
--registry "$(REGISTRY)" \
|
||||||
--owner "$(OWNER)" \
|
--owner "$(OWNER)" \
|
||||||
--short-image-name "$(notdir $@)" \
|
--short-image-name "$(notdir $@)" \
|
||||||
--variant "$(VARIANT)" \
|
--variant "$(VARIANT)" \
|
||||||
--hist-lines-dir /tmp/jupyter/hist_lines/ \
|
--hist-lines-dir /tmp/jupyter/hist_lines/ \
|
||||||
--manifests-dir /tmp/jupyter/manifests/
|
--manifests-dir /tmp/jupyter/manifests/
|
||||||
python3 -m tagging.apply_tags \
|
python3 -m tagging.apps.apply_tags \
|
||||||
--registry "$(REGISTRY)" \
|
--registry "$(REGISTRY)" \
|
||||||
--owner "$(OWNER)" \
|
--owner "$(OWNER)" \
|
||||||
--short-image-name "$(notdir $@)" \
|
--short-image-name "$(notdir $@)" \
|
||||||
|
@@ -35,6 +35,7 @@ Table of Contents
|
|||||||
:caption: Maintainer Guide
|
:caption: Maintainer Guide
|
||||||
|
|
||||||
maintaining/new-images-and-packages-policy
|
maintaining/new-images-and-packages-policy
|
||||||
|
maintaining/tagging
|
||||||
maintaining/tasks
|
maintaining/tasks
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
|
94
docs/maintaining/tagging.md
Normal file
94
docs/maintaining/tagging.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Tagging and manifest creation
|
||||||
|
|
||||||
|
The main purpose of the source code in [the `tagging` folder](https://github.com/jupyter/docker-stacks/tree/main/tagging) is to properly write tag files and manifests for single-platform images,
|
||||||
|
apply these tags, and merge single-platform images into one multi-arch image.
|
||||||
|
|
||||||
|
## What is a tag and a manifest
|
||||||
|
|
||||||
|
A tag is a label attached to a Docker image identifying specific attributes or versions.
|
||||||
|
For example, an image `jupyter/base-notebook` with Python 3.10.5 will have a full image name `quay.io/jupyter/base-notebook:python-3.10.5`.
|
||||||
|
These tags are pushed to our [Quay.io registry](https://quay.io/organization/jupyter).
|
||||||
|
|
||||||
|
A manifest is a description of important image attributes written in Markdown format.
|
||||||
|
For example, we dump all `conda` packages with their versions into the manifest.
|
||||||
|
|
||||||
|
## Main principles
|
||||||
|
|
||||||
|
- All images are organized in a hierarchical tree.
|
||||||
|
More info on [image relationships](../using/selecting.md#image-relationships).
|
||||||
|
- Classes inherit from `TaggerInterface` and `ManifestInterface` to generate tags and manifest pieces by running commands in Docker containers.
|
||||||
|
- Tags and manifests are reevaluated for each image in the hierarchy since values may change between parent and child images.
|
||||||
|
- To tag an image and create its manifest, run `make hook/<somestack>` (e.g., `make hook/base-notebook`).
|
||||||
|
|
||||||
|
## Utils
|
||||||
|
|
||||||
|
### DockerRunner
|
||||||
|
|
||||||
|
`DockerRunner` is a helper class to easily run a docker container and execute commands inside this container:
|
||||||
|
|
||||||
|
```{literalinclude} tagging_examples/docker_runner.py
|
||||||
|
:language: py
|
||||||
|
```
|
||||||
|
|
||||||
|
### GitHelper
|
||||||
|
|
||||||
|
`GitHelper` methods are run in the current `git` repo and give the information about the last commit hash and commit message:
|
||||||
|
|
||||||
|
```{literalinclude} tagging_examples/git_helper.py
|
||||||
|
:language: py
|
||||||
|
```
|
||||||
|
|
||||||
|
The prefix of commit hash (namely, 12 letters) is used as an image tag to make it easy to inherit from a fixed version of a docker image.
|
||||||
|
|
||||||
|
## Taggers and Manifests
|
||||||
|
|
||||||
|
### Tagger
|
||||||
|
|
||||||
|
`Tagger` is a class that can be run inside a docker container to calculate some tag for an image.
|
||||||
|
|
||||||
|
All the taggers are inherited from `TaggerInterface`:
|
||||||
|
|
||||||
|
```{literalinclude} ../../tagging/taggers/tagger_interface.py
|
||||||
|
:language: py
|
||||||
|
```
|
||||||
|
|
||||||
|
So, the `tag_value(container)` method gets a docker container as an input and returns a tag.
|
||||||
|
|
||||||
|
`SHATagger` example:
|
||||||
|
|
||||||
|
```{literalinclude} ../../tagging/taggers/sha.py
|
||||||
|
:language: py
|
||||||
|
```
|
||||||
|
|
||||||
|
- `taggers/` subdirectory contains all the taggers.
|
||||||
|
- `apps/write_tags_file.py`, `apps/apply_tags.py`, and `apps/merge_tags.py` are Python executable used to write tags for an image, apply tags from a file, and create multi-arch images.
|
||||||
|
|
||||||
|
### Manifest
|
||||||
|
|
||||||
|
`ManifestHeader` is a build manifest header.
|
||||||
|
It contains the following sections: `Build timestamp`, `Docker image size`, and `Git commit` info.
|
||||||
|
|
||||||
|
All the other manifest classes are inherited from `ManifestInterface`:
|
||||||
|
|
||||||
|
```{literalinclude} ../../tagging/manifests/manifest_interface.py
|
||||||
|
:language: py
|
||||||
|
```
|
||||||
|
|
||||||
|
- The `markdown_piece(container)` method returns a piece of markdown file to be used as a part of the build manifest.
|
||||||
|
|
||||||
|
`AptPackagesManifest` example:
|
||||||
|
|
||||||
|
```{literalinclude} ../../tagging/manifests/apt_packages.py
|
||||||
|
:language: py
|
||||||
|
```
|
||||||
|
|
||||||
|
- `quoted_output(container, cmd)` simply runs the command inside a container using `DockerRunner.run_simple_command` and wraps it to triple quotes to create a valid markdown piece.
|
||||||
|
It also adds the command which was run to the markdown piece.
|
||||||
|
- `manifests/` subdirectory contains all the manifests.
|
||||||
|
- `apps/write_manifest.py` is a Python executable to create the build manifest and history line for an image.
|
||||||
|
|
||||||
|
### Images Hierarchy
|
||||||
|
|
||||||
|
All images' dependencies on each other and what taggers and manifest are applicable to them are defined in `hierarchy/images_hierarchy.py`.
|
||||||
|
|
||||||
|
`hierarchy/get_taggers_and_manifests.py` defines a function to get the taggers and manifests for a specific image.
|
4
docs/maintaining/tagging_examples/docker_runner.py
Normal file
4
docs/maintaining/tagging_examples/docker_runner.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from tagging.utils.docker_runner import DockerRunner
|
||||||
|
|
||||||
|
with DockerRunner("ubuntu") as container:
|
||||||
|
DockerRunner.run_simple_command(container, cmd="env", print_result=True)
|
4
docs/maintaining/tagging_examples/git_helper.py
Normal file
4
docs/maintaining/tagging_examples/git_helper.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from tagging.utils.git_helper import GitHelper
|
||||||
|
|
||||||
|
print("Git hash:", GitHelper.commit_hash())
|
||||||
|
print("Git message:", GitHelper.commit_message())
|
@@ -1,126 +1,3 @@
|
|||||||
# Docker stacks tagging and manifest creation
|
# Docker stacks tagging and manifest creation
|
||||||
|
|
||||||
The main purpose of the source code in this folder is to properly tag all the images and to update [build manifests](https://github.com/jupyter/docker-stacks/wiki).
|
Please, refer to the [tagging section of documentation](https://jupyter-docker-stacks.readthedocs.io/en/latest/maintaing/tagging.html) to see how tags and manifests are created.
|
||||||
These two processes are closely related, so the source code is widely reused.
|
|
||||||
|
|
||||||
A basic example of a tag is a `Python` version tag.
|
|
||||||
For example, an image `jupyter/base-notebook` with `python 3.10.5` will have a full image name `quay.io/jupyter/base-notebook:python-3.10.5`.
|
|
||||||
This tag (and all the other tags) are pushed to Quay.io.
|
|
||||||
|
|
||||||
Manifest is a description of some important part of the image in a `markdown`.
|
|
||||||
For example, we dump all the `conda` packages, including their versions.
|
|
||||||
|
|
||||||
## Main principles
|
|
||||||
|
|
||||||
- All the images are located in a hierarchical tree.
|
|
||||||
More info on [image relationships](../docs/using/selecting.md#image-relationships).
|
|
||||||
- We have `tagger` and `manifest` classes, which can be run inside docker containers to obtain tags and build manifest pieces.
|
|
||||||
- These classes are inherited from the parent image to all the child images.
|
|
||||||
- Because manifests and tags might change from parent to child, `taggers` and `manifests` are reevaluated on each image.
|
|
||||||
So, the values are not inherited.
|
|
||||||
- To tag an image and create a manifest, run `make hook/base-notebook` (or another image of your choice).
|
|
||||||
|
|
||||||
## Source code description
|
|
||||||
|
|
||||||
In this section, we will briefly describe the source code in this folder and give examples of how to use it.
|
|
||||||
|
|
||||||
### DockerRunner
|
|
||||||
|
|
||||||
`DockerRunner` is a helper class to easily run a docker container and execute commands inside this container:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from tagging.docker_runner import DockerRunner
|
|
||||||
|
|
||||||
with DockerRunner("ubuntu:22.04") as container:
|
|
||||||
DockerRunner.run_simple_command(container, cmd="env", print_result=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
### GitHelper
|
|
||||||
|
|
||||||
`GitHelper` methods are run in the current `git` repo and give the information about the last commit hash and commit message:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from tagging.git_helper import GitHelper
|
|
||||||
|
|
||||||
print("Git hash:", GitHelper.commit_hash())
|
|
||||||
print("Git message:", GitHelper.commit_message())
|
|
||||||
```
|
|
||||||
|
|
||||||
The prefix of commit hash (namely, 12 letters) is used as an image tag to make it easy to inherit from a fixed version of a docker image.
|
|
||||||
|
|
||||||
### Tagger
|
|
||||||
|
|
||||||
`Tagger` is a class that can be run inside a docker container to calculate some tag for an image.
|
|
||||||
|
|
||||||
All the taggers are inherited from `TaggerInterface`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class TaggerInterface:
|
|
||||||
"""Common interface for all taggers"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def tag_value(container) -> str:
|
|
||||||
raise NotImplementedError
|
|
||||||
```
|
|
||||||
|
|
||||||
So, the `tag_value(container)` method gets a docker container as an input and returns a tag.
|
|
||||||
|
|
||||||
`SHATagger` example:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from tagging.git_helper import GitHelper
|
|
||||||
from tagging.taggers import TaggerInterface
|
|
||||||
|
|
||||||
|
|
||||||
class SHATagger(TaggerInterface):
|
|
||||||
@staticmethod
|
|
||||||
def tag_value(container):
|
|
||||||
return GitHelper.commit_hash_tag()
|
|
||||||
```
|
|
||||||
|
|
||||||
- `taggers.py` contains all the taggers.
|
|
||||||
- `tag_image.py` is a Python executable that is used to tag the image.
|
|
||||||
|
|
||||||
### Manifest
|
|
||||||
|
|
||||||
`ManifestHeader` is a build manifest header.
|
|
||||||
It contains the following sections: `Build timestamp`, `Docker image size`, and `Git commit` info.
|
|
||||||
|
|
||||||
All the other manifest classes are inherited from `ManifestInterface`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ManifestInterface:
|
|
||||||
"""Common interface for all manifests"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def markdown_piece(container) -> str:
|
|
||||||
raise NotImplementedError
|
|
||||||
```
|
|
||||||
|
|
||||||
- The `markdown_piece(container)` method returns a piece of markdown file to be used as a part of the build manifest.
|
|
||||||
|
|
||||||
`AptPackagesManifest` example:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from tagging.manifests import ManifestInterface, quoted_output
|
|
||||||
|
|
||||||
|
|
||||||
class AptPackagesManifest(ManifestInterface):
|
|
||||||
@staticmethod
|
|
||||||
def markdown_piece(container) -> str:
|
|
||||||
return f"""\
|
|
||||||
## Apt Packages
|
|
||||||
|
|
||||||
{quoted_output(container, "apt list --installed")}"""
|
|
||||||
```
|
|
||||||
|
|
||||||
- `quoted_output` simply runs the command inside a container using `DockerRunner.run_simple_command` and wraps it to triple quotes to create a valid markdown piece.
|
|
||||||
It also adds the command which was run to the markdown piece.
|
|
||||||
- `manifests.py` contains all the manifests.
|
|
||||||
- `write_manifest.py` is a Python executable that is used to create the build manifest and history line for an image.
|
|
||||||
|
|
||||||
### Images Hierarchy
|
|
||||||
|
|
||||||
All images' dependencies on each other and what taggers and manifest they make use of are defined in `images_hierarchy.py`.
|
|
||||||
|
|
||||||
`get_taggers_and_manifests.py` defines a helper function to get the taggers and manifests for a specific image.
|
|
||||||
|
0
tagging/apps/__init__.py
Normal file
0
tagging/apps/__init__.py
Normal file
@@ -6,9 +6,9 @@ from pathlib import Path
|
|||||||
|
|
||||||
import plumbum
|
import plumbum
|
||||||
|
|
||||||
from tagging.common_arguments import common_arguments_parser
|
from tagging.apps.common_cli_arguments import common_arguments_parser
|
||||||
from tagging.get_platform import unify_aarch64
|
from tagging.utils.get_platform import unify_aarch64
|
||||||
from tagging.get_prefix import get_file_prefix_for_platform
|
from tagging.utils.get_prefix import get_file_prefix_for_platform
|
||||||
|
|
||||||
docker = plumbum.local["docker"]
|
docker = plumbum.local["docker"]
|
||||||
|
|
@@ -6,9 +6,9 @@ from pathlib import Path
|
|||||||
|
|
||||||
import plumbum
|
import plumbum
|
||||||
|
|
||||||
from tagging.common_arguments import common_arguments_parser
|
from tagging.apps.common_cli_arguments import common_arguments_parser
|
||||||
from tagging.get_platform import ALL_PLATFORMS
|
from tagging.utils.get_platform import ALL_PLATFORMS
|
||||||
from tagging.get_prefix import get_file_prefix_for_platform
|
from tagging.utils.get_prefix import get_file_prefix_for_platform
|
||||||
|
|
||||||
docker = plumbum.local["docker"]
|
docker = plumbum.local["docker"]
|
||||||
|
|
@@ -7,12 +7,15 @@ from pathlib import Path
|
|||||||
|
|
||||||
from docker.models.containers import Container
|
from docker.models.containers import Container
|
||||||
|
|
||||||
from tagging.common_arguments import common_arguments_parser
|
from tagging.apps.common_cli_arguments import common_arguments_parser
|
||||||
from tagging.docker_runner import DockerRunner
|
from tagging.hierarchy.get_taggers_and_manifests import (
|
||||||
from tagging.get_prefix import get_file_prefix, get_tag_prefix
|
get_taggers_and_manifests,
|
||||||
from tagging.get_taggers_and_manifests import get_taggers_and_manifests
|
)
|
||||||
from tagging.git_helper import GitHelper
|
from tagging.manifests.header import ManifestHeader
|
||||||
from tagging.manifests import ManifestHeader, ManifestInterface
|
from tagging.manifests.manifest_interface import ManifestInterface
|
||||||
|
from tagging.utils.docker_runner import DockerRunner
|
||||||
|
from tagging.utils.get_prefix import get_file_prefix, get_tag_prefix
|
||||||
|
from tagging.utils.git_helper import GitHelper
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
@@ -4,10 +4,12 @@
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from tagging.common_arguments import common_arguments_parser
|
from tagging.apps.common_cli_arguments import common_arguments_parser
|
||||||
from tagging.docker_runner import DockerRunner
|
from tagging.hierarchy.get_taggers_and_manifests import (
|
||||||
from tagging.get_prefix import get_file_prefix, get_tag_prefix
|
get_taggers_and_manifests,
|
||||||
from tagging.get_taggers_and_manifests import get_taggers_and_manifests
|
)
|
||||||
|
from tagging.utils.docker_runner import DockerRunner
|
||||||
|
from tagging.utils.get_prefix import get_file_prefix, get_tag_prefix
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
0
tagging/hierarchy/__init__.py
Normal file
0
tagging/hierarchy/__init__.py
Normal file
@@ -1,9 +1,9 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
from tagging.images_hierarchy import ALL_IMAGES
|
from tagging.hierarchy.images_hierarchy import ALL_IMAGES
|
||||||
from tagging.manifests import ManifestInterface
|
from tagging.manifests.manifest_interface import ManifestInterface
|
||||||
from tagging.taggers import TaggerInterface
|
from tagging.taggers.tagger_interface import TaggerInterface
|
||||||
|
|
||||||
|
|
||||||
def get_taggers_and_manifests(
|
def get_taggers_and_manifests(
|
@@ -2,16 +2,17 @@
|
|||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from tagging.manifests import (
|
from tagging.manifests.apt_packages import AptPackagesManifest
|
||||||
AptPackagesManifest,
|
from tagging.manifests.conda_environment import CondaEnvironmentManifest
|
||||||
CondaEnvironmentManifest,
|
from tagging.manifests.julia_packages import JuliaPackagesManifest
|
||||||
JuliaPackagesManifest,
|
from tagging.manifests.manifest_interface import ManifestInterface
|
||||||
ManifestInterface,
|
from tagging.manifests.r_packages import RPackagesManifest
|
||||||
RPackagesManifest,
|
from tagging.manifests.spark_info import SparkInfoManifest
|
||||||
SparkInfoManifest,
|
from tagging.taggers.date import DateTagger
|
||||||
)
|
from tagging.taggers.sha import SHATagger
|
||||||
from tagging.taggers import (
|
from tagging.taggers.tagger_interface import TaggerInterface
|
||||||
DateTagger,
|
from tagging.taggers.ubuntu_version import UbuntuVersionTagger
|
||||||
|
from tagging.taggers.versions import (
|
||||||
JavaVersionTagger,
|
JavaVersionTagger,
|
||||||
JuliaVersionTagger,
|
JuliaVersionTagger,
|
||||||
JupyterHubVersionTagger,
|
JupyterHubVersionTagger,
|
||||||
@@ -21,11 +22,8 @@ from tagging.taggers import (
|
|||||||
PythonVersionTagger,
|
PythonVersionTagger,
|
||||||
PytorchVersionTagger,
|
PytorchVersionTagger,
|
||||||
RVersionTagger,
|
RVersionTagger,
|
||||||
SHATagger,
|
|
||||||
SparkVersionTagger,
|
SparkVersionTagger,
|
||||||
TaggerInterface,
|
|
||||||
TensorflowVersionTagger,
|
TensorflowVersionTagger,
|
||||||
UbuntuVersionTagger,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@@ -1,126 +0,0 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
|
||||||
# Distributed under the terms of the Modified BSD License.
|
|
||||||
import plumbum
|
|
||||||
from docker.models.containers import Container
|
|
||||||
|
|
||||||
from tagging.docker_runner import DockerRunner
|
|
||||||
from tagging.git_helper import GitHelper
|
|
||||||
|
|
||||||
docker = plumbum.local["docker"]
|
|
||||||
|
|
||||||
|
|
||||||
def quoted_output(container: Container, cmd: str) -> str:
|
|
||||||
cmd_output = DockerRunner.run_simple_command(container, cmd, print_result=False)
|
|
||||||
# For example, `mamba info` adds redundant empty lines
|
|
||||||
cmd_output = cmd_output.strip("\n")
|
|
||||||
# For example, R packages list contains trailing backspaces
|
|
||||||
cmd_output = "\n".join(line.rstrip() for line in cmd_output.split("\n"))
|
|
||||||
|
|
||||||
assert cmd_output, f"Command `{cmd}` returned empty output"
|
|
||||||
|
|
||||||
return f"""\
|
|
||||||
`{cmd}`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
{cmd_output}
|
|
||||||
```"""
|
|
||||||
|
|
||||||
|
|
||||||
class ManifestHeader:
|
|
||||||
"""ManifestHeader doesn't fall under common interface, and we run it separately"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def create_header(
|
|
||||||
short_image_name: str, registry: str, owner: str, build_timestamp: str
|
|
||||||
) -> str:
|
|
||||||
commit_hash = GitHelper.commit_hash()
|
|
||||||
commit_hash_tag = GitHelper.commit_hash_tag()
|
|
||||||
commit_message = GitHelper.commit_message()
|
|
||||||
|
|
||||||
# Unfortunately, `docker images` doesn't work when specifying `docker.io` as registry
|
|
||||||
fixed_registry = registry + "/" if registry != "docker.io" else ""
|
|
||||||
|
|
||||||
image_size = docker[
|
|
||||||
"images",
|
|
||||||
f"{fixed_registry}{owner}/{short_image_name}:latest",
|
|
||||||
"--format",
|
|
||||||
"{{.Size}}",
|
|
||||||
]().rstrip()
|
|
||||||
|
|
||||||
return f"""\
|
|
||||||
# Build manifest for image: {short_image_name}:{commit_hash_tag}
|
|
||||||
|
|
||||||
## Build Info
|
|
||||||
|
|
||||||
- Build timestamp: {build_timestamp}
|
|
||||||
- Docker image: `{registry}/{owner}/{short_image_name}:{commit_hash_tag}`
|
|
||||||
- Docker image size: {image_size}
|
|
||||||
- Git commit SHA: [{commit_hash}](https://github.com/jupyter/docker-stacks/commit/{commit_hash})
|
|
||||||
- Git commit message:
|
|
||||||
|
|
||||||
```text
|
|
||||||
{commit_message}
|
|
||||||
```"""
|
|
||||||
|
|
||||||
|
|
||||||
class ManifestInterface:
|
|
||||||
"""Common interface for all manifests"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def markdown_piece(container: Container) -> str:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class CondaEnvironmentManifest(ManifestInterface):
|
|
||||||
@staticmethod
|
|
||||||
def markdown_piece(container: Container) -> str:
|
|
||||||
return f"""\
|
|
||||||
## Python Packages
|
|
||||||
|
|
||||||
{DockerRunner.run_simple_command(container, "python --version")}
|
|
||||||
|
|
||||||
{quoted_output(container, "conda info")}
|
|
||||||
|
|
||||||
{quoted_output(container, "mamba info")}
|
|
||||||
|
|
||||||
{quoted_output(container, "mamba list")}"""
|
|
||||||
|
|
||||||
|
|
||||||
class AptPackagesManifest(ManifestInterface):
|
|
||||||
@staticmethod
|
|
||||||
def markdown_piece(container: Container) -> str:
|
|
||||||
return f"""\
|
|
||||||
## Apt Packages
|
|
||||||
|
|
||||||
{quoted_output(container, "apt list --installed")}"""
|
|
||||||
|
|
||||||
|
|
||||||
class RPackagesManifest(ManifestInterface):
|
|
||||||
@staticmethod
|
|
||||||
def markdown_piece(container: Container) -> str:
|
|
||||||
return f"""\
|
|
||||||
## R Packages
|
|
||||||
|
|
||||||
{quoted_output(container, "R --version")}
|
|
||||||
|
|
||||||
{quoted_output(container, "R --silent -e 'installed.packages(.Library)[, c(1,3)]'")}"""
|
|
||||||
|
|
||||||
|
|
||||||
class JuliaPackagesManifest(ManifestInterface):
|
|
||||||
@staticmethod
|
|
||||||
def markdown_piece(container: Container) -> str:
|
|
||||||
return f"""\
|
|
||||||
## Julia Packages
|
|
||||||
|
|
||||||
{quoted_output(container, "julia -E 'using InteractiveUtils; versioninfo()'")}
|
|
||||||
|
|
||||||
{quoted_output(container, "julia -E 'import Pkg; Pkg.status()'")}"""
|
|
||||||
|
|
||||||
|
|
||||||
class SparkInfoManifest(ManifestInterface):
|
|
||||||
@staticmethod
|
|
||||||
def markdown_piece(container: Container) -> str:
|
|
||||||
return f"""\
|
|
||||||
## Apache Spark
|
|
||||||
|
|
||||||
{quoted_output(container, "/usr/local/spark/bin/spark-submit --version")}"""
|
|
0
tagging/manifests/__init__.py
Normal file
0
tagging/manifests/__init__.py
Normal file
15
tagging/manifests/apt_packages.py
Normal file
15
tagging/manifests/apt_packages.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Copyright (c) Jupyter Development Team.
|
||||||
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
from docker.models.containers import Container
|
||||||
|
|
||||||
|
from tagging.manifests.manifest_interface import ManifestInterface
|
||||||
|
from tagging.utils.quoted_output import quoted_output
|
||||||
|
|
||||||
|
|
||||||
|
class AptPackagesManifest(ManifestInterface):
|
||||||
|
@staticmethod
|
||||||
|
def markdown_piece(container: Container) -> str:
|
||||||
|
return f"""\
|
||||||
|
## Apt Packages
|
||||||
|
|
||||||
|
{quoted_output(container, "apt list --installed")}"""
|
22
tagging/manifests/conda_environment.py
Normal file
22
tagging/manifests/conda_environment.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Copyright (c) Jupyter Development Team.
|
||||||
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
from docker.models.containers import Container
|
||||||
|
|
||||||
|
from tagging.manifests.manifest_interface import ManifestInterface
|
||||||
|
from tagging.utils.docker_runner import DockerRunner
|
||||||
|
from tagging.utils.quoted_output import quoted_output
|
||||||
|
|
||||||
|
|
||||||
|
class CondaEnvironmentManifest(ManifestInterface):
|
||||||
|
@staticmethod
|
||||||
|
def markdown_piece(container: Container) -> str:
|
||||||
|
return f"""\
|
||||||
|
## Python Packages
|
||||||
|
|
||||||
|
{DockerRunner.run_simple_command(container, "python --version")}
|
||||||
|
|
||||||
|
{quoted_output(container, "conda info")}
|
||||||
|
|
||||||
|
{quoted_output(container, "mamba info")}
|
||||||
|
|
||||||
|
{quoted_output(container, "mamba list")}"""
|
44
tagging/manifests/header.py
Normal file
44
tagging/manifests/header.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Copyright (c) Jupyter Development Team.
|
||||||
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
import plumbum
|
||||||
|
|
||||||
|
from tagging.utils.git_helper import GitHelper
|
||||||
|
|
||||||
|
docker = plumbum.local["docker"]
|
||||||
|
|
||||||
|
|
||||||
|
class ManifestHeader:
|
||||||
|
"""ManifestHeader doesn't fall under common interface, and we run it separately"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_header(
|
||||||
|
short_image_name: str, registry: str, owner: str, build_timestamp: str
|
||||||
|
) -> str:
|
||||||
|
commit_hash = GitHelper.commit_hash()
|
||||||
|
commit_hash_tag = GitHelper.commit_hash_tag()
|
||||||
|
commit_message = GitHelper.commit_message()
|
||||||
|
|
||||||
|
# Unfortunately, `docker images` doesn't work when specifying `docker.io` as registry
|
||||||
|
fixed_registry = registry + "/" if registry != "docker.io" else ""
|
||||||
|
|
||||||
|
image_size = docker[
|
||||||
|
"images",
|
||||||
|
f"{fixed_registry}{owner}/{short_image_name}:latest",
|
||||||
|
"--format",
|
||||||
|
"{{.Size}}",
|
||||||
|
]().rstrip()
|
||||||
|
|
||||||
|
return f"""\
|
||||||
|
# Build manifest for image: {short_image_name}:{commit_hash_tag}
|
||||||
|
|
||||||
|
## Build Info
|
||||||
|
|
||||||
|
- Build timestamp: {build_timestamp}
|
||||||
|
- Docker image: `{registry}/{owner}/{short_image_name}:{commit_hash_tag}`
|
||||||
|
- Docker image size: {image_size}
|
||||||
|
- Git commit SHA: [{commit_hash}](https://github.com/jupyter/docker-stacks/commit/{commit_hash})
|
||||||
|
- Git commit message:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{commit_message}
|
||||||
|
```"""
|
17
tagging/manifests/julia_packages.py
Normal file
17
tagging/manifests/julia_packages.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Copyright (c) Jupyter Development Team.
|
||||||
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
from docker.models.containers import Container
|
||||||
|
|
||||||
|
from tagging.manifests.manifest_interface import ManifestInterface
|
||||||
|
from tagging.utils.quoted_output import quoted_output
|
||||||
|
|
||||||
|
|
||||||
|
class JuliaPackagesManifest(ManifestInterface):
|
||||||
|
@staticmethod
|
||||||
|
def markdown_piece(container: Container) -> str:
|
||||||
|
return f"""\
|
||||||
|
## Julia Packages
|
||||||
|
|
||||||
|
{quoted_output(container, "julia -E 'using InteractiveUtils; versioninfo()'")}
|
||||||
|
|
||||||
|
{quoted_output(container, "julia -E 'import Pkg; Pkg.status()'")}"""
|
9
tagging/manifests/manifest_interface.py
Normal file
9
tagging/manifests/manifest_interface.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from docker.models.containers import Container
|
||||||
|
|
||||||
|
|
||||||
|
class ManifestInterface:
|
||||||
|
"""Common interface for all manifests"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def markdown_piece(container: Container) -> str:
|
||||||
|
raise NotImplementedError
|
17
tagging/manifests/r_packages.py
Normal file
17
tagging/manifests/r_packages.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Copyright (c) Jupyter Development Team.
|
||||||
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
from docker.models.containers import Container
|
||||||
|
|
||||||
|
from tagging.manifests.manifest_interface import ManifestInterface
|
||||||
|
from tagging.utils.quoted_output import quoted_output
|
||||||
|
|
||||||
|
|
||||||
|
class RPackagesManifest(ManifestInterface):
|
||||||
|
@staticmethod
|
||||||
|
def markdown_piece(container: Container) -> str:
|
||||||
|
return f"""\
|
||||||
|
## R Packages
|
||||||
|
|
||||||
|
{quoted_output(container, "R --version")}
|
||||||
|
|
||||||
|
{quoted_output(container, "R --silent -e 'installed.packages(.Library)[, c(1,3)]'")}"""
|
15
tagging/manifests/spark_info.py
Normal file
15
tagging/manifests/spark_info.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Copyright (c) Jupyter Development Team.
|
||||||
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
from docker.models.containers import Container
|
||||||
|
|
||||||
|
from tagging.manifests.manifest_interface import ManifestInterface
|
||||||
|
from tagging.utils.quoted_output import quoted_output
|
||||||
|
|
||||||
|
|
||||||
|
class SparkInfoManifest(ManifestInterface):
|
||||||
|
@staticmethod
|
||||||
|
def markdown_piece(container: Container) -> str:
|
||||||
|
return f"""\
|
||||||
|
## Apache Spark
|
||||||
|
|
||||||
|
{quoted_output(container, "/usr/local/spark/bin/spark-submit --version")}"""
|
0
tagging/taggers/__init__.py
Normal file
0
tagging/taggers/__init__.py
Normal file
13
tagging/taggers/date.py
Normal file
13
tagging/taggers/date.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Copyright (c) Jupyter Development Team.
|
||||||
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from docker.models.containers import Container
|
||||||
|
|
||||||
|
from tagging.taggers.tagger_interface import TaggerInterface
|
||||||
|
|
||||||
|
|
||||||
|
class DateTagger(TaggerInterface):
|
||||||
|
@staticmethod
|
||||||
|
def tag_value(container: Container) -> str:
|
||||||
|
return datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d")
|
12
tagging/taggers/sha.py
Normal file
12
tagging/taggers/sha.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Copyright (c) Jupyter Development Team.
|
||||||
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
from docker.models.containers import Container
|
||||||
|
|
||||||
|
from tagging.taggers.tagger_interface import TaggerInterface
|
||||||
|
from tagging.utils.git_helper import GitHelper
|
||||||
|
|
||||||
|
|
||||||
|
class SHATagger(TaggerInterface):
|
||||||
|
@staticmethod
|
||||||
|
def tag_value(container: Container) -> str:
|
||||||
|
return GitHelper.commit_hash_tag()
|
9
tagging/taggers/tagger_interface.py
Normal file
9
tagging/taggers/tagger_interface.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from docker.models.containers import Container
|
||||||
|
|
||||||
|
|
||||||
|
class TaggerInterface:
|
||||||
|
"""Common interface for all taggers"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def tag_value(container: Container) -> str:
|
||||||
|
raise NotImplementedError
|
19
tagging/taggers/ubuntu_version.py
Normal file
19
tagging/taggers/ubuntu_version.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Copyright (c) Jupyter Development Team.
|
||||||
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
from docker.models.containers import Container
|
||||||
|
|
||||||
|
from tagging.taggers.tagger_interface import TaggerInterface
|
||||||
|
from tagging.utils.docker_runner import DockerRunner
|
||||||
|
|
||||||
|
|
||||||
|
class UbuntuVersionTagger(TaggerInterface):
|
||||||
|
@staticmethod
|
||||||
|
def tag_value(container: Container) -> str:
|
||||||
|
os_release = DockerRunner.run_simple_command(
|
||||||
|
container,
|
||||||
|
"cat /etc/os-release",
|
||||||
|
).split("\n")
|
||||||
|
for line in os_release:
|
||||||
|
if line.startswith("VERSION_ID"):
|
||||||
|
return "ubuntu-" + line.split("=")[1].strip('"')
|
||||||
|
raise RuntimeError(f"did not find ubuntu version in: {os_release}")
|
@@ -1,11 +1,9 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
import datetime
|
|
||||||
|
|
||||||
from docker.models.containers import Container
|
from docker.models.containers import Container
|
||||||
|
|
||||||
from tagging.docker_runner import DockerRunner
|
from tagging.taggers.tagger_interface import TaggerInterface
|
||||||
from tagging.git_helper import GitHelper
|
from tagging.utils.docker_runner import DockerRunner
|
||||||
|
|
||||||
|
|
||||||
def _get_program_version(container: Container, program: str) -> str:
|
def _get_program_version(container: Container, program: str) -> str:
|
||||||
@@ -25,39 +23,6 @@ def _get_pip_package_version(container: Container, package: str) -> str:
|
|||||||
return version_line[len(PIP_VERSION_PREFIX) :]
|
return version_line[len(PIP_VERSION_PREFIX) :]
|
||||||
|
|
||||||
|
|
||||||
class TaggerInterface:
|
|
||||||
"""Common interface for all taggers"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def tag_value(container: Container) -> str:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class SHATagger(TaggerInterface):
|
|
||||||
@staticmethod
|
|
||||||
def tag_value(container: Container) -> str:
|
|
||||||
return GitHelper.commit_hash_tag()
|
|
||||||
|
|
||||||
|
|
||||||
class DateTagger(TaggerInterface):
|
|
||||||
@staticmethod
|
|
||||||
def tag_value(container: Container) -> str:
|
|
||||||
return datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
|
|
||||||
class UbuntuVersionTagger(TaggerInterface):
|
|
||||||
@staticmethod
|
|
||||||
def tag_value(container: Container) -> str:
|
|
||||||
os_release = DockerRunner.run_simple_command(
|
|
||||||
container,
|
|
||||||
"cat /etc/os-release",
|
|
||||||
).split("\n")
|
|
||||||
for line in os_release:
|
|
||||||
if line.startswith("VERSION_ID"):
|
|
||||||
return "ubuntu-" + line.split("=")[1].strip('"')
|
|
||||||
raise RuntimeError(f"did not find ubuntu version in: {os_release}")
|
|
||||||
|
|
||||||
|
|
||||||
class PythonVersionTagger(TaggerInterface):
|
class PythonVersionTagger(TaggerInterface):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def tag_value(container: Container) -> str:
|
def tag_value(container: Container) -> str:
|
0
tagging/utils/__init__.py
Normal file
0
tagging/utils/__init__.py
Normal file
@@ -1,6 +1,6 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
from tagging.get_platform import get_platform
|
from tagging.utils.get_platform import get_platform
|
||||||
|
|
||||||
DEFAULT_VARIANT = "default"
|
DEFAULT_VARIANT = "default"
|
||||||
|
|
22
tagging/utils/quoted_output.py
Normal file
22
tagging/utils/quoted_output.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Copyright (c) Jupyter Development Team.
|
||||||
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
from docker.models.containers import Container
|
||||||
|
|
||||||
|
from tagging.utils.docker_runner import DockerRunner
|
||||||
|
|
||||||
|
|
||||||
|
def quoted_output(container: Container, cmd: str) -> str:
|
||||||
|
cmd_output = DockerRunner.run_simple_command(container, cmd, print_result=False)
|
||||||
|
# For example, `mamba info` adds redundant empty lines
|
||||||
|
cmd_output = cmd_output.strip("\n")
|
||||||
|
# For example, R packages list contains trailing backspaces
|
||||||
|
cmd_output = "\n".join(line.rstrip() for line in cmd_output.split("\n"))
|
||||||
|
|
||||||
|
assert cmd_output, f"Command `{cmd}` returned empty output"
|
||||||
|
|
||||||
|
return f"""\
|
||||||
|
`{cmd}`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{cmd_output}
|
||||||
|
```"""
|
0
wiki/__init__.py
Normal file
0
wiki/__init__.py
Normal file
Reference in New Issue
Block a user