├── .dockerignore ├── .github └── main.workflow ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── cnab ├── __init__.py ├── cnab.py ├── invocation_image.py ├── test_cnab.py ├── test_invocation_image.py ├── test_types.py ├── test_util.py ├── types.py └── util.py ├── examples └── testing │ ├── Dockerfile │ ├── Pipfile │ ├── Pipfile.lock │ ├── README.md │ ├── helloworld │ ├── bundle.json │ └── cnab │ │ └── app │ │ └── run │ ├── pytest.ini │ └── test_validate_cnab.py ├── fixtures ├── hellohelm │ └── bundle.json ├── helloworld │ └── bundle.json ├── invalidinvocationimage │ └── cnab │ │ ├── INVALID │ │ └── README.txt └── invocationimage │ └── cnab │ ├── LICENSE │ ├── README.md │ └── app │ └── run ├── poetry.lock ├── pyproject.toml └── pytest.ini /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | dist 3 | .egg/ 4 | *.egg-info 5 | **/*.pyc 6 | **/*.swp 7 | **/__pycache__ 8 | 9 | -------------------------------------------------------------------------------- /.github/main.workflow: -------------------------------------------------------------------------------- 1 | workflow "Quality" { 2 | on = "push" 3 | resolves = ["test"] 4 | } 5 | 6 | action "test" { 7 | uses = "actions/docker/cli@master" 8 | args = "build --target test ." 9 | } 10 | 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | scratch 2 | dist 3 | .eggs 4 | __pycache__ 5 | .pyc 6 | .swp 7 | .mypy_cache 8 | .coverage 9 | *.egg-info 10 | schemas 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 AS poetry 2 | ENV PATH="/root/.poetry/bin:${PATH}" 3 | RUN curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py | python 4 | 5 | 6 | FROM poetry AS base 7 | RUN mkdir /app 8 | WORKDIR /app 9 | COPY pyproject.* . 10 | RUN poetry install -n --extras=docker 11 | COPY . /app 12 | 13 | 14 | FROM base AS Test 15 | RUN poetry run pytest -m "not docker" 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | pycnab 2 | 3 | Copyright (C) 2018 Gareth Rushgrove 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | https://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python CNAB Library 2 | 3 | _Work-in-progress_ library for working with [CNAB](https://cnab.io/) in Python. 4 | 5 | There are probably three main areas of interest for a CNAB client: 6 | 7 | 1. Handling the `bundle.json` format ([101](https://github.com/deislabs/cnab-spec/blob/master/101-bundle-json.md)) 8 | 2. Building invocation images ([102](https://github.com/deislabs/cnab-spec/blob/master/102-invocation-image.md)) 9 | 3. Running actions against a CNAB ([103](https://github.com/deislabs/cnab-spec/blob/master/103-bundle-runtime.md)) 10 | 11 | Claims and Signing are optional but will be worked on once the above are stable. 12 | 13 | 14 | ## Installation 15 | 16 | The module is published on [PyPi](https://pypi.org/project/cnab/) and can be installed from there. 17 | 18 | ```bash 19 | pip install cnab 20 | ``` 21 | 22 | 23 | ## Parsing `bundle.json` 24 | 25 | Nothing too fancy here, the `Bundle` class has a `from_dict` static method which 26 | builds a full `Bundle` object. 27 | 28 | ```python 29 | import json 30 | from cnab import Bundle 31 | 32 | with open("bundle.json") as f: 33 | data = json.load(f) 34 | 35 | bundle = Bundle.from_dict(data) 36 | ``` 37 | 38 | This could for example be used for validation purposes, or for building user interfaces for `bundle.json` files. 39 | 40 | 41 | ## Describing `bundle.json` in Python 42 | 43 | You can also describe the `bundle.json` file in Python. This will correctly validate the 44 | structure based on the current specification and would allow for building a custom DSL or other 45 | user interface for generating `bundle.json` files. 46 | 47 | ```python 48 | from cnab import Bundle, InvocationImage 49 | 50 | bundle = Bundle( 51 | name="hello", 52 | version="0.1.0", 53 | invocation_images=[ 54 | InvocationImage( 55 | image_type="docker", 56 | image="technosophos/helloworld:0.1.0", 57 | digest="sha256:aaaaaaa...", 58 | ) 59 | ], 60 | ) 61 | 62 | print(bundle.to_json()) 63 | ``` 64 | 65 | ## Running CNABs 66 | 67 | The module supports running actions on a CNAB, using the `docker` driver. 68 | 69 | ```python 70 | from cnab import CNAB 71 | 72 | # The first argument can be a path to a bundle.json file, a dictionary 73 | # or a full `Bundle` object 74 | app = CNAB("fixtures/helloworld/bundle.json") 75 | 76 | # list available actions 77 | print(app.actions) 78 | 79 | # list available parameters 80 | print(app.parameter) 81 | 82 | # run the install action 83 | print(app.run("install")) 84 | 85 | # run the install action specifying a parameters 86 | print(app.run("install", parameters={"port": 9090})) 87 | 88 | # Many applications will require credentials 89 | app = CNAB("fixtures/hellohelm/bundle.json") 90 | 91 | # list required credentials 92 | print(app.credentials) 93 | 94 | # Here we pass the value for the required credential 95 | # in this case by reading the existing configuration from disk 96 | with open("/home/garethr/.kube/config") as f: 97 | print(app.run("status", credentials={"kubeconfig": f.read()})) 98 | ``` 99 | 100 | Note that error handling for this is very work-in-progress. 101 | 102 | 103 | ## Working with invocation images 104 | 105 | `pycnab` also has a class for working with invocation images. 106 | 107 | ```python 108 | from cnab import CNABDirectory 109 | 110 | directory = CNABDirectory("fixtures/invocationimage") 111 | 112 | # Check whether the directory is valid 113 | # Raises `InvalidCNABDirectory` exception if invalid 114 | directory.valid() 115 | 116 | # Returns the text of the associated README file if present 117 | directory.readme() 118 | 119 | # Returns the text of the associated LICENSE file if present 120 | directory.license() 121 | ``` 122 | 123 | 124 | ## Thanks 125 | 126 | Thanks to [QuickType](https://quicktype.io/) for bootstrapping the creation of the Python code for manipulating `bundle.json` based on the current JSON Schema. 127 | 128 | -------------------------------------------------------------------------------- /cnab/__init__.py: -------------------------------------------------------------------------------- 1 | from cnab.types import ( 2 | Action, 3 | Credential, 4 | ImagePlatform, 5 | Ref, 6 | Image, 7 | InvocationImage, 8 | Maintainer, 9 | Destination, 10 | Metadata, 11 | Parameter, 12 | Bundle, 13 | ) 14 | from cnab.cnab import CNAB 15 | from cnab.invocation_image import CNABDirectory 16 | -------------------------------------------------------------------------------- /cnab/cnab.py: -------------------------------------------------------------------------------- 1 | import json 2 | import tempfile 3 | from typing import Union 4 | 5 | from cnab.types import Bundle, Action 6 | from cnab.util import extract_docker_images 7 | 8 | 9 | class CNAB: 10 | bundle: Bundle 11 | name: str 12 | 13 | def __init__(self, bundle: Union[Bundle, dict, str], name: str = None): 14 | if isinstance(bundle, Bundle): 15 | self.bundle = bundle 16 | elif isinstance(bundle, dict): 17 | self.bundle = Bundle.from_dict(bundle) 18 | elif isinstance(bundle, str): 19 | with open(bundle) as f: 20 | data = json.load(f) 21 | self.bundle = Bundle.from_dict(data) 22 | else: 23 | raise TypeError 24 | 25 | self.name = name or self.bundle.name 26 | 27 | def run(self, action: str, credentials: dict = {}, parameters: dict = {}): 28 | import docker # type: ignore 29 | 30 | # check if action is supported 31 | assert action in self.actions 32 | 33 | client = docker.from_env() 34 | docker_images = extract_docker_images(self.bundle.invocation_images) 35 | assert len(docker_images) == 1 36 | 37 | # check if parameters passed in are in bundle parameters 38 | errors = [] 39 | for key in parameters: 40 | if key not in self.bundle.parameters: 41 | errors.append(f"Invalid parameter provided: {key}") 42 | assert len(errors) == 0 43 | 44 | # check if required parameters have been passed in 45 | required = [] 46 | for param in self.bundle.parameters: 47 | parameter = self.bundle.parameters[param] 48 | if parameter.required: 49 | required.append(param) 50 | 51 | for param in required: 52 | assert param in parameters 53 | 54 | # validate passed in params 55 | for param in parameters: 56 | parameter = self.bundle.parameters[param] 57 | if parameter.allowed_values: 58 | assert param in parameter.allowed_values 59 | if isinstance(param, int): 60 | if parameter.max_value: 61 | assert param <= parameter.max_value 62 | if parameter.min_value: 63 | assert param >= parameter.min_value 64 | elif isinstance(param, str): 65 | if parameter.max_length: 66 | assert len(param) <= parameter.max_length 67 | if parameter.min_length: 68 | assert len(param) >= parameter.min_length 69 | 70 | env = { 71 | "CNAB_INSTALLATION_NAME": self.name, 72 | "CNAB_BUNDLE_NAME": self.bundle.name, 73 | "CNAB_ACTION": action, 74 | } 75 | 76 | # build environment hash 77 | for param in self.bundle.parameters: 78 | parameter = self.bundle.parameters[param] 79 | if parameter.destination: 80 | if parameter.destination.env: 81 | key = parameter.destination.env 82 | value = ( 83 | parameters[param] 84 | if param in parameters 85 | else parameter.default_value 86 | ) 87 | env[key] = value 88 | if parameter.destination.path: 89 | # not yet supported 90 | pass 91 | 92 | mounts = [] 93 | if self.bundle.credentials: 94 | for name in self.bundle.credentials: 95 | # check credential has been provided 96 | assert name in credentials 97 | 98 | credential = self.bundle.credentials[name] 99 | if credential.env: 100 | # discussing behavour in https://github.com/deislabs/cnab-spec/issues/69 101 | assert credential.env[:5] != "CNAB_" 102 | env[credential.env] = credentials[name] 103 | 104 | if credential.path: 105 | tmp = tempfile.NamedTemporaryFile(mode="w+", delete=True) 106 | tmp.write(credentials[name]) 107 | tmp.flush() 108 | mounts.append( 109 | docker.types.Mount( 110 | target=credential.path, 111 | source=tmp.name, 112 | read_only=True, 113 | type="bind", 114 | ) 115 | ) 116 | 117 | # Mount image maps for runtime usage 118 | tmp = tempfile.NamedTemporaryFile(mode="w+", delete=True) 119 | tmp.write(json.dumps(self.bundle.images)) 120 | tmp.flush() 121 | mounts.append( 122 | docker.types.Mount( 123 | target="/cnab/app/image-map.json", 124 | source=tmp.name, 125 | read_only=True, 126 | type="bind", 127 | ) 128 | ) 129 | 130 | return client.containers.run( 131 | docker_images[0].image, 132 | "/cnab/app/run", 133 | auto_remove=False, 134 | remove=True, 135 | environment=env, 136 | mounts=mounts, 137 | ) 138 | 139 | @property 140 | def actions(self) -> dict: 141 | actions = { 142 | "install": Action(modifies=True), 143 | "uninstall": Action(modifies=True), 144 | "upgrade": Action(modifies=True), 145 | } 146 | if self.bundle.actions: 147 | actions.update(self.bundle.actions) 148 | return actions 149 | 150 | @property 151 | def parameters(self) -> dict: 152 | return self.bundle.parameters 153 | 154 | @property 155 | def credentials(self) -> dict: 156 | return self.bundle.credentials 157 | -------------------------------------------------------------------------------- /cnab/invocation_image.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Union, List 3 | 4 | 5 | class InvalidCNABDirectoryError(Exception): 6 | pass 7 | 8 | 9 | class CNABDirectory(object): 10 | path: str 11 | 12 | def __init__(self, path: str): 13 | self.path = path 14 | 15 | def has_cnab_directory(self) -> bool: 16 | cnab = os.path.join(self.path, "cnab") 17 | return os.path.isdir(cnab) 18 | 19 | def has_app_directory(self) -> bool: 20 | app = os.path.join(self.path, "cnab", "app") 21 | return os.path.isdir(app) 22 | 23 | def has_no_misc_files_in_cnab_dir(self) -> bool: 24 | cnab = os.path.join(self.path, "cnab") 25 | disallowed_dirs: List[str] = [] 26 | disallowed_files: List[str] = [] 27 | for root, dirs, files in os.walk(cnab): 28 | disallowed_dirs = [x for x in dirs if x not in ["app", "build"]] 29 | disallowed_files = [ 30 | x for x in files if x not in ["LICENSE", "README.md", "README.txt"] 31 | ] 32 | break 33 | if disallowed_dirs or disallowed_files: 34 | return False 35 | else: 36 | return True 37 | 38 | def has_run(self) -> bool: 39 | run = os.path.join(self.path, "cnab", "app", "run") 40 | return os.path.isfile(run) 41 | 42 | def has_executable_run(self) -> bool: 43 | run = os.path.join(self.path, "cnab", "app", "run") 44 | return os.access(run, os.X_OK) 45 | 46 | def readme(self) -> Union[bool, str]: 47 | readme = os.path.join(self.path, "cnab", "README") 48 | txt = readme + ".txt" 49 | md = readme + ".md" 50 | if os.path.isfile(txt): 51 | with open(txt, "r") as content: 52 | return content.read() 53 | elif os.path.isfile(md): 54 | with open(md, "r") as content: 55 | return content.read() 56 | else: 57 | return False 58 | 59 | def license(self) -> Union[bool, str]: 60 | license = os.path.join(self.path, "cnab", "LICENSE") 61 | if os.path.isfile(license): 62 | with open(license, "r") as content: 63 | return content.read() 64 | else: 65 | return False 66 | 67 | def valid(self) -> bool: 68 | errors = [] 69 | if not self.has_executable_run(): 70 | errors.append("Run entrypoint is not executable") 71 | if not self.has_run(): 72 | errors.append("Missing a run entrypoint") 73 | if not self.has_app_directory(): 74 | errors.append("Missing the app directory") 75 | if not self.has_cnab_directory(): 76 | errors.append("Missing the cnab directory") 77 | if not self.has_no_misc_files_in_cnab_dir(): 78 | errors.append("Has additional files in the cnab directory") 79 | 80 | if len(errors) == 0: 81 | return True 82 | else: 83 | raise InvalidCNABDirectoryError(errors) 84 | -------------------------------------------------------------------------------- /cnab/test_cnab.py: -------------------------------------------------------------------------------- 1 | import pytest # type: ignore 2 | 3 | from cnab import CNAB, Bundle, InvocationImage 4 | 5 | 6 | class HelloWorld(object): 7 | @pytest.fixture 8 | def app(self): 9 | return CNAB("fixtures/helloworld/bundle.json") 10 | 11 | 12 | class TestHelloWorld(HelloWorld): 13 | @pytest.mark.parametrize("action", ["install", "upgrade", "uninstall"]) 14 | def test_actions_present(self, app, action): 15 | assert action in app.actions 16 | 17 | def test_credentials_empty(self, app): 18 | assert app.credentials == None 19 | 20 | def test_port_parameter_present(self, app): 21 | assert "port" in app.parameters 22 | 23 | def test_port_details(self, app): 24 | assert app.parameters["port"].type == "int" 25 | assert app.parameters["port"].default_value == 8080 26 | 27 | def test_app_name(self, app): 28 | assert app.name == "helloworld" 29 | 30 | def test_version(self, app): 31 | assert app.bundle.version == "0.1.1" 32 | 33 | def test_app_bundle(self, app): 34 | assert isinstance(app.bundle, Bundle) 35 | 36 | def test_invocation_images(self, app): 37 | assert len(app.bundle.invocation_images) == 1 38 | 39 | 40 | @pytest.mark.docker 41 | class TestIntegrationHelloWorld(HelloWorld): 42 | @pytest.fixture 43 | def install(self, app): 44 | return str(app.run("install", parameters={"port": 9090})) 45 | 46 | def test_run(self, install): 47 | assert "install" in install 48 | 49 | 50 | class TestHelloHelm(object): 51 | @pytest.fixture 52 | def app(self): 53 | return CNAB("fixtures/hellohelm/bundle.json") 54 | 55 | @pytest.mark.parametrize("action", ["install", "upgrade", "uninstall", "status"]) 56 | def test_actions_present(self, app, action): 57 | assert action in app.actions 58 | 59 | 60 | def test_app_from_dict(): 61 | bundle = { 62 | "name": "helloworld", 63 | "version": "0.1.1", 64 | "invocationImages": [ 65 | {"imageType": "docker", "image": "cnab/helloworld:latest"} 66 | ], 67 | "images": {}, 68 | "parameters": { 69 | "port": { 70 | "defaultValue": 8080, 71 | "type": "int", 72 | "destination": {"env": "PORT"}, 73 | "metadata": {"descriptiob": "the public port"}, 74 | } 75 | }, 76 | "maintainers": [ 77 | {"email": "test@example.com", "name": "test", "url": "example.com"} 78 | ], 79 | } 80 | assert CNAB(bundle) 81 | 82 | 83 | def test_app_from_bundle(): 84 | bundle = Bundle( 85 | name="sample", 86 | version="0.1.0", 87 | invocation_images=[ 88 | InvocationImage(image_type="docker", image="garethr/helloworld:0.1.0") 89 | ], 90 | ) 91 | assert CNAB(bundle) 92 | 93 | 94 | def test_app_from_invalid_input(): 95 | with pytest.raises(TypeError): 96 | CNAB(1) 97 | -------------------------------------------------------------------------------- /cnab/test_invocation_image.py: -------------------------------------------------------------------------------- 1 | import pytest # type: ignore 2 | 3 | from cnab import CNABDirectory 4 | from cnab.invocation_image import InvalidCNABDirectoryError 5 | 6 | 7 | class SampleCNAB(object): 8 | @pytest.fixture 9 | def directory(self): 10 | return CNABDirectory("fixtures/invocationimage") 11 | 12 | 13 | class TestCNABDirectory(SampleCNAB): 14 | def test_has_app_dir(self, directory): 15 | assert directory.has_app_directory() 16 | 17 | def test_has_cnab_dir(self, directory): 18 | assert directory.has_cnab_directory() 19 | 20 | def test_has_readme(self, directory): 21 | assert isinstance(directory.readme(), str) 22 | 23 | def test_has_license(self, directory): 24 | assert isinstance(directory.license(), str) 25 | 26 | def test_has_no_misc_files(self, directory): 27 | assert directory.has_no_misc_files_in_cnab_dir() 28 | 29 | def test_has_run(self, directory): 30 | assert directory.has_run() 31 | 32 | def test_has_executable(self, directory): 33 | assert directory.has_executable_run() 34 | 35 | def test_is_valid(self, directory): 36 | assert directory.valid() 37 | 38 | 39 | class InvalidCNAB(object): 40 | @pytest.fixture 41 | def directory(self): 42 | return CNABDirectory("fixtures/invalidinvocationimage") 43 | 44 | 45 | class TestInvalidCNABDirectory(InvalidCNAB): 46 | def test_has_no_app_dir(self, directory): 47 | assert not directory.has_app_directory() 48 | 49 | def test_has_cnab_dir(self, directory): 50 | assert directory.has_cnab_directory() 51 | 52 | def test_has_readme(self, directory): 53 | assert isinstance(directory.readme(), str) 54 | 55 | def test_has_no_license(self, directory): 56 | assert not directory.license() 57 | 58 | def test_has_invalid_misc_files(self, directory): 59 | assert not directory.has_no_misc_files_in_cnab_dir() 60 | 61 | def test_has_no_run(self, directory): 62 | assert not directory.has_run() 63 | 64 | def test_has_no_executable(self, directory): 65 | assert not directory.has_executable_run() 66 | 67 | def test_is_invalid(self, directory): 68 | with pytest.raises(InvalidCNABDirectoryError): 69 | directory.valid() 70 | -------------------------------------------------------------------------------- /cnab/test_types.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from cnab import ( 4 | Bundle, 5 | Credential, 6 | InvocationImage, 7 | Action, 8 | Parameter, 9 | Metadata, 10 | Maintainer, 11 | Destination, 12 | ) 13 | import pytest # type: ignore 14 | 15 | 16 | class TestMinimalParameters(object): 17 | @pytest.fixture 18 | def bundle(self): 19 | return Bundle( 20 | name="sample", 21 | version="0.1.0", 22 | invocation_images=[ 23 | InvocationImage(image_type="docker", image="garethr/helloworld:0.1.0") 24 | ], 25 | ) 26 | 27 | def test_bundle_images_empty(self, bundle): 28 | assert bundle.images == {} 29 | 30 | def test_bundle_parameters_empty(self, bundle): 31 | assert bundle.parameters == {} 32 | 33 | def test_bundle_credentials_empty(self, bundle): 34 | assert bundle.credentials == {} 35 | 36 | def test_bundle_default_schema_version(self, bundle): 37 | assert bundle.schema_version == "v1" 38 | 39 | def test_bundle_keywords_empty(self, bundle): 40 | assert bundle.keywords == [] 41 | 42 | def test_bundle_actions_empty(self, bundle): 43 | assert bundle.actions == {} 44 | 45 | def test_bundle_maintainers_empty(self, bundle): 46 | assert bundle.maintainers == [] 47 | 48 | def test_convert_bundle_to_dict(self, bundle): 49 | assert isinstance(bundle.to_dict(), dict) 50 | 51 | def test_bundle_description_blank(self, bundle): 52 | assert not bundle.description 53 | 54 | def test_convert_bundle_to_json(self, bundle): 55 | assert isinstance(bundle.to_json(), str) 56 | 57 | def test_convert_bundle_to_pretty_json(self, bundle): 58 | assert isinstance(bundle.to_json(pretty=True), str) 59 | 60 | 61 | def test_read_bundle(): 62 | with open("fixtures/helloworld/bundle.json") as f: 63 | data = json.load(f) 64 | 65 | assert isinstance(Bundle.from_dict(data), Bundle) 66 | 67 | 68 | class TestAllParameters(object): 69 | @pytest.fixture 70 | def bundle(self): 71 | return Bundle( 72 | name="sample", 73 | version="0.1.0", 74 | invocation_images=[ 75 | InvocationImage(image_type="docker", image="garethr/helloworld:0.1.0") 76 | ], 77 | actions={ 78 | "status": Action(modifies=False), 79 | "explode": Action(modifies=True), 80 | }, 81 | parameters={ 82 | "port": Parameter( 83 | type="int", 84 | default_value=8080, 85 | destination=Destination(env="PORT"), 86 | metadata=Metadata(description="the public port"), 87 | ) 88 | }, 89 | credentials={"kubeconfig": Credential(path="/root/.kube/config")}, 90 | description="test", 91 | keywords=["test1", "test2"], 92 | maintainers=[ 93 | Maintainer(email="test@example.com", name="test", url="example.com") 94 | ], 95 | images={}, 96 | schema_version="v2", 97 | ) 98 | 99 | def test_bundle_set_schema_version(self, bundle): 100 | assert bundle.schema_version == "v2" 101 | 102 | def test_bundle_set_description(self, bundle): 103 | assert bundle.description == "test" 104 | 105 | @pytest.mark.parametrize("keyword", ["test1", "test2"]) 106 | def test_bundle_set_keywords(self, bundle, keyword): 107 | assert keyword in bundle.keywords 108 | 109 | @pytest.mark.parametrize("action", ["status", "explode"]) 110 | def test_bundle_set_actions(self, bundle, action): 111 | assert action in bundle.actions 112 | 113 | def test_bundle_set_maintainer(self, bundle): 114 | assert len(bundle.maintainers) == 1 115 | 116 | def test_bundle_set_credentials(self, bundle): 117 | assert len(bundle.credentials) == 1 118 | 119 | def test_bundle_kubeconfig_credential(self, bundle): 120 | assert "kubeconfig" in bundle.credentials 121 | 122 | def test_bundle_set_parameters(self, bundle): 123 | assert len(bundle.parameters) == 1 124 | 125 | def test_bundle_port_parameter(self, bundle): 126 | assert "port" in bundle.parameters 127 | 128 | def test_convert_bundle_to_dict(self, bundle): 129 | assert isinstance(bundle.to_dict(), dict) 130 | -------------------------------------------------------------------------------- /cnab/test_util.py: -------------------------------------------------------------------------------- 1 | import pytest # type: ignore 2 | 3 | import cnab.util 4 | from cnab import InvocationImage 5 | 6 | 7 | class TestExtractImages(object): 8 | @pytest.fixture 9 | def filtered(self): 10 | images = [ 11 | InvocationImage(image="oci"), 12 | InvocationImage(image="docker", image_type="docker"), 13 | ] 14 | return cnab.util.extract_docker_images(images) 15 | 16 | def test_filtered_images(self, filtered): 17 | assert len(filtered) == 1 18 | 19 | def test_extract_docker_images(self, filtered): 20 | assert filtered[0].image == "docker" 21 | -------------------------------------------------------------------------------- /cnab/types.py: -------------------------------------------------------------------------------- 1 | import canonicaljson # type: ignore 2 | 3 | from dataclasses import dataclass, field 4 | from typing import Optional, Any, List, Union, Dict, TypeVar, Callable, Type, cast 5 | 6 | 7 | T = TypeVar("T") 8 | 9 | 10 | def from_bool(x: Any) -> bool: 11 | if not isinstance(x, bool): 12 | raise Exception(f"{x} not a boolean") 13 | return x 14 | 15 | 16 | def from_none(x: Any) -> Any: 17 | if not x is None: 18 | raise Exception(f"{x} not None") 19 | return x 20 | 21 | 22 | def from_union(fs, x): 23 | for f in fs: 24 | try: 25 | return f(x) 26 | except: 27 | pass 28 | assert False 29 | 30 | 31 | def from_str(x: Any) -> str: 32 | if not isinstance(x, str): 33 | raise Exception(f"{x} not a string") 34 | return x 35 | 36 | 37 | def from_list(f: Callable[[Any], T], x: Any) -> List[T]: 38 | if not isinstance(x, list): 39 | raise Exception(f"{x} not a list") 40 | return [f(y) for y in x] 41 | 42 | 43 | def from_int(x: Any) -> int: 44 | if not (isinstance(x, int) and not isinstance(x, bool)): 45 | raise Exception(f"{x} not an integer") 46 | return x 47 | 48 | 49 | def to_class(c: Type[T], x: Any) -> dict: 50 | if not isinstance(x, c): 51 | raise Exception(f"{x} not a {c}") 52 | return cast(Any, x).to_dict() 53 | 54 | 55 | def from_dict(f: Callable[[Any], T], x: Any) -> Dict[str, T]: 56 | if not isinstance(x, dict): 57 | raise Exception(f"{x} not a dictionary") 58 | return {k: f(v) for (k, v) in x.items()} 59 | 60 | 61 | def clean(result: Dict) -> dict: 62 | return {k: v for k, v in result.items() if v} 63 | 64 | 65 | @dataclass 66 | class Action: 67 | modifies: Optional[bool] = None 68 | stateless: Optional[bool] = None 69 | description: Optional[str] = None 70 | 71 | @staticmethod 72 | def from_dict(obj: Any) -> "Action": 73 | assert isinstance(obj, dict) 74 | modifies = from_union([from_bool, from_none], obj.get("modifies")) 75 | stateless = from_union([from_bool, from_none], obj.get("stateless")) 76 | description = from_union([from_str, from_none], obj.get("description")) 77 | return Action(modifies, stateless, description) 78 | 79 | def to_dict(self) -> dict: 80 | result: dict = {} 81 | result["modifies"] = from_union([from_bool, from_none], self.modifies) 82 | result["stateless"] = from_union([from_bool, from_none], self.stateless) 83 | result["description"] = from_union([from_str, from_none], self.description) 84 | return clean(result) 85 | 86 | 87 | @dataclass 88 | class Credential: 89 | description: Optional[str] = None 90 | env: Optional[str] = None 91 | path: Optional[str] = None 92 | 93 | @staticmethod 94 | def from_dict(obj: Any) -> "Credential": 95 | assert isinstance(obj, dict) 96 | description = from_union([from_str, from_none], obj.get("description")) 97 | env = from_union([from_str, from_none], obj.get("env")) 98 | path = from_union([from_str, from_none], obj.get("path")) 99 | return Credential(description, env, path) 100 | 101 | def to_dict(self) -> dict: 102 | result: dict = {} 103 | result["description"] = from_union([from_str, from_none], self.description) 104 | result["env"] = from_union([from_str, from_none], self.env) 105 | result["path"] = from_union([from_str, from_none], self.path) 106 | return clean(result) 107 | 108 | 109 | @dataclass 110 | class ImagePlatform: 111 | architecture: Optional[str] = None 112 | os: Optional[str] = None 113 | 114 | @staticmethod 115 | def from_dict(obj: Any) -> "ImagePlatform": 116 | assert isinstance(obj, dict) 117 | architecture = from_union([from_str, from_none], obj.get("architecture")) 118 | os = from_union([from_str, from_none], obj.get("os")) 119 | return ImagePlatform(architecture, os) 120 | 121 | def to_dict(self) -> dict: 122 | result: dict = {} 123 | result["architecture"] = from_union([from_str, from_none], self.architecture) 124 | result["os"] = from_union([from_str, from_none], self.os) 125 | return clean(result) 126 | 127 | 128 | @dataclass 129 | class Ref: 130 | field: Optional[str] = None 131 | media_type: Optional[str] = None 132 | path: Optional[str] = None 133 | 134 | @staticmethod 135 | def from_dict(obj: Any) -> "Ref": 136 | assert isinstance(obj, dict) 137 | field = from_union([from_str, from_none], obj.get("field")) 138 | media_type = from_union([from_str, from_none], obj.get("mediaType")) 139 | path = from_union([from_str, from_none], obj.get("path")) 140 | return Ref(field, media_type, path) 141 | 142 | def to_dict(self) -> dict: 143 | result: dict = {} 144 | result["field"] = from_union([from_str, from_none], self.field) 145 | result["mediaType"] = from_union([from_str, from_none], self.media_type) 146 | result["path"] = from_union([from_str, from_none], self.path) 147 | return clean(result) 148 | 149 | 150 | @dataclass 151 | class Image: 152 | image: str 153 | description: Optional[str] = None 154 | digest: Optional[str] = None 155 | image_type: Optional[str] = None 156 | media_type: Optional[str] = None 157 | platform: Optional[ImagePlatform] = None 158 | refs: List[Ref] = field(default_factory=list) 159 | size: Optional[int] = None 160 | 161 | @staticmethod 162 | def from_dict(obj: Any) -> "Image": 163 | assert isinstance(obj, dict) 164 | description = from_union([from_str, from_none], obj.get("description")) 165 | digest = from_union([from_str, from_none], obj.get("digest")) 166 | image = from_str(obj.get("image")) 167 | image_type = from_union([from_str, from_none], obj.get("imageType")) 168 | media_type = from_union([from_str, from_none], obj.get("mediaType")) 169 | platform = from_union([ImagePlatform.from_dict, from_none], obj.get("platform")) 170 | refs = from_union( 171 | [lambda x: from_list(Ref.from_dict, x), from_none], obj.get("refs") 172 | ) 173 | size = from_union([from_int, from_none], obj.get("size")) 174 | return Image( 175 | description, digest, image, image_type, media_type, platform, refs, size 176 | ) 177 | 178 | def to_dict(self) -> dict: 179 | result: dict = {} 180 | result["description"] = from_union([from_str, from_none], self.description) 181 | result["digest"] = from_union([from_str, from_none], self.digest) 182 | result["image"] = from_str(self.image) 183 | result["imageType"] = from_union([from_str, from_none], self.image_type) 184 | result["mediaType"] = from_union([from_str, from_none], self.media_type) 185 | result["platform"] = from_union( 186 | [lambda x: to_class(ImagePlatform, x), from_none], self.platform 187 | ) 188 | result["refs"] = from_list(lambda x: to_class(Ref, x), self.refs) 189 | result["size"] = from_union([from_int, from_none], self.size) 190 | return clean(result) 191 | 192 | 193 | @dataclass 194 | class InvocationImage: 195 | image: str 196 | digest: Optional[str] = None 197 | image_type: Optional[str] = "oci" 198 | media_type: Optional[str] = None 199 | platform: Optional[ImagePlatform] = None 200 | size: Optional[str] = None 201 | 202 | @staticmethod 203 | def from_dict(obj: Any) -> "InvocationImage": 204 | assert isinstance(obj, dict) 205 | digest = from_union([from_str, from_none], obj.get("digest")) 206 | image = from_str(obj.get("image")) 207 | image_type = from_union([from_str, from_none], obj.get("imageType")) 208 | media_type = from_union([from_str, from_none], obj.get("mediaType")) 209 | platform = from_union([ImagePlatform.from_dict, from_none], obj.get("platform")) 210 | size = from_union([from_str, from_none], obj.get("size")) 211 | return InvocationImage(image, digest, image_type, media_type, platform, size) 212 | 213 | def to_dict(self) -> dict: 214 | result: dict = {} 215 | result["digest"] = from_union([from_str, from_none], self.digest) 216 | result["image"] = from_str(self.image) 217 | result["imageType"] = from_union([from_str, from_none], self.image_type) 218 | result["mediaType"] = from_union([from_str, from_none], self.media_type) 219 | result["platform"] = from_union( 220 | [lambda x: to_class(ImagePlatform, x), from_none], self.platform 221 | ) 222 | result["size"] = from_union([from_str, from_none], self.size) 223 | return clean(result) 224 | 225 | 226 | @dataclass 227 | class Maintainer: 228 | name: str 229 | email: Optional[str] = None 230 | url: Optional[str] = None 231 | 232 | @staticmethod 233 | def from_dict(obj: Any) -> "Maintainer": 234 | assert isinstance(obj, dict) 235 | name = from_union([from_str, from_none], obj.get("name")) 236 | email = from_union([from_str, from_none], obj.get("email")) 237 | url = from_union([from_str, from_none], obj.get("url")) 238 | return Maintainer(name, email, url) 239 | 240 | def to_dict(self) -> dict: 241 | result: dict = {} 242 | result["name"] = from_union([from_str, from_none], self.name) 243 | result["email"] = from_union([from_str, from_none], self.email) 244 | result["url"] = from_union([from_str, from_none], self.url) 245 | return clean(result) 246 | 247 | 248 | @dataclass 249 | class Destination: 250 | description: Optional[str] = None 251 | env: Optional[str] = None 252 | path: Optional[str] = None 253 | 254 | @staticmethod 255 | def from_dict(obj: Any) -> "Destination": 256 | assert isinstance(obj, dict) 257 | description = from_union([from_str, from_none], obj.get("description")) 258 | env = from_union([from_str, from_none], obj.get("env")) 259 | path = from_union([from_str, from_none], obj.get("path")) 260 | return Destination(description, env, path) 261 | 262 | def to_dict(self) -> dict: 263 | result: dict = {} 264 | result["description"] = from_union([from_str, from_none], self.description) 265 | result["env"] = from_union([from_str, from_none], self.env) 266 | result["path"] = from_union([from_str, from_none], self.path) 267 | return clean(result) 268 | 269 | 270 | @dataclass 271 | class Metadata: 272 | description: Optional[str] = None 273 | 274 | @staticmethod 275 | def from_dict(obj: Any) -> "Metadata": 276 | assert isinstance(obj, dict) 277 | description = from_union([from_str, from_none], obj.get("description")) 278 | return Metadata(description) 279 | 280 | def to_dict(self) -> dict: 281 | result: dict = {} 282 | result["description"] = from_union([from_str, from_none], self.description) 283 | return clean(result) 284 | 285 | 286 | @dataclass 287 | class Parameter: 288 | type: str 289 | destination: Destination 290 | default_value: Union[bool, int, None, str] = None 291 | allowed_values: Optional[List[Any]] = field(default_factory=list) 292 | max_length: Optional[int] = None 293 | max_value: Optional[int] = None 294 | metadata: Optional[Metadata] = None 295 | min_length: Optional[int] = None 296 | min_value: Optional[int] = None 297 | required: Optional[bool] = None 298 | 299 | @staticmethod 300 | def from_dict(obj: Any) -> "Parameter": 301 | assert isinstance(obj, dict) 302 | allowed_values = from_union( 303 | [lambda x: from_list(lambda x: x, x), from_none], obj.get("allowedValues") 304 | ) 305 | default_value = from_union( 306 | [from_int, from_bool, from_none, from_str], obj.get("defaultValue") 307 | ) 308 | destination = from_union([Destination.from_dict], obj.get("destination")) 309 | max_length = from_union([from_int, from_none], obj.get("maxLength")) 310 | max_value = from_union([from_int, from_none], obj.get("maxValue")) 311 | metadata = from_union([Metadata.from_dict, from_none], obj.get("metadata")) 312 | min_length = from_union([from_int, from_none], obj.get("minLength")) 313 | min_value = from_union([from_int, from_none], obj.get("minValue")) 314 | required = from_union([from_bool, from_none], obj.get("required")) 315 | type = from_str(obj.get("type")) 316 | return Parameter( 317 | type, 318 | destination, 319 | default_value, 320 | allowed_values, 321 | max_length, 322 | max_value, 323 | metadata, 324 | min_length, 325 | min_value, 326 | required, 327 | ) 328 | 329 | def to_dict(self) -> dict: 330 | result: dict = {} 331 | result["allowedValues"] = from_list(lambda x: x, self.allowed_values) 332 | result["destination"] = from_union( 333 | [lambda x: to_class(Destination, x)], self.destination 334 | ) 335 | result["maxLength"] = from_union([from_int, from_none], self.max_length) 336 | result["maxValue"] = from_union([from_int, from_none], self.max_value) 337 | result["metadata"] = from_union( 338 | [lambda x: to_class(Metadata, x), from_none], self.metadata 339 | ) 340 | result["minLength"] = from_union([from_int, from_none], self.min_length) 341 | result["minValue"] = from_union([from_int, from_none], self.min_value) 342 | result["required"] = from_union([from_bool, from_none], self.required) 343 | result["type"] = from_str(self.type) 344 | return clean(result) 345 | 346 | 347 | @dataclass 348 | class Bundle: 349 | name: str 350 | version: str 351 | invocation_images: List[InvocationImage] 352 | schema_version: Optional[str] = "v1" 353 | actions: Dict[str, Action] = field(default_factory=dict) 354 | credentials: Dict[str, Credential] = field(default_factory=dict) 355 | description: Optional[str] = None 356 | license: Optional[str] = None 357 | images: Dict[str, Image] = field(default_factory=dict) 358 | keywords: List[str] = field(default_factory=list) 359 | maintainers: List[Maintainer] = field(default_factory=list) 360 | parameters: Dict[str, Parameter] = field(default_factory=dict) 361 | 362 | @staticmethod 363 | def from_dict(obj: Any) -> "Bundle": 364 | assert isinstance(obj, dict) 365 | actions = from_union( 366 | [lambda x: from_dict(Action.from_dict, x), from_none], obj.get("actions") 367 | ) 368 | credentials = from_union( 369 | [lambda x: from_dict(Credential.from_dict, x), from_none], 370 | obj.get("credentials"), 371 | ) 372 | description = from_union([from_str, from_none], obj.get("description")) 373 | license = from_union([from_str, from_none], obj.get("license")) 374 | images = from_union( 375 | [lambda x: from_dict(Image.from_dict, x), from_none], obj.get("images") 376 | ) 377 | invocation_images = from_list( 378 | InvocationImage.from_dict, obj.get("invocationImages") 379 | ) 380 | keywords = from_union( 381 | [lambda x: from_list(from_str, x), from_none], obj.get("keywords") 382 | ) 383 | maintainers = from_union( 384 | [lambda x: from_list(Maintainer.from_dict, x), from_none], 385 | obj.get("maintainers"), 386 | ) 387 | name = from_str(obj.get("name")) 388 | parameters = from_union( 389 | [lambda x: from_dict(Parameter.from_dict, x), from_none], 390 | obj.get("parameters"), 391 | ) 392 | schema_version = from_union([from_str, from_none], obj.get("schemaVersion")) 393 | version = from_str(obj.get("version")) 394 | return Bundle( 395 | name, 396 | version, 397 | invocation_images, 398 | schema_version, 399 | actions, 400 | credentials, 401 | description, 402 | license, 403 | images, 404 | keywords, 405 | maintainers, 406 | parameters, 407 | ) 408 | 409 | def to_dict(self) -> dict: 410 | result: dict = {} 411 | result["actions"] = from_dict(lambda x: to_class(Action, x), self.actions) 412 | result["credentials"] = from_dict( 413 | lambda x: to_class(Credential, x), self.credentials 414 | ) 415 | result["description"] = from_union([from_str, from_none], self.description) 416 | result["license"] = from_union([from_str, from_none], self.license) 417 | result["images"] = from_dict(lambda x: to_class(Image, x), self.images) 418 | result["invocationImages"] = from_list( 419 | lambda x: to_class(InvocationImage, x), self.invocation_images 420 | ) 421 | result["keywords"] = from_list(from_str, self.keywords) 422 | result["maintainers"] = from_list( 423 | lambda x: to_class(Maintainer, x), self.maintainers 424 | ) 425 | result["name"] = from_str(self.name) 426 | result["parameters"] = from_dict( 427 | lambda x: to_class(Parameter, x), self.parameters 428 | ) 429 | result["schemaVersion"] = from_str(self.schema_version) 430 | result["version"] = from_str(self.version) 431 | return clean(result) 432 | 433 | def to_json(self, pretty: bool = False) -> str: 434 | if pretty: 435 | func = canonicaljson.encode_pretty_printed_json 436 | else: 437 | func = canonicaljson.encode_canonical_json 438 | return func(self.to_dict()).decode() 439 | -------------------------------------------------------------------------------- /cnab/util.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from cnab.types import InvocationImage 4 | 5 | 6 | def extract_docker_images(images: List[InvocationImage]) -> list: 7 | return list(filter(lambda x: x.image_type == "docker", images)) 8 | -------------------------------------------------------------------------------- /examples/testing/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM kennethreitz/pipenv 2 | 3 | COPY . /app 4 | 5 | RUN pytest 6 | -------------------------------------------------------------------------------- /examples/testing/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | cnab = "*" 10 | jsonschema = "*" 11 | pytest = "*" 12 | 13 | [requires] 14 | python_version = "3.7" 15 | -------------------------------------------------------------------------------- /examples/testing/Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "546c0be2c74833aa27c5a4c65c7ac292c0477714de75c89588ad3bc0a21f335a" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "atomicwrites": { 20 | "hashes": [ 21 | "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", 22 | "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" 23 | ], 24 | "version": "==1.3.0" 25 | }, 26 | "attrs": { 27 | "hashes": [ 28 | "sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69", 29 | "sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb" 30 | ], 31 | "version": "==18.2.0" 32 | }, 33 | "canonicaljson": { 34 | "hashes": [ 35 | "sha256:45bce530ff5fd0ca93703f71bfb66de740a894a3b5dd6122398c6d8f18539725", 36 | "sha256:e7fe5b1d5a2b740c188ec2860fad0578aabff2cc59edcb41c02eb4d472b95ded" 37 | ], 38 | "version": "==1.1.4" 39 | }, 40 | "cnab": { 41 | "hashes": [ 42 | "sha256:22c9734ef758a73b5054824390c34e219c482185703207ff7dd26f42ffb27da7", 43 | "sha256:99db5c808ca523485b14bea5d7cc25be215b81de77e5cbb65a2b266be984e3f1" 44 | ], 45 | "index": "pypi", 46 | "version": "==0.1.6" 47 | }, 48 | "frozendict": { 49 | "hashes": [ 50 | "sha256:774179f22db2ef8a106e9c38d4d1f8503864603db08de2e33be5b778230f6e45" 51 | ], 52 | "version": "==1.2" 53 | }, 54 | "jsonschema": { 55 | "hashes": [ 56 | "sha256:000e68abd33c972a5248544925a0cae7d1125f9bf6c58280d37546b946769a08", 57 | "sha256:6ff5f3180870836cae40f06fa10419f557208175f13ad7bc26caa77beb1f6e02" 58 | ], 59 | "index": "pypi", 60 | "version": "==2.6.0" 61 | }, 62 | "more-itertools": { 63 | "hashes": [ 64 | "sha256:38a936c0a6d98a38bcc2d03fdaaedaba9f412879461dd2ceff8d37564d6522e4", 65 | "sha256:c0a5785b1109a6bd7fac76d6837fd1feca158e54e521ccd2ae8bfe393cc9d4fc", 66 | "sha256:fe7a7cae1ccb57d33952113ff4fa1bc5f879963600ed74918f1236e212ee50b9" 67 | ], 68 | "version": "==5.0.0" 69 | }, 70 | "pluggy": { 71 | "hashes": [ 72 | "sha256:8ddc32f03971bfdf900a81961a48ccf2fb677cf7715108f85295c67405798616", 73 | "sha256:980710797ff6a041e9a73a5787804f848996ecaa6f8a1b1e08224a5894f2074a" 74 | ], 75 | "version": "==0.8.1" 76 | }, 77 | "py": { 78 | "hashes": [ 79 | "sha256:bf92637198836372b520efcba9e020c330123be8ce527e535d185ed4b6f45694", 80 | "sha256:e76826342cefe3c3d5f7e8ee4316b80d1dd8a300781612ddbc765c17ba25a6c6" 81 | ], 82 | "version": "==1.7.0" 83 | }, 84 | "pytest": { 85 | "hashes": [ 86 | "sha256:65aeaa77ae87c7fc95de56285282546cfa9c886dc8e5dc78313db1c25e21bc07", 87 | "sha256:6ac6d467d9f053e95aaacd79f831dbecfe730f419c6c7022cb316b365cd9199d" 88 | ], 89 | "index": "pypi", 90 | "version": "==4.2.0" 91 | }, 92 | "simplejson": { 93 | "hashes": [ 94 | "sha256:067a7177ddfa32e1483ba5169ebea1bc2ea27f224853211ca669325648ca5642", 95 | "sha256:2fc546e6af49fb45b93bbe878dea4c48edc34083729c0abd09981fe55bdf7f91", 96 | "sha256:354fa32b02885e6dae925f1b5bbf842c333c1e11ea5453ddd67309dc31fdb40a", 97 | "sha256:37e685986cf6f8144607f90340cff72d36acf654f3653a6c47b84c5c38d00df7", 98 | "sha256:3af610ee72efbe644e19d5eaad575c73fb83026192114e5f6719f4901097fce2", 99 | "sha256:3b919fc9cf508f13b929a9b274c40786036b31ad28657819b3b9ba44ba651f50", 100 | "sha256:3dd289368bbd064974d9a5961101f080e939cbe051e6689a193c99fb6e9ac89b", 101 | "sha256:6c3258ffff58712818a233b9737fe4be943d306c40cf63d14ddc82ba563f483a", 102 | "sha256:75e3f0b12c28945c08f54350d91e624f8dd580ab74fd4f1bbea54bc6b0165610", 103 | "sha256:b1f329139ba647a9548aa05fb95d046b4a677643070dc2afc05fa2e975d09ca5", 104 | "sha256:ee9625fc8ee164902dfbb0ff932b26df112da9f871c32f0f9c1bcf20c350fe2a", 105 | "sha256:fb2530b53c28f0d4d84990e945c2ebb470edb469d63e389bf02ff409012fe7c5" 106 | ], 107 | "version": "==3.16.0" 108 | }, 109 | "six": { 110 | "hashes": [ 111 | "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", 112 | "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" 113 | ], 114 | "version": "==1.12.0" 115 | } 116 | }, 117 | "develop": {} 118 | } 119 | -------------------------------------------------------------------------------- /examples/testing/README.md: -------------------------------------------------------------------------------- 1 | # Testing for valid CNAB bundles 2 | 3 | The following demonstrates usage of `pycnab`, by building a set of tests to validate CNAB bundles. 4 | 5 | 6 | ## Usage 7 | 8 | Dependencies are managed using [Pipenv](https://pipenv.readthedocs.io/en/latest/). 9 | 10 | ```console 11 | pipenv sync 12 | ``` 13 | 14 | With the dependencies installed you should be able to simply run `pytest` and have it detect the tests. This with sample data you should get something like the following output. 15 | 16 | ```console 17 | $ pytest 18 | collected 3 items 19 | 20 | test_validate.py::test_valid_bundles[helloworld/bundle.json] PASSED 21 | test_validate.py::test_valid_jsonschema[schemas/latest.json-helloworld/bundle.json] PASSED 22 | test_validate.py::test_valid_invocation_image[helloworld/cnab] PASSED 23 | ``` 24 | 25 | You can drop your own schemas in the `schemas` directory, and any directories containing `cnab` directories or `bundle.json` files will be checked for correctness. 26 | 27 | 28 | ### Docker 29 | 30 | If you prefer to keep your environment clean you can run using Docker, using the Dockerfile provided. 31 | 32 | ```console 33 | $ docker build . 34 | Sending build context to Docker daemon 2.331MB 35 | Step 1/3 : FROM kennethreitz/pipenv 36 | # Executing 3 build triggers 37 | ---> Using cache 38 | ---> Using cache 39 | ---> Using cache 40 | ---> 7c4d92001455 41 | Step 2/3 : COPY . /app 42 | ---> 7a0045b96aab 43 | Step 3/3 : RUN pytest 44 | ---> Running in 61ab93743ba2 45 | ============================= test session starts ============================== 46 | platform linux -- Python 3.7.1, pytest-4.2.0, py-1.7.0, pluggy-0.8.1 -- /usr/bin/python3.7 47 | cachedir: .pytest_cache 48 | rootdir: /app, inifile: pytest.ini 49 | collecting ... collected 3 items 50 | 51 | test_validate.py::test_valid_bundles[helloworld/bundle.json] PASSED [ 33%] 52 | test_validate.py::test_valid_jsonschema[schemas/latest.json-helloworld/bundle.json] PASSED [ 66%] 53 | test_validate.py::test_valid_invocation_image[helloworld/cnab] PASSED [100%] 54 | 55 | =========================== 3 passed in 0.31 seconds =========================== 56 | Removing intermediate container 61ab93743ba2 57 | ---> 6a50483b8550 58 | Successfully built 6a50483b8550 59 | ``` 60 | 61 | 62 | ## Failures 63 | 64 | [CNAB](https://cnab.io/) is still new, and the specification is changing rapidly. The JSON Schema also has bugs that are being worked out. 65 | `pycnab` is also new, and bugs will exist in there too. Using this test suite should be a useful way of working out those issues, so please report 66 | any suspect failures. 67 | -------------------------------------------------------------------------------- /examples/testing/helloworld/bundle.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "helloworld", 3 | "version": "0.1.1", 4 | "invocationImages": [ 5 | { 6 | "imageType": "docker", 7 | "image": "cnab/helloworld:latest" 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /examples/testing/helloworld/cnab/app/run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | #set -eo pipefail 4 | 5 | action=$CNAB_ACTION 6 | name=$CNAB_INSTALLATION_NAME 7 | 8 | echo "Port parameter was set to ${CNAB_P_PORT}" 9 | case $action in 10 | install) 11 | echo "Install action" 12 | ;; 13 | uninstall) 14 | echo "uninstall action" 15 | ;; 16 | upgrade) 17 | echo "Upgrade action" 18 | ;; 19 | downgrade) 20 | echo "Downgrade action" 21 | ;; 22 | status) 23 | echo "Status action" 24 | ;; 25 | *) 26 | echo "No action for $action" 27 | ;; 28 | esac 29 | echo "Action $action complete for $name" 30 | -------------------------------------------------------------------------------- /examples/testing/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --verbose 3 | filterwarnings = 4 | ignore::DeprecationWarning 5 | -------------------------------------------------------------------------------- /examples/testing/test_validate_cnab.py: -------------------------------------------------------------------------------- 1 | """ 2 | This pytest set of tests is designed to validate uses of CNAB, both bundle.json 3 | metadata files and the file system layout for invocation images. 4 | 5 | The tests below will automatically generate individual unit tests along various axis: 6 | 7 | - for every schema in the schemas directory 8 | - for every bundle.json file found in decendent directories 9 | - for every cnab directory found in decendent directories 10 | 11 | """ 12 | 13 | import json 14 | import glob 15 | import os 16 | import shutil 17 | from urllib.request import urlopen 18 | 19 | from jsonschema import validate # type: ignore 20 | import pytest 21 | 22 | from cnab import Bundle, CNABDirectory 23 | 24 | # If we don't already have a schemas directory from a previous run or extra schemas then create one 25 | if not os.path.exists("schemas"): 26 | os.makedirs("schemas") 27 | 28 | # We grab the latest version of the schema from the spec repository whenever the tests are run 29 | URL = "https://raw.githubusercontent.com/deislabs/cnab-spec/master/schema/bundle.schema.json" 30 | with urlopen(URL) as response, open("schemas/latest.json", "wb") as out_file: 31 | shutil.copyfileobj(response, out_file) 32 | 33 | 34 | @pytest.fixture(scope="module", params=glob.glob("schemas/*.json")) 35 | def schema(request): 36 | "This fixture will pick up multiple schemas if present" 37 | with open(request.param) as schema_data: 38 | return json.load(schema_data) 39 | 40 | 41 | @pytest.mark.parametrize("path", glob.glob("**/bundle.json")) 42 | def test_valid_bundles(path): 43 | "Test that any bundle.json files present are valid according to pycnab" 44 | with open(path) as data: 45 | Bundle.from_dict(json.load(data)) 46 | 47 | 48 | @pytest.mark.parametrize("path", glob.glob("**/bundle.json")) 49 | def test_valid_jsonschema(schema, path): 50 | "Test that any bundle.json files present are valid according to the JSON Schema" 51 | with open(path) as data: 52 | validate(json.load(data), schema) 53 | 54 | 55 | @pytest.mark.parametrize("path", glob.glob("**/cnab")) 56 | def test_valid_invocation_image(path): 57 | "Test that CNAB build directories for adherence to the spec" 58 | head, tail = os.path.split(path) 59 | directory = CNABDirectory(head) 60 | directory.valid() 61 | -------------------------------------------------------------------------------- /fixtures/hellohelm/bundle.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hellohelm", 3 | "version": "0.1.0", 4 | "invocationImages": [ 5 | { 6 | "imageType": "docker", 7 | "image": "cnab/hellohelm:latest" 8 | } 9 | ], 10 | "images": { 11 | "demo": { 12 | "description": "alpine", 13 | "image": "technosophos/demo2alpine:0.1.0", 14 | "imageType": "docker", 15 | "refs": [ 16 | { 17 | "path": "cnab/app/charts/alpine/values.yaml", 18 | "field": "image.repository" 19 | } 20 | ] 21 | } 22 | }, 23 | "parameters": { 24 | "port": { 25 | "defaultValue": 8080, 26 | "type": "int", 27 | "destination": { 28 | "env": "PORT" 29 | } 30 | } 31 | }, 32 | "credentials": { 33 | "kubeconfig": { 34 | "path": "/root/.kube/config" 35 | } 36 | }, 37 | "actions": { 38 | "status": { 39 | "modifies": false 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /fixtures/helloworld/bundle.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "helloworld", 3 | "version": "0.1.1", 4 | "invocationImages": [ 5 | { 6 | "imageType": "docker", 7 | "image": "cnab/helloworld:latest" 8 | } 9 | ], 10 | "parameters": { 11 | "port": { 12 | "defaultValue": 8080, 13 | "type": "int", 14 | "destination": { 15 | "env": "PORT" 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /fixtures/invalidinvocationimage/cnab/INVALID: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garethr/pycnab/9d8b707b8d7990214c3dcb2ccfe3f32af8c36d8d/fixtures/invalidinvocationimage/cnab/INVALID -------------------------------------------------------------------------------- /fixtures/invalidinvocationimage/cnab/README.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garethr/pycnab/9d8b707b8d7990214c3dcb2ccfe3f32af8c36d8d/fixtures/invalidinvocationimage/cnab/README.txt -------------------------------------------------------------------------------- /fixtures/invocationimage/cnab/LICENSE: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garethr/pycnab/9d8b707b8d7990214c3dcb2ccfe3f32af8c36d8d/fixtures/invocationimage/cnab/LICENSE -------------------------------------------------------------------------------- /fixtures/invocationimage/cnab/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garethr/pycnab/9d8b707b8d7990214c3dcb2ccfe3f32af8c36d8d/fixtures/invocationimage/cnab/README.md -------------------------------------------------------------------------------- /fixtures/invocationimage/cnab/app/run: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garethr/pycnab/9d8b707b8d7990214c3dcb2ccfe3f32af8c36d8d/fixtures/invocationimage/cnab/app/run -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | category = "dev" 3 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 4 | name = "appdirs" 5 | optional = false 6 | python-versions = "*" 7 | version = "1.4.3" 8 | 9 | [[package]] 10 | category = "dev" 11 | description = "Atomic file writes." 12 | name = "atomicwrites" 13 | optional = false 14 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 15 | version = "1.2.1" 16 | 17 | [[package]] 18 | category = "dev" 19 | description = "Classes Without Boilerplate" 20 | name = "attrs" 21 | optional = false 22 | python-versions = "*" 23 | version = "18.2.0" 24 | 25 | [[package]] 26 | category = "dev" 27 | description = "The uncompromising code formatter." 28 | name = "black" 29 | optional = false 30 | python-versions = ">=3.6" 31 | version = "18.9b0" 32 | 33 | [package.dependencies] 34 | appdirs = "*" 35 | attrs = ">=17.4.0" 36 | click = ">=6.5" 37 | toml = ">=0.9.4" 38 | 39 | [[package]] 40 | category = "main" 41 | description = "Canonical JSON" 42 | name = "canonicaljson" 43 | optional = false 44 | python-versions = "*" 45 | version = "1.1.4" 46 | 47 | [package.dependencies] 48 | frozendict = ">=1.0" 49 | simplejson = ">=3.6.5" 50 | six = "*" 51 | 52 | [[package]] 53 | category = "main" 54 | description = "Python package for providing Mozilla's CA Bundle." 55 | name = "certifi" 56 | optional = true 57 | python-versions = "*" 58 | version = "2018.11.29" 59 | 60 | [[package]] 61 | category = "main" 62 | description = "Universal encoding detector for Python 2 and 3" 63 | name = "chardet" 64 | optional = true 65 | python-versions = "*" 66 | version = "3.0.4" 67 | 68 | [[package]] 69 | category = "dev" 70 | description = "Composable command line interface toolkit" 71 | name = "click" 72 | optional = false 73 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 74 | version = "7.0" 75 | 76 | [[package]] 77 | category = "dev" 78 | description = "Cross-platform colored terminal text." 79 | marker = "sys_platform == \"win32\"" 80 | name = "colorama" 81 | optional = false 82 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 83 | version = "0.4.1" 84 | 85 | [[package]] 86 | category = "dev" 87 | description = "Code coverage measurement for Python" 88 | name = "coverage" 89 | optional = false 90 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, <4" 91 | version = "4.5.2" 92 | 93 | [[package]] 94 | category = "main" 95 | description = "A Python library for the Docker Engine API." 96 | name = "docker" 97 | optional = true 98 | python-versions = "*" 99 | version = "3.6.0" 100 | 101 | [package.dependencies] 102 | docker-pycreds = ">=0.3.0" 103 | requests = ">=2.14.2,<2.18.0 || >2.18.0" 104 | six = ">=1.4.0" 105 | websocket-client = ">=0.32.0" 106 | 107 | [package.dependencies.pypiwin32] 108 | python = ">=3.6" 109 | version = "223" 110 | 111 | [[package]] 112 | category = "main" 113 | description = "Python bindings for the docker credentials store API" 114 | name = "docker-pycreds" 115 | optional = true 116 | python-versions = "*" 117 | version = "0.4.0" 118 | 119 | [package.dependencies] 120 | six = ">=1.4.0" 121 | 122 | [[package]] 123 | category = "main" 124 | description = "An immutable dictionary" 125 | name = "frozendict" 126 | optional = false 127 | python-versions = "*" 128 | version = "1.2" 129 | 130 | [[package]] 131 | category = "main" 132 | description = "Internationalized Domain Names in Applications (IDNA)" 133 | name = "idna" 134 | optional = true 135 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 136 | version = "2.8" 137 | 138 | [[package]] 139 | category = "dev" 140 | description = "More routines for operating on iterables, beyond itertools" 141 | name = "more-itertools" 142 | optional = false 143 | python-versions = "*" 144 | version = "5.0.0" 145 | 146 | [package.dependencies] 147 | six = ">=1.0.0,<2.0.0" 148 | 149 | [[package]] 150 | category = "dev" 151 | description = "Optional static typing for Python" 152 | name = "mypy" 153 | optional = false 154 | python-versions = "*" 155 | version = "0.650" 156 | 157 | [package.dependencies] 158 | mypy-extensions = ">=0.4.0,<0.5.0" 159 | typed-ast = ">=1.1.0,<1.2.0" 160 | 161 | [[package]] 162 | category = "dev" 163 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 164 | name = "mypy-extensions" 165 | optional = false 166 | python-versions = "*" 167 | version = "0.4.1" 168 | 169 | [[package]] 170 | category = "dev" 171 | description = "plugin and hook calling mechanisms for python" 172 | name = "pluggy" 173 | optional = false 174 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 175 | version = "0.8.0" 176 | 177 | [[package]] 178 | category = "dev" 179 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 180 | name = "py" 181 | optional = false 182 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 183 | version = "1.7.0" 184 | 185 | [[package]] 186 | category = "main" 187 | description = "" 188 | marker = "sys_platform == \"win32\" and python_version >= \"3.6\"" 189 | name = "pypiwin32" 190 | optional = true 191 | python-versions = "*" 192 | version = "223" 193 | 194 | [package.dependencies] 195 | pywin32 = ">=223" 196 | 197 | [[package]] 198 | category = "dev" 199 | description = "pytest: simple powerful testing with Python" 200 | name = "pytest" 201 | optional = false 202 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 203 | version = "3.10.1" 204 | 205 | [package.dependencies] 206 | atomicwrites = ">=1.0" 207 | attrs = ">=17.4.0" 208 | colorama = "*" 209 | more-itertools = ">=4.0.0" 210 | pluggy = ">=0.7" 211 | py = ">=1.5.0" 212 | setuptools = "*" 213 | six = ">=1.10.0" 214 | 215 | [[package]] 216 | category = "dev" 217 | description = "A pytest plugin to enable format checking with black" 218 | name = "pytest-black" 219 | optional = false 220 | python-versions = ">=3.6" 221 | version = "0.3.2" 222 | 223 | [package.dependencies] 224 | black = "18.9b0" 225 | pytest = ">=3.5.0" 226 | 227 | [[package]] 228 | category = "dev" 229 | description = "Pytest plugin for measuring coverage." 230 | name = "pytest-cov" 231 | optional = false 232 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 233 | version = "2.6.1" 234 | 235 | [package.dependencies] 236 | coverage = ">=4.4" 237 | pytest = ">=3.6" 238 | 239 | [[package]] 240 | category = "dev" 241 | description = "Mypy static type checker plugin for Pytest" 242 | name = "pytest-mypy" 243 | optional = false 244 | python-versions = "*" 245 | version = "0.3.2" 246 | 247 | [package.dependencies] 248 | mypy = ">=0.570,<1.0" 249 | pytest = ">=2.9.2" 250 | 251 | [[package]] 252 | category = "main" 253 | description = "Python for Window Extensions" 254 | marker = "sys_platform == \"win32\" and python_version >= \"3.6\"" 255 | name = "pywin32" 256 | optional = true 257 | python-versions = "*" 258 | version = "224" 259 | 260 | [[package]] 261 | category = "main" 262 | description = "Python HTTP for Humans." 263 | name = "requests" 264 | optional = true 265 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 266 | version = "2.21.0" 267 | 268 | [package.dependencies] 269 | certifi = ">=2017.4.17" 270 | chardet = ">=3.0.2,<3.1.0" 271 | idna = ">=2.5,<2.9" 272 | urllib3 = ">=1.21.1,<1.25" 273 | 274 | [[package]] 275 | category = "main" 276 | description = "Simple, fast, extensible JSON encoder/decoder for Python" 277 | name = "simplejson" 278 | optional = false 279 | python-versions = "*" 280 | version = "3.16.0" 281 | 282 | [[package]] 283 | category = "main" 284 | description = "Python 2 and 3 compatibility utilities" 285 | name = "six" 286 | optional = false 287 | python-versions = ">=2.6, !=3.0.*, !=3.1.*" 288 | version = "1.12.0" 289 | 290 | [[package]] 291 | category = "dev" 292 | description = "Python Library for Tom's Obvious, Minimal Language" 293 | name = "toml" 294 | optional = false 295 | python-versions = "*" 296 | version = "0.10.0" 297 | 298 | [[package]] 299 | category = "dev" 300 | description = "a fork of Python 2 and 3 ast modules with type comment support" 301 | name = "typed-ast" 302 | optional = false 303 | python-versions = "*" 304 | version = "1.1.1" 305 | 306 | [[package]] 307 | category = "main" 308 | description = "HTTP library with thread-safe connection pooling, file post, and more." 309 | name = "urllib3" 310 | optional = true 311 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4" 312 | version = "1.24.1" 313 | 314 | [[package]] 315 | category = "main" 316 | description = "WebSocket client for Python. hybi13 is supported." 317 | name = "websocket-client" 318 | optional = true 319 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 320 | version = "0.54.0" 321 | 322 | [package.dependencies] 323 | six = "*" 324 | 325 | [extras] 326 | docker = ["docker"] 327 | 328 | [metadata] 329 | content-hash = "7651e98e8658ffbd810139adeb1f1b3fe437c4c3e5a26b9522587d40bcfd2970" 330 | python-versions = "^3.7" 331 | 332 | [metadata.hashes] 333 | appdirs = ["9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", "d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"] 334 | atomicwrites = ["0312ad34fcad8fac3704d441f7b317e50af620823353ec657a53e981f92920c0", "ec9ae8adaae229e4f8446952d204a3e4b5fdd2d099f9be3aaf556120135fb3ee"] 335 | attrs = ["10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69", "ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb"] 336 | black = ["817243426042db1d36617910df579a54f1afd659adb96fc5032fcf4b36209739", "e030a9a28f542debc08acceb273f228ac422798e5215ba2a791a6ddeaaca22a5"] 337 | canonicaljson = ["45bce530ff5fd0ca93703f71bfb66de740a894a3b5dd6122398c6d8f18539725", "e7fe5b1d5a2b740c188ec2860fad0578aabff2cc59edcb41c02eb4d472b95ded"] 338 | certifi = ["47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7", "993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033"] 339 | chardet = ["84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", "fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"] 340 | click = ["2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", "5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"] 341 | colorama = ["05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", "f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"] 342 | coverage = ["06123b58a1410873e22134ca2d88bd36680479fe354955b3579fb8ff150e4d27", "09e47c529ff77bf042ecfe858fb55c3e3eb97aac2c87f0349ab5a7efd6b3939f", "0a1f9b0eb3aa15c990c328535655847b3420231af299386cfe5efc98f9c250fe", "0cc941b37b8c2ececfed341444a456912e740ecf515d560de58b9a76562d966d", "0d34245f824cc3140150ab7848d08b7e2ba67ada959d77619c986f2062e1f0e8", "10e8af18d1315de936d67775d3a814cc81d0747a1a0312d84e27ae5610e313b0", "1b4276550b86caa60606bd3572b52769860a81a70754a54acc8ba789ce74d607", "1e8a2627c48266c7b813975335cfdea58c706fe36f607c97d9392e61502dc79d", "258b21c5cafb0c3768861a6df3ab0cfb4d8b495eee5ec660e16f928bf7385390", "2b224052bfd801beb7478b03e8a66f3f25ea56ea488922e98903914ac9ac930b", "3ad59c84c502cd134b0088ca9038d100e8fb5081bbd5ccca4863f3804d81f61d", "447c450a093766744ab53bf1e7063ec82866f27bcb4f4c907da25ad293bba7e3", "46101fc20c6f6568561cdd15a54018bb42980954b79aa46da8ae6f008066a30e", "4710dc676bb4b779c4361b54eb308bc84d64a2fa3d78e5f7228921eccce5d815", "510986f9a280cd05189b42eee2b69fecdf5bf9651d4cd315ea21d24a964a3c36", "5535dda5739257effef56e49a1c51c71f1d37a6e5607bb25a5eee507c59580d1", "5a7524042014642b39b1fcae85fb37556c200e64ec90824ae9ecf7b667ccfc14", "5f55028169ef85e1fa8e4b8b1b91c0b3b0fa3297c4fb22990d46ff01d22c2d6c", "6694d5573e7790a0e8d3d177d7a416ca5f5c150742ee703f3c18df76260de794", "6831e1ac20ac52634da606b658b0b2712d26984999c9d93f0c6e59fe62ca741b", "71afc1f5cd72ab97330126b566bbf4e8661aab7449f08895d21a5d08c6b051ff", "7349c27128334f787ae63ab49d90bf6d47c7288c63a0a5dfaa319d4b4541dd2c", "77f0d9fa5e10d03aa4528436e33423bfa3718b86c646615f04616294c935f840", "828ad813c7cdc2e71dcf141912c685bfe4b548c0e6d9540db6418b807c345ddd", "859714036274a75e6e57c7bab0c47a4602d2a8cfaaa33bbdb68c8359b2ed4f5c", "85a06c61598b14b015d4df233d249cd5abfa61084ef5b9f64a48e997fd829a82", "869ef4a19f6e4c6987e18b315721b8b971f7048e6eaea29c066854242b4e98d9", "8cb4febad0f0b26c6f62e1628f2053954ad2c555d67660f28dfb1b0496711952", "977e2d9a646773cc7428cdd9a34b069d6ee254fadfb4d09b3f430e95472f3cf3", "99bd767c49c775b79fdcd2eabff405f1063d9d959039c0bdd720527a7738748a", "a5c58664b23b248b16b96253880b2868fb34358911400a7ba39d7f6399935389", "aaa0f296e503cda4bc07566f592cd7a28779d433f3a23c48082af425d6d5a78f", "ab235d9fe64833f12d1334d29b558aacedfbca2356dfb9691f2d0d38a8a7bfb4", "b3b0c8f660fae65eac74fbf003f3103769b90012ae7a460863010539bb7a80da", "bab8e6d510d2ea0f1d14f12642e3f35cefa47a9b2e4c7cea1852b52bc9c49647", "c45297bbdbc8bb79b02cf41417d63352b70bcb76f1bbb1ee7d47b3e89e42f95d", "d19bca47c8a01b92640c614a9147b081a1974f69168ecd494687c827109e8f42", "d64b4340a0c488a9e79b66ec9f9d77d02b99b772c8b8afd46c1294c1d39ca478", "da969da069a82bbb5300b59161d8d7c8d423bc4ccd3b410a9b4d8932aeefc14b", "ed02c7539705696ecb7dc9d476d861f3904a8d2b7e894bd418994920935d36bb", "ee5b8abc35b549012e03a7b1e86c09491457dba6c94112a2482b18589cc2bdb9"] 343 | docker = ["145c673f531df772a957bd1ebc49fc5a366bcd55efa0e64bbd029f5cc7a1fd8e", "666611862edded75f6049893f779bff629fdcd4cd21ccf01d648626e709adb13"] 344 | docker-pycreds = ["6ce3270bcaf404cc4c3e27e4b6c70d3521deae82fb508767870fdbf772d584d4", "7266112468627868005106ec19cd0d722702d2b7d5912a28e19b826c3d37af49"] 345 | frozendict = ["774179f22db2ef8a106e9c38d4d1f8503864603db08de2e33be5b778230f6e45"] 346 | idna = ["c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", "ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"] 347 | more-itertools = ["38a936c0a6d98a38bcc2d03fdaaedaba9f412879461dd2ceff8d37564d6522e4", "c0a5785b1109a6bd7fac76d6837fd1feca158e54e521ccd2ae8bfe393cc9d4fc", "fe7a7cae1ccb57d33952113ff4fa1bc5f879963600ed74918f1236e212ee50b9"] 348 | mypy = ["12d965c9c4e8a625673aec493162cf390e66de12ef176b1f4821ac00d55f3ab3", "38d5b5f835a81817dcc0af8d155bce4e9aefa03794fe32ed154d6612e83feafa"] 349 | mypy-extensions = ["37e0e956f41369209a3d5f34580150bcacfabaa57b33a15c0b25f4b5725e0812", "b16cabe759f55e3409a7d231ebd2841378fb0c27a5d1994719e340e4f429ac3e"] 350 | pluggy = ["447ba94990e8014ee25ec853339faf7b0fc8050cdc3289d4d71f7f410fb90095", "bde19360a8ec4dfd8a20dcb811780a30998101f078fc7ded6162f0076f50508f"] 351 | py = ["bf92637198836372b520efcba9e020c330123be8ce527e535d185ed4b6f45694", "e76826342cefe3c3d5f7e8ee4316b80d1dd8a300781612ddbc765c17ba25a6c6"] 352 | pypiwin32 = ["67adf399debc1d5d14dffc1ab5acacb800da569754fafdc576b2a039485aa775", "71be40c1fbd28594214ecaecb58e7aa8b708eabfa0125c8a109ebd51edbd776a"] 353 | pytest = ["3f193df1cfe1d1609d4c583838bea3d532b18d6160fd3f55c9447fdca30848ec", "e246cf173c01169b9617fc07264b7b1316e78d7a650055235d6d897bc80d9660"] 354 | pytest-black = ["f5faabfb470d3fa5a56f11e15443c5fe624b8ceef03470f17a2c407bcf7912d7"] 355 | pytest-cov = ["0ab664b25c6aa9716cbf203b17ddb301932383046082c081b9848a0edf5add33", "230ef817450ab0699c6cc3c9c8f7a829c34674456f2ed8df1fe1d39780f7c87f"] 356 | pytest-mypy = ["8f6436eed8118afd6c10a82b3b60fb537336736b0fd7a29262a656ac42ce01ac", "acc653210e7d8d5c72845a5248f00fd33f4f3379ca13fe56cfc7b749b5655c3e"] 357 | pywin32 = ["22e218832a54ed206452c8f3ca9eff07ef327f8e597569a4c2828be5eaa09a77", "32b37abafbfeddb0fe718008d6aada5a71efa2874f068bee1f9e703983dcc49a", "35451edb44162d2f603b5b18bd427bc88fcbc74849eaa7a7e7cfe0f507e5c0c8", "4eda2e1e50faa706ff8226195b84fbcbd542b08c842a9b15e303589f85bfb41c", "5f265d72588806e134c8e1ede8561739071626ea4cc25c12d526aa7b82416ae5", "6852ceac5fdd7a146b570655c37d9eacd520ed1eaeec051ff41c6fc94243d8bf", "6dbc4219fe45ece6a0cc6baafe0105604fdee551b5e876dc475d3955b77190ec", "9bd07746ce7f2198021a9fa187fa80df7b221ec5e4c234ab6f00ea355a3baf99"] 358 | requests = ["502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", "7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b"] 359 | simplejson = ["067a7177ddfa32e1483ba5169ebea1bc2ea27f224853211ca669325648ca5642", "2b8cb601d9ba0381499db719ccc9dfbb2fbd16013f5ff096b1a68a4775576a04", "2c139daf167b96f21542248f8e0a06596c9b9a7a41c162cc5c9ee9f3833c93cd", "2fc546e6af49fb45b93bbe878dea4c48edc34083729c0abd09981fe55bdf7f91", "354fa32b02885e6dae925f1b5bbf842c333c1e11ea5453ddd67309dc31fdb40a", "37e685986cf6f8144607f90340cff72d36acf654f3653a6c47b84c5c38d00df7", "3af610ee72efbe644e19d5eaad575c73fb83026192114e5f6719f4901097fce2", "3b919fc9cf508f13b929a9b274c40786036b31ad28657819b3b9ba44ba651f50", "3dd289368bbd064974d9a5961101f080e939cbe051e6689a193c99fb6e9ac89b", "491de7acc423e871a814500eb2dcea8aa66c4a4b1b4825d18f756cdf58e370cb", "495511fe5f10ccf4e3ed4fc0c48318f533654db6c47ecbc970b4ed215c791968", "65b41a5cda006cfa7c66eabbcf96aa704a6be2a5856095b9e2fd8c293bad2b46", "6c3258ffff58712818a233b9737fe4be943d306c40cf63d14ddc82ba563f483a", "75e3f0b12c28945c08f54350d91e624f8dd580ab74fd4f1bbea54bc6b0165610", "79b129fe65fdf3765440f7a73edaffc89ae9e7885d4e2adafe6aa37913a00fbb", "b1f329139ba647a9548aa05fb95d046b4a677643070dc2afc05fa2e975d09ca5", "c206f47cbf9f32b573c9885f0ec813d2622976cf5effcf7e472344bc2e020ac1", "d8e238f20bcf70063ee8691d4a72162bcec1f4c38f83c93e6851e72ad545dabb", "ee9625fc8ee164902dfbb0ff932b26df112da9f871c32f0f9c1bcf20c350fe2a", "fb2530b53c28f0d4d84990e945c2ebb470edb469d63e389bf02ff409012fe7c5", "feadb95170e45f439455354904768608e356c5b174ca30b3d11b0e3f24b5c0df"] 360 | six = ["3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", "d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"] 361 | toml = ["229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e", "f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"] 362 | typed-ast = ["0555eca1671ebe09eb5f2176723826f6f44cca5060502fea259de9b0e893ab53", "0ca96128ea66163aea13911c9b4b661cb345eb729a20be15c034271360fc7474", "16ccd06d614cf81b96de42a37679af12526ea25a208bce3da2d9226f44563868", "1e21ae7b49a3f744958ffad1737dfbdb43e1137503ccc59f4e32c4ac33b0bd1c", "37670c6fd857b5eb68aa5d193e14098354783b5138de482afa401cc2644f5a7f", "46d84c8e3806619ece595aaf4f37743083f9454c9ea68a517f1daa05126daf1d", "5b972bbb3819ece283a67358103cc6671da3646397b06e7acea558444daf54b2", "6306ffa64922a7b58ee2e8d6f207813460ca5a90213b4a400c2e730375049246", "6cb25dc95078931ecbd6cbcc4178d1b8ae8f2b513ae9c3bd0b7f81c2191db4c6", "7e19d439fee23620dea6468d85bfe529b873dace39b7e5b0c82c7099681f8a22", "7f5cd83af6b3ca9757e1127d852f497d11c7b09b4716c355acfbebf783d028da", "81e885a713e06faeef37223a5b1167615db87f947ecc73f815b9d1bbd6b585be", "94af325c9fe354019a29f9016277c547ad5d8a2d98a02806f27a7436b2da6735", "b1e5445c6075f509d5764b84ce641a1535748801253b97f3b7ea9d948a22853a", "cb061a959fec9a514d243831c514b51ccb940b58a5ce572a4e209810f2507dcf", "cc8d0b703d573cbabe0d51c9d68ab68df42a81409e4ed6af45a04a95484b96a5", "da0afa955865920edb146926455ec49da20965389982f91e926389666f5cf86a", "dc76738331d61818ce0b90647aedde17bbba3d3f9e969d83c1d9087b4f978862", "e7ec9a1445d27dbd0446568035f7106fa899a36f55e52ade28020f7b3845180d", "f741ba03feb480061ab91a465d1a3ed2d40b52822ada5b4017770dfcb88f839f", "fe800a58547dd424cd286b7270b967b5b3316b993d86453ede184a17b5a6b17d"] 363 | urllib3 = ["61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39", "de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22"] 364 | websocket-client = ["8c8bf2d4f800c3ed952df206b18c28f7070d9e3dcbd6ca6291127574f57ee786", "e51562c91ddb8148e791f0155fdb01325d99bb52c4cdbb291aee7a3563fd0849"] 365 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "cnab" 3 | version = "0.1.7" 4 | description = "A module for working with Cloud Native Application Bundles in Python" 5 | authors = ["Gareth Rushgrove "] 6 | license = "Apache-2.0" 7 | readme = "README.md" 8 | repository = "https://github.com/garethr/pycnab" 9 | keywords = ["cnab"] 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.7" 13 | docker = { version = "^3.6", optional = true } 14 | canonicaljson = "^1.1" 15 | 16 | [tool.poetry.extras] 17 | docker = ["docker"] 18 | 19 | [tool.poetry.dev-dependencies] 20 | pytest = "^3.4" 21 | pytest-black = "^0.3.2" 22 | pytest-mypy = "^0.3.2" 23 | pytest-cov = "^2.6" 24 | 25 | [build-system] 26 | requires = ["poetry>=0.12"] 27 | build-backend = "poetry.masonry.api" 28 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --black --mypy --verbose --ignore scratch --ignore examples --cov=cnab --cov-report term-missing 3 | filterwarnings = 4 | ignore::DeprecationWarning 5 | 6 | markers = 7 | docker: tests which rely on Docker 8 | --------------------------------------------------------------------------------