diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 50ffe19..31c0db6 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -10,7 +10,8 @@ jobs: uses: actions/checkout@v2 - name: Install dotrun - run: pip3 install . + run: | + pip3 install . requests==2.31.0 - name: Install newest version of curl for --retry-all-errors support run: sudo snap install curl diff --git a/README.md b/README.md index 4a9b87f..3447063 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,13 @@ $ dotrun -s {script} # Run {script} but skip installing dependencies $ dotrun --env FOO=bar {script} # Run {script} with FOO environment variable $ dotrun -m "/path/to/mount":"localname" # Mount additional directory and run `dotrun` $ dotrun serve -m "/path/to/mount":"localname" # Mount additional directory and run `dotrun serve` +$ dotrun refresh image # Download the latest version of dotrun-image +$ dotrun --release {release-version} # Use a specific image tag for dotrun. Useful for switching versions +$ dotrun --image {image-name} # Use a specific image for dotrun. Useful for running dotrun off local images ``` +- Note that the `--image` and `--release` arguments cannot be used together, as `--image` will take precedence over `--release` + ## Installation ### Docker @@ -105,3 +110,34 @@ docker buildx create --name mybuilder docker buildx use mybuilder docker buildx build --push --platform linux/arm/v7,linux/arm64/v8,linux/amd64 --tag canonicalwebteam/dotrun-image:latest . ``` + +## Hacking + +You can install the package locally using either pip or poetry. + +### Using pip +```bash +pip3 install . requests==2.31.0 +``` + +### Using Poetry +```bash +pip install poetry +poetry install --no-interaction +``` + +To run dotrun off alternative base images such as local images, you can use the `--image` flag. +```bash +dotrun --image "localimage" exec echo hello +``` + +To run dotrun off alternative releases, besides the `:latest` release, you can use the `--release` flag. +```bash +dotrun --release "latest" serve +``` + +Note that before changing the base image you should run +```bash +dotrun clean +``` +to get rid of the old virtualenv. \ No newline at end of file diff --git a/dotrun.py b/dotrun.py index 0ad91e9..aa2517f 100644 --- a/dotrun.py +++ b/dotrun.py @@ -18,6 +18,8 @@ class Dotrun: + BASE_IMAGE_NAME = "canonicalwebteam/dotrun-image:latest" + def __init__(self): self.cwd = os.getcwd() self.project_name = slugify(os.path.basename(self.cwd)) @@ -29,10 +31,20 @@ def __init__(self): sys.platform.startswith("linux") and "microsoft" not in platform.platform() ) + self._get_docker_client() self._check_image_updates() self._create_cache_volume() + def _get_image_name(self, image_name): + """ + Return a fully qualified image name from a given image + name, defaulting to the :latest tag if none is provided. + """ + if ":" not in image_name: + return image_name + ":latest" + return image_name + def _get_docker_client(self): try: self.docker_client = docker.from_env() @@ -47,9 +59,7 @@ def _get_docker_client(self): def _check_image_updates(self): try: - self.docker_client.images.get( - "canonicalwebteam/dotrun-image:latest" - ) + self.docker_client.images.get(self.BASE_IMAGE_NAME) # Pull the image in the background print("Checking for dotrun image updates...") threading.Thread(target=self._pull_image) @@ -57,11 +67,21 @@ def _check_image_updates(self): print("Getting the dotrun image...") self._pull_image() - def _pull_image(self): + def _pull_image(self, image_name=None, exit_on_download_error=True): """Pull the dotrun image (if updated) from Docker Hub""" - self.docker_client.images.pull( - repository="canonicalwebteam/dotrun-image", tag="latest" - ) + if not image_name: + image_name = self.BASE_IMAGE_NAME + image_uri = self._get_image_name(image_name) + repository, tag = image_uri.split(":") + try: + self.docker_client.images.pull(repository=repository, tag=tag) + except (docker.errors.APIError, docker.errors.ImageNotFound) as e: + print(f"Unable to download image: {image_name}") + # Optionally quit if image download fails + if exit_on_download_error: + print(e) + sys.exit(1) + print(f"Attempting to use local image: {image_name}") def _create_cache_volume(self): try: @@ -71,7 +91,7 @@ def _create_cache_volume(self): # We need to fix the volume ownership self.docker_client.containers.run( - "canonicalwebteam/dotrun-image", + self.BASE_IMAGE_NAME, f"chown -R ubuntu:ubuntu {self.container_home}.cache", user="root", mounts=self._prepare_mounts([]), @@ -154,7 +174,9 @@ def get_mount(command, mounts): return get_mount(command, []) - def create_container(self, command): + def create_container(self, command, image_name=None): + if not image_name: + image_name = self.BASE_IMAGE_NAME ports = {self.project_port: self.project_port} # Run on the same network mode as the host network_mode = None @@ -175,7 +197,7 @@ def create_container(self, command): network_mode = "host" return self.docker_client.containers.create( - image="canonicalwebteam/dotrun-image", + image=image_name, name=name, hostname=name, mounts=self._prepare_mounts(command), @@ -189,6 +211,82 @@ def create_container(self, command): ) +def _extract_cli_command_arg(pattern, command_list): + """ + Return the value from the format + + --command + + and remove the command from the command list. + """ + pattern = re.compile(f"--{pattern} [^\s]+") # noqa + if match := re.search(pattern, " ".join(command_list)): + # Extract the value from the cli arg + command_arg = match.group(0) + try: + value = command_arg.split(" ")[1] + except IndexError: + print(f"Value for arg {command_arg} not supplied.") + sys.exit(1) + + # Remove the command from command list + new_command_list = ( + " ".join(command_list).replace(command_arg, "").replace(" ", " ") + ) + return value, new_command_list.split(" ") + return None + + +def _handle_image_cli_param(dotrun, command_list): + """ + Handle the --image cli parameter, if supplied, and return the + created container and the modified command list. + """ + if result := _extract_cli_command_arg("image", command_list): + image_name, commands = result + # Sanitize the image name + image_name = dotrun._get_image_name(image_name) + return ( + _start_container_with_image(dotrun, image_name, commands), + commands, + ) + + +def _handle_release_cli_param(dotrun, command_list): + """ + Handle the --release cli parameter, if supplied, and return the + created container and the modified command list. + """ + if result := _extract_cli_command_arg("release", command_list): + image_tag, commands = result + # Get the release image uri + image_name, _ = dotrun.BASE_IMAGE_NAME.split(":") + image_tag = f"{image_name}:{image_tag}" + return ( + _start_container_with_image(dotrun, image_tag, commands), + commands, + ) + + +def _start_container_with_image(dotrun, image_uri, command_list): + """ + Utility function to start dotrun using a specified + image. + """ + + print(f"Using image: {image_uri}") + + # Download the image + dotrun._pull_image(image_uri, exit_on_download_error=True) + + # Start dotrun from the supplied base image + try: + return dotrun.create_container(command_list, image_name=image_uri) + except docker.errors.ImageNotFound as e: + print(e) + sys.exit(1) + + def cli(): dotrun = Dotrun() command = ["dotrun"] @@ -196,8 +294,20 @@ def cli(): if command[-1] == "version": print(f"dotrun v{__version__}") + sys.exit(1) - container = dotrun.create_container(command) + if command[-1] == "refresh": + dotrun._pull_image() + print("Latest image pulled successfully.") + sys.exit(1) + + # Options for starting the container using different base images + if result := _handle_image_cli_param(dotrun, command): + container, command = result + elif result := _handle_release_cli_param(dotrun, command): + container, command = result + else: + container = dotrun.create_container(command) # 1 by default status_code = 1 @@ -210,3 +320,7 @@ def cli(): container.remove() return status_code + + +if __name__ == "__main__": + cli() diff --git a/poetry.lock b/poetry.lock index 102c045..6cd71cd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,9 @@ -# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "certifi" version = "2022.12.7" description = "Python package for providing Mozilla's CA Bundle." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -16,7 +15,6 @@ files = [ name = "charset-normalizer" version = "2.0.12" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "main" optional = false python-versions = ">=3.5.0" files = [ @@ -29,30 +27,29 @@ unicode-backport = ["unicodedata2"] [[package]] name = "docker" -version = "5.0.3" +version = "6.1.3" description = "A Python library for the Docker Engine API." -category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "docker-5.0.3-py2.py3-none-any.whl", hash = "sha256:7a79bb439e3df59d0a72621775d600bc8bc8b422d285824cb37103eab91d1ce0"}, - {file = "docker-5.0.3.tar.gz", hash = "sha256:d916a26b62970e7c2f554110ed6af04c7ccff8e9f81ad17d0d40c75637e227fb"}, + {file = "docker-6.1.3-py3-none-any.whl", hash = "sha256:aecd2277b8bf8e506e484f6ab7aec39abe0038e29fa4a6d3ba86c3fe01844ed9"}, + {file = "docker-6.1.3.tar.gz", hash = "sha256:aa6d17830045ba5ef0168d5eaa34d37beeb113948c413affe1d5991fc11f9a20"}, ] [package.dependencies] -pywin32 = {version = "227", markers = "sys_platform == \"win32\""} -requests = ">=2.14.2,<2.18.0 || >2.18.0" +packaging = ">=14.0" +pywin32 = {version = ">=304", markers = "sys_platform == \"win32\""} +requests = ">=2.26.0" +urllib3 = ">=1.26.0" websocket-client = ">=0.32.0" [package.extras] -ssh = ["paramiko (>=2.4.2)"] -tls = ["cryptography (>=3.4.7)", "idna (>=2.0.0)", "pyOpenSSL (>=17.5.0)"] +ssh = ["paramiko (>=2.4.3)"] [[package]] name = "dockerpty" version = "0.4.1" description = "Python library to use the pseudo-tty of a docker container" -category = "main" optional = false python-versions = "*" files = [ @@ -66,7 +63,6 @@ six = ">=1.3.0" name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -74,11 +70,21 @@ files = [ {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + [[package]] name = "python-dotenv" version = "0.20.0" description = "Read key-value pairs from a .env file and set them as environment variables" -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -93,7 +99,6 @@ cli = ["click (>=5.0)"] name = "python-slugify" version = "6.1.2" description = "A Python slugify application that also handles Unicode" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ @@ -109,31 +114,31 @@ unidecode = ["Unidecode (>=1.1.1)"] [[package]] name = "pywin32" -version = "227" +version = "306" description = "Python for Window Extensions" -category = "main" optional = false python-versions = "*" files = [ - {file = "pywin32-227-cp27-cp27m-win32.whl", hash = "sha256:371fcc39416d736401f0274dd64c2302728c9e034808e37381b5e1b22be4a6b0"}, - {file = "pywin32-227-cp27-cp27m-win_amd64.whl", hash = "sha256:4cdad3e84191194ea6d0dd1b1b9bdda574ff563177d2adf2b4efec2a244fa116"}, - {file = "pywin32-227-cp35-cp35m-win32.whl", hash = "sha256:f4c5be1a293bae0076d93c88f37ee8da68136744588bc5e2be2f299a34ceb7aa"}, - {file = "pywin32-227-cp35-cp35m-win_amd64.whl", hash = "sha256:a929a4af626e530383a579431b70e512e736e9588106715215bf685a3ea508d4"}, - {file = "pywin32-227-cp36-cp36m-win32.whl", hash = "sha256:300a2db938e98c3e7e2093e4491439e62287d0d493fe07cce110db070b54c0be"}, - {file = "pywin32-227-cp36-cp36m-win_amd64.whl", hash = "sha256:9b31e009564fb95db160f154e2aa195ed66bcc4c058ed72850d047141b36f3a2"}, - {file = "pywin32-227-cp37-cp37m-win32.whl", hash = "sha256:47a3c7551376a865dd8d095a98deba954a98f326c6fe3c72d8726ca6e6b15507"}, - {file = "pywin32-227-cp37-cp37m-win_amd64.whl", hash = "sha256:31f88a89139cb2adc40f8f0e65ee56a8c585f629974f9e07622ba80199057511"}, - {file = "pywin32-227-cp38-cp38-win32.whl", hash = "sha256:7f18199fbf29ca99dff10e1f09451582ae9e372a892ff03a28528a24d55875bc"}, - {file = "pywin32-227-cp38-cp38-win_amd64.whl", hash = "sha256:7c1ae32c489dc012930787f06244426f8356e129184a02c25aef163917ce158e"}, - {file = "pywin32-227-cp39-cp39-win32.whl", hash = "sha256:c054c52ba46e7eb6b7d7dfae4dbd987a1bb48ee86debe3f245a2884ece46e295"}, - {file = "pywin32-227-cp39-cp39-win_amd64.whl", hash = "sha256:f27cec5e7f588c3d1051651830ecc00294f90728d19c3bf6916e6dba93ea357c"}, + {file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"}, + {file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"}, + {file = "pywin32-306-cp311-cp311-win32.whl", hash = "sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407"}, + {file = "pywin32-306-cp311-cp311-win_amd64.whl", hash = "sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e"}, + {file = "pywin32-306-cp311-cp311-win_arm64.whl", hash = "sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a"}, + {file = "pywin32-306-cp312-cp312-win32.whl", hash = "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b"}, + {file = "pywin32-306-cp312-cp312-win_amd64.whl", hash = "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e"}, + {file = "pywin32-306-cp312-cp312-win_arm64.whl", hash = "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040"}, + {file = "pywin32-306-cp37-cp37m-win32.whl", hash = "sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65"}, + {file = "pywin32-306-cp37-cp37m-win_amd64.whl", hash = "sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36"}, + {file = "pywin32-306-cp38-cp38-win32.whl", hash = "sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a"}, + {file = "pywin32-306-cp38-cp38-win_amd64.whl", hash = "sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0"}, + {file = "pywin32-306-cp39-cp39-win32.whl", hash = "sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802"}, + {file = "pywin32-306-cp39-cp39-win_amd64.whl", hash = "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4"}, ] [[package]] name = "requests" version = "2.27.1" description = "Python HTTP for Humans." -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ @@ -155,7 +160,6 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<5)"] name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -167,7 +171,6 @@ files = [ name = "text-unidecode" version = "1.3" description = "The most basic Text::Unidecode port" -category = "main" optional = false python-versions = "*" files = [ @@ -179,7 +182,6 @@ files = [ name = "urllib3" version = "1.26.15" description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ @@ -196,7 +198,6 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] name = "websocket-client" version = "1.3.1" description = "WebSocket client for Python with low level API options" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -211,5 +212,5 @@ test = ["websockets"] [metadata] lock-version = "2.0" -python-versions = '^3.6' -content-hash = "5ee65bae18836cf8e5ee1fad3b0ea51b2b13e48641695e16ae4361a9119ca810" +python-versions = "^3.10" +content-hash = "34169362595085367730a23528a5c5c2f7da09c7f6ab9e9ea421f5d44d540b65" diff --git a/pyproject.toml b/pyproject.toml index 7a1c427..ac70ced 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = 'dotrun' -version = '2.2.0' +version = '2.3.0' description = 'A tool for developing Node.js and Python projects' authors = ['Canonical Web Team '] license = 'LGPL-3.0' @@ -10,7 +10,7 @@ readme = 'README.md' dotrun = "dotrun:cli" [tool.poetry.dependencies] -python = '^3.6' +python = '^3.10' python-dotenv = '0.20.0' python-slugify = '6.1.2' docker = '6.1.3'