kaas-cli 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kaas_cli-0.1.1/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2024 Runtimeverification, Inc.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files KaaS (K as a Service), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.1
2
+ Name: kaas-cli
3
+ Version: 0.1.1
4
+ Summary: Command line utility for K as a Service
5
+ Author: Runtime Verification, Inc.
6
+ Author-email: contact@runtimeverification.com
7
+ Requires-Python: >=3.10,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Requires-Dist: gql[requests] (>=3.4.0,<4.0.0)
13
+ Requires-Dist: hurry-filesize (>=0.9,<0.10)
14
+ Requires-Dist: jmespath (>=1.0.1,<2.0.0)
15
+ Requires-Dist: pytest (>=8.0.1,<9.0.0)
16
+ Requires-Dist: pytest-watch (>=4.2.0,<5.0.0)
17
+ Requires-Dist: python-dotenv (>=1.0.1,<2.0.0)
18
+ Requires-Dist: requests (>=2.31.0,<3.0.0)
19
+ Requires-Dist: types-jmespath (>=1.0.2.20240106,<2.0.0.0)
20
+ Requires-Dist: urllib3 (>=2.1.0,<3.0.0)
21
+ Description-Content-Type: text/markdown
22
+
23
+ # KaaS CLI
24
+
25
+ ## For Developers
26
+
27
+ Prerequisites: `python >= 3.10`, `pip >= 20.0.2`, `poetry >= 1.3.2`.
28
+
29
+ ### Installation
30
+
31
+ To install the package using `pip`, start by building it:
32
+
33
+ ```bash
34
+ make build
35
+ pip install dist/*.whl
36
+ ```
37
+
38
+ Configure the CLI by copying the example environment file and setting up the necessary environment variables:
39
+
40
+ ```bash
41
+ cp .flaskenv.example .flaskenv
42
+ ```
43
+
44
+ Then, edit the `.flaskenv` file to match your settings.
45
+
46
+ ### Environment Variables
47
+
48
+ Here's an overview of the environment variables:
49
+
50
+ - **SERVER_URL**: The KaaS server API address for the main interaction within the CLI tool. This is a required field. For local development, use `http://localhost:5000`.
51
+ - **DEFAULT_DIRECTORY**: The folder path for artifacts. This is an optional field. You can leave it empty.
52
+ - **DEFAULT_PROJECT_ID**: Artifacts should be associated with a project ID. This is an optional field. You can leave it empty.
53
+ - **DEFAULT_TOKEN**: If the user is not the owner of the project, they are required to provide a security token. This is an optional field. You can leave it empty.
54
+
55
+ ### Usage
56
+
57
+ After installing the dependencies with `poetry install`, you can spawn a shell using `poetry shell`, or alternatively, use `make`:
58
+
59
+ ```bash
60
+ make shell
61
+ kaas-cli hello
62
+ ```
63
+
64
+ To verify the installation, run `kaas-cli hello`. If you see the message `Hello World!`, the CLI is set up correctly.
65
+
66
+ ### Documentation
67
+
68
+ For detailed usage instructions of the `kaas-cli` tool, please refer to the official [documentation](https://docs.runtimeverification.com/kaas/guides/getting-started).
69
+
@@ -0,0 +1,46 @@
1
+ # KaaS CLI
2
+
3
+ ## For Developers
4
+
5
+ Prerequisites: `python >= 3.10`, `pip >= 20.0.2`, `poetry >= 1.3.2`.
6
+
7
+ ### Installation
8
+
9
+ To install the package using `pip`, start by building it:
10
+
11
+ ```bash
12
+ make build
13
+ pip install dist/*.whl
14
+ ```
15
+
16
+ Configure the CLI by copying the example environment file and setting up the necessary environment variables:
17
+
18
+ ```bash
19
+ cp .flaskenv.example .flaskenv
20
+ ```
21
+
22
+ Then, edit the `.flaskenv` file to match your settings.
23
+
24
+ ### Environment Variables
25
+
26
+ Here's an overview of the environment variables:
27
+
28
+ - **SERVER_URL**: The KaaS server API address for the main interaction within the CLI tool. This is a required field. For local development, use `http://localhost:5000`.
29
+ - **DEFAULT_DIRECTORY**: The folder path for artifacts. This is an optional field. You can leave it empty.
30
+ - **DEFAULT_PROJECT_ID**: Artifacts should be associated with a project ID. This is an optional field. You can leave it empty.
31
+ - **DEFAULT_TOKEN**: If the user is not the owner of the project, they are required to provide a security token. This is an optional field. You can leave it empty.
32
+
33
+ ### Usage
34
+
35
+ After installing the dependencies with `poetry install`, you can spawn a shell using `poetry shell`, or alternatively, use `make`:
36
+
37
+ ```bash
38
+ make shell
39
+ kaas-cli hello
40
+ ```
41
+
42
+ To verify the installation, run `kaas-cli hello`. If you see the message `Hello World!`, the CLI is set up correctly.
43
+
44
+ ### Documentation
45
+
46
+ For detailed usage instructions of the `kaas-cli` tool, please refer to the official [documentation](https://docs.runtimeverification.com/kaas/guides/getting-started).
@@ -0,0 +1,78 @@
1
+ [project.urls]
2
+ Homepage = "https://github.com/runtimeverification/kaas"
3
+ Issues = "https://github.com/runtimeverification/kaas"
4
+
5
+ [build-system]
6
+ requires = ["poetry-core"]
7
+ build-backend = "poetry.core.masonry.api"
8
+
9
+ [tool.poetry]
10
+ name = "kaas-cli"
11
+ version = "0.1.1"
12
+ description = "Command line utility for K as a Service"
13
+ authors = [
14
+ "Runtime Verification, Inc. <contact@runtimeverification.com>",
15
+ ]
16
+ readme = "README.md"
17
+
18
+ [tool.poetry.dependencies]
19
+ python = "^3.10"
20
+ gql = { version = "^3.4.0", extras = [ "requests" ] }
21
+ urllib3 = "^2.1.0"
22
+ hurry-filesize = "^0.9"
23
+ pytest-watch = "^4.2.0"
24
+ python-dotenv = "^1.0.1"
25
+ pytest = "^8.0.1"
26
+ jmespath = "^1.0.1"
27
+ types-jmespath = "^1.0.2.20240106"
28
+ requests = "^2.31.0"
29
+
30
+ [tool.poetry.group.dev.dependencies]
31
+ autoflake = "*"
32
+ black = "*"
33
+ flake8 = "*"
34
+ flake8-bugbear = "*"
35
+ flake8-comprehensions = "*"
36
+ flake8-quotes = "*"
37
+ flake8-type-checking = "*"
38
+ isort = "*"
39
+ mypy = "*"
40
+ pep8-naming = "*"
41
+ pytest = "*"
42
+ pytest-cov = "*"
43
+ pytest-mock = "*"
44
+ pytest-xdist = "*"
45
+ pyupgrade = "*"
46
+ typer = "*"
47
+ types-requests = "*"
48
+
49
+ [tool.poetry.scripts]
50
+ kaas-cli = "kaas_cli.__main__:main"
51
+
52
+ [tool.isort]
53
+ profile = "black"
54
+ line_length = 120
55
+
56
+ [tool.autoflake]
57
+ recursive = true
58
+ expand-star-imports = true
59
+ remove-all-unused-imports = true
60
+ ignore-init-module-imports = true
61
+ remove-duplicate-keys = true
62
+ remove-unused-variables = true
63
+
64
+ [tool.black]
65
+ line-length = 120
66
+ skip-string-normalization = true
67
+
68
+ [tool.mypy]
69
+ disallow_untyped_defs = true
70
+
71
+
72
+ [[tool.mypy.overrides]]
73
+ module = "hurry.*"
74
+ ignore_missing_imports = true
75
+
76
+ [[tool.mypy.overrides]]
77
+ module = "dotenv.*"
78
+ ignore_missing_imports = true
@@ -0,0 +1,2 @@
1
+ __app_name__ = 'kaas-cli'
2
+ __version__ = '0.1.2'
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ import click
6
+
7
+ from .cli import cli # Import the Click group you've defined
8
+ from .config import CustomHelpOption
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Callable
12
+
13
+
14
+ def register_command_aliases() -> None:
15
+ # Collect aliases and their corresponding commands in a list
16
+ aliases_to_register = []
17
+ for _command_name, command in cli.commands.items():
18
+ if isinstance(command, CustomHelpOption):
19
+ # Ensure command.callback is not None before proceeding
20
+ if command.callback is not None:
21
+ for alias in command.aliases:
22
+ # Avoid adding an alias if it's already a command
23
+ if alias not in cli.commands:
24
+ # Add type assertion to ensure command.callback is not None
25
+ callback: Callable[..., Any] = command.callback
26
+ aliases_to_register.append((alias, callback))
27
+
28
+ # Register collected aliases as commands
29
+ for alias, callback in aliases_to_register:
30
+ cli.command(name=alias, cls=click.Command)(callback)
31
+
32
+
33
+ def main() -> None:
34
+ register_command_aliases()
35
+ cli()
36
+
37
+
38
+ if __name__ == "__main__":
39
+ main()
@@ -0,0 +1,205 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import click
6
+
7
+ from .client import KaasClient
8
+ from .config import DEFAULT_DIRECTORY, DEFAULT_PROJECT_ID, DEFAULT_TOKEN, SERVER_URL, CustomHelpOption
9
+
10
+ if TYPE_CHECKING:
11
+ from click.core import Context
12
+
13
+
14
+ @click.group()
15
+ @click.option("--url", "-u", default=SERVER_URL, show_default=True, help="Server URL")
16
+ @click.option("--token", "-t", default=DEFAULT_TOKEN, help="Personal access token for vault")
17
+ @click.option("--vault", "-v", default=DEFAULT_PROJECT_ID, help="Vault hash")
18
+ @click.pass_context
19
+ def cli(ctx: Context, url: str, token: str | None, vault: str | None) -> None:
20
+ """KaaS Command Line Interface"""
21
+ ctx.ensure_object(dict)
22
+ ctx.obj["client"] = KaasClient(url=url, token=token, vault=vault)
23
+
24
+
25
+ @cli.command(cls=CustomHelpOption, help="Upload proofs to the remote server.")
26
+ @click.option(
27
+ "-d",
28
+ "--directory",
29
+ default=DEFAULT_DIRECTORY,
30
+ show_default=True,
31
+ type=click.Path(exists=True, file_okay=False, dir_okay=True),
32
+ help="Directory containing proofs to upload",
33
+ )
34
+ @click.option("--url", "-u", default=SERVER_URL, show_default=True, help="Server URL")
35
+ @click.option("--token", "-t", default=DEFAULT_TOKEN, help="Personal access token for vault")
36
+ @click.option("--vault", "-v", default=DEFAULT_PROJECT_ID, help="Vault hash")
37
+ @click.pass_context
38
+ def upload(ctx: Context, directory: str, url: str | None, token: str | None, vault: str | None) -> None:
39
+ ctx.ensure_object(dict)
40
+ client: KaasClient = KaasClient(url=url, token=token, vault=vault)
41
+ ctx.obj["client"] = client
42
+ response_message = client.upload_files_s3(directory=directory)
43
+ click.echo(response_message)
44
+
45
+
46
+ @cli.command(cls=CustomHelpOption, help="Download proofs from the remote server.")
47
+ @click.option(
48
+ "-d",
49
+ "--directory",
50
+ default=DEFAULT_DIRECTORY,
51
+ show_default=True,
52
+ type=click.Path(exists=False, file_okay=False, dir_okay=True),
53
+ help="Directory to save downloaded proofs",
54
+ )
55
+ @click.option("--url", "-u", default=SERVER_URL, show_default=True, help="Server URL")
56
+ @click.option("--token", "-t", default=DEFAULT_TOKEN, help="Personal access token for vault")
57
+ @click.option("--vault", "-v", default=DEFAULT_PROJECT_ID, help="Vault hash")
58
+ @click.argument("version_address", required=False)
59
+ @click.pass_context
60
+ def download(
61
+ ctx: Context,
62
+ version_address: str | None,
63
+ directory: str, url: str | None, token: str | None, vault: str | None
64
+ ) -> None: # type: ignore
65
+ ctx.ensure_object(dict)
66
+ client: KaasClient = KaasClient(url=url, token=token, vault=vault)
67
+ ctx.obj["client"] = client
68
+ if not version_address:
69
+ message = client.download_last_version(target_directory=directory)
70
+ if message:
71
+ click.echo(message)
72
+ return
73
+
74
+ message = client.download_version(version_address, target_directory=directory)
75
+ if message:
76
+ click.echo(message)
77
+
78
+
79
+ @cli.command(cls=CustomHelpOption, help="Say hello.")
80
+ @click.option("-n", "--name", default="World", show_default=True, help="Name to say hello to")
81
+ @click.pass_context
82
+ def hello(ctx: Context, name: str) -> None:
83
+ client: KaasClient = ctx.obj["client"]
84
+
85
+ response = client.hello(name=name)
86
+ click.echo(response)
87
+
88
+
89
+ @cli.command(cls=CustomHelpOption, help="Login to the system.")
90
+ @click.pass_context
91
+ def login(ctx: Context) -> None:
92
+ client: KaasClient = ctx.obj["client"]
93
+ data = client.login()
94
+ click.echo(f"Your user code: {data.user_code}")
95
+ click.echo(f"Open the link and type your code {data.verification_uri}")
96
+ click.echo("Then hit 'enter'")
97
+ input_value = click.prompt("Press Enter to continue or type 'q' to quit", default="", show_default=False)
98
+ if input_value.lower() == 'q':
99
+ click.echo("You left authentication")
100
+ return
101
+ click.echo("You pressed Enter. The application continues...")
102
+ confirm_success = client.confirm_login(data.device_code).success
103
+ if not confirm_success:
104
+ click.echo("Authentication failed")
105
+ return
106
+ click.echo("Access token received. We store it in cache folder")
107
+
108
+
109
+ @cli.command(cls=CustomHelpOption, help="List local proofs.", aliases=['l', 'ls'])
110
+ @click.option(
111
+ "-d",
112
+ "--directory",
113
+ default=DEFAULT_DIRECTORY,
114
+ show_default=True,
115
+ type=click.Path(exists=True, file_okay=False, dir_okay=True),
116
+ help="Directory to list local proofs",
117
+ )
118
+ @click.option("--remote", is_flag=True, help="List remote proofs instead of local proofs", default=False)
119
+ @click.pass_context
120
+ def list(ctx: Context, directory: str = DEFAULT_DIRECTORY, remote: bool = False) -> None:
121
+ client: KaasClient = ctx.obj["client"]
122
+ if remote:
123
+ proofs = client.list_remote()
124
+ else:
125
+ proofs = client.list_local_proofs(directory=directory)
126
+ if not proofs:
127
+ click.echo('No proofs found')
128
+ for proof in proofs:
129
+ click.echo(proof)
130
+
131
+
132
+ def list_remote(ctx: Context) -> None:
133
+ client: KaasClient = ctx.obj["client"]
134
+ proofs = client.list_remote()
135
+ for proof in proofs:
136
+ click.echo(proof)
137
+
138
+
139
+ @cli.command(cls=CustomHelpOption, name="check-auth", help="Check authentication status.")
140
+ @click.pass_context
141
+ def check_auth(ctx: Context) -> None:
142
+ client: KaasClient = ctx.obj["client"]
143
+ is_authenticated = client.check()
144
+ if is_authenticated:
145
+ click.echo("You are currently authenticated.")
146
+ else:
147
+ click.echo("You are not authenticated. Please log in.")
148
+
149
+
150
+ @cli.command(cls=CustomHelpOption, name="list-vaults", help="List remote vaults owned by your KaaS account.")
151
+ @click.pass_context
152
+ def list_vaults(ctx: Context) -> None:
153
+ client: KaasClient = ctx.obj["client"]
154
+ vaults = client.list_vaults()
155
+
156
+ if not vaults:
157
+ click.echo("No vaults")
158
+ return
159
+
160
+ for vault in vaults:
161
+ click.echo(vault)
162
+
163
+
164
+ @cli.command(cls=CustomHelpOption, name="list-keys", help="List remote keys owned by <VAULT_ADDRESS>")
165
+ @click.argument("vault_address", required=True)
166
+ @click.pass_context
167
+ def list_keys(ctx: Context, vault_address: str) -> None:
168
+ client: KaasClient = ctx.obj["client"]
169
+ keys = client.list_keys(vault_address)
170
+
171
+ if not keys:
172
+ click.echo(f"No keys found for vault {vault_address}")
173
+ return
174
+
175
+ click.echo(f"Keys for vault {vault_address}:")
176
+ for key in keys:
177
+ click.echo(key)
178
+
179
+
180
+ @cli.command(cls=CustomHelpOption, name="list-versions",
181
+ help="List remote versions of a cached artifact, with <VAULT_ADDRESS> as the address of the vault.")
182
+ @click.argument("vault_address", required=True)
183
+ @click.pass_context
184
+ def list_versions(ctx: Context, vault_address: str) -> None:
185
+ client: KaasClient = ctx.obj["client"]
186
+ versions = client.list_versions(vault_address)
187
+
188
+ if not versions:
189
+ click.echo(f"No versions found for vault {vault_address}")
190
+ return
191
+
192
+ click.echo(f"Versions for vault {vault_address}:")
193
+ for version in versions:
194
+ click.echo(version)
195
+
196
+
197
+ @cli.command(cls=CustomHelpOption, name="logout", help="Log out from the system.")
198
+ @click.pass_context
199
+ def logout(ctx: Context) -> None:
200
+ client: KaasClient = ctx.obj["client"]
201
+ logout_success = client.logout()
202
+ if logout_success:
203
+ click.echo("You have been logged out successfully.")
204
+ else:
205
+ click.echo("Logout failed. You may not be logged in.")
@@ -0,0 +1,617 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import pickle
7
+ import shutil
8
+ import tempfile
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any, Final, List
13
+ from uuid import uuid4
14
+ from zipfile import ZipFile
15
+
16
+ import click
17
+ import jmespath
18
+ import requests
19
+ from gql import Client, gql
20
+ from gql.transport.requests import RequestsHTTPTransport
21
+ from hurry.filesize import size
22
+ from requests import JSONDecodeError, Session
23
+
24
+ from kaas_cli.types import KaasCliException
25
+
26
+ if TYPE_CHECKING:
27
+ from .types import File_Data, Metadata
28
+
29
+ from .constants import (
30
+ CONFIG_LOG_PATH,
31
+ CONFIG_SESSION_PATH,
32
+ DEVICE_LOGIN_URL,
33
+ FILES_LIST_URL,
34
+ FILES_URL,
35
+ GRAPHQL_URL,
36
+ UPLOAD_FAILURE_MESSAGE,
37
+ UPLOAD_SUCCESS_MESSAGE,
38
+ USER_URL,
39
+ VAULTS_KEY_URL,
40
+ VAULTS_ROOT_URL,
41
+ )
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class DeviceAuth:
46
+ device_code: str
47
+ expires_in: int
48
+ interval: int
49
+ user_code: str
50
+ verification_uri: str
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Confirmation:
55
+ success: bool
56
+
57
+
58
+ class CustomSession(Session):
59
+ def get(self, *args: Any, **kwargs: Any) -> Any:
60
+ response = super().get(*args, **kwargs)
61
+ try:
62
+ json_response = response.json()
63
+ if 'error' in json_response:
64
+ raise ValueError(json_response['error'], json_response.get('message', 'No message provided'))
65
+ return json_response
66
+ except JSONDecodeError as e:
67
+ logging.error(f"GET request JSON decode failed: {e}")
68
+ raise e
69
+ except ValueError as e:
70
+ logging.error(f"GET request failed: {e}")
71
+ click.echo(f"GET request failed: {e}")
72
+ raise e
73
+
74
+ def post(self, *args: Any, **kwargs: Any) -> Any:
75
+ response = super().post(*args, **kwargs)
76
+ try:
77
+ json_response = response.json()
78
+ if 'error' in json_response:
79
+ raise ValueError(json_response['error'], json_response.get('message', 'No message provided'))
80
+ return json_response
81
+ except JSONDecodeError as e:
82
+ logging.error(f"POST request JSON decode failed: {e}")
83
+ raise e
84
+ except ValueError as e:
85
+ logging.error(f"POST request failed: {e}")
86
+ click.echo(f"POST request failed: {e}")
87
+ raise e
88
+
89
+
90
+ class AuthenticatedSession(CustomSession):
91
+ def __init__(self, access_token: str) -> None:
92
+ super().__init__()
93
+ self.access_token = access_token
94
+ self.headers.update({'Authorization': f'Bearer {self.access_token}'})
95
+
96
+
97
+ class KaasClient:
98
+ _client: Client
99
+ _session: CustomSession
100
+ _url: str
101
+ _token: str | None
102
+ _vault: str | None
103
+
104
+ def __init__(
105
+ self,
106
+ url: str,
107
+ *,
108
+ token: str | None = None,
109
+ vault: str | None = None,
110
+ ) -> None:
111
+ self._url = url
112
+ self._token = token
113
+ self._vault = vault
114
+
115
+ self._configure_logging()
116
+ self._setup_client()
117
+ self._session = CustomSession()
118
+ if token:
119
+ self._session = AuthenticatedSession(token)
120
+ else:
121
+ self._load_session_if_exists()
122
+
123
+ def _setup_client(self) -> None:
124
+ """Setup the GraphQL client."""
125
+ transport = RequestsHTTPTransport(
126
+ url=f'{self._url}{GRAPHQL_URL}',
127
+ verify=True,
128
+ )
129
+ self._client = Client(transport=transport, fetch_schema_from_transport=True)
130
+
131
+ def _configure_logging(self) -> None:
132
+ """Configure logging for the application."""
133
+ if not CONFIG_LOG_PATH.exists():
134
+ CONFIG_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
135
+ CONFIG_LOG_PATH.touch()
136
+ logging.basicConfig(
137
+ filename=CONFIG_LOG_PATH,
138
+ filemode='a',
139
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
140
+ level=logging.DEBUG,
141
+ )
142
+
143
+ def _load_session_if_exists(self) -> None:
144
+ """Load session if the session file exists."""
145
+ if CONFIG_SESSION_PATH.exists():
146
+ self._load_session()
147
+
148
+ def hello(self, name: str | None = None) -> str:
149
+ response = self._client.execute(QUERY_HELLO, variable_values={'name': name} if name is not None else {})
150
+ return response['hello']
151
+
152
+ def _save_session(self, file_path: Path = CONFIG_SESSION_PATH) -> None:
153
+ file_path.parent.mkdir(parents=True, exist_ok=True)
154
+ with file_path.open('wb') as file:
155
+ pickle.dump(self._session, file)
156
+
157
+ def _load_session(self, file_path: Path = CONFIG_SESSION_PATH) -> None:
158
+ with file_path.open('rb') as file:
159
+ self._session = pickle.load(file)
160
+
161
+ def _remove_session(self, file_path: Path = CONFIG_SESSION_PATH) -> bool:
162
+ file_path.unlink()
163
+ return True
164
+
165
+ def _list_local_files(self, directory: str) -> list[Path]:
166
+ return [path for path in Path(directory).glob('**/*') if path.is_file()]
167
+
168
+ def list_local_proofs(self, directory: str) -> list[dict[str, Any]]:
169
+ list_local_files = [
170
+ {
171
+ 'name': self._read_proof(path / 'proof.json').get('id'),
172
+ 'size': size(sum(f.stat().st_size for f in path.glob('**/*') if f.is_file())),
173
+ 'last_update_date': self._read_proof(path / 'proof.json').get('last_update_date'),
174
+ }
175
+ for path in Path(directory).glob('**/*')
176
+ if path.is_dir() and len(path.name.split(':')) == 2
177
+ ]
178
+ return list_local_files
179
+
180
+ def _read_proof(self, proof_path: Path) -> dict[str, Any]:
181
+ if not proof_path.exists():
182
+ return {'id': None, 'last_update_date': None}
183
+
184
+ with proof_path.open() as file:
185
+ data = json.load(file)
186
+ return {
187
+ 'id': data.get('id'),
188
+ 'last_update_date': datetime.fromtimestamp(os.path.getmtime(proof_path)).isoformat(),
189
+ }
190
+
191
+ def list_remote(self) -> Any:
192
+ try:
193
+ json_data = self._session.get(url=f'{self._url}{USER_URL}')
194
+ except Exception:
195
+ raise KaasCliException("List remote proofs failed") from None
196
+ return json_data
197
+
198
+ def _get_default_vault(self) -> str | None:
199
+ try:
200
+ json_data = self._session.get(url=f'{self._url}{USER_URL}')
201
+ except Exception:
202
+ raise KaasCliException("Get default vault failed") from None
203
+ vault_hash = jmespath.search('vaults[0].hash', json_data)
204
+ return vault_hash
205
+
206
+ def _get_upload_urls(self, metadata: dict[str, Any], vault: str) -> dict[str, Any]:
207
+ data = self._session.post(
208
+ url=f'{self._url}{FILES_URL.format(vault)}',
209
+ data=json.dumps(metadata),
210
+ headers={
211
+ 'Content-Type': 'application/json',
212
+ },
213
+ )["data"]
214
+ return data
215
+
216
+ def _upload_presigned_url(self, files: dict[str, Any], directory: str = '') -> dict[str, Any]:
217
+ upload_results: Any = {}
218
+ for file_path_str, url in files.items():
219
+ file_path = Path(directory) / file_path_str
220
+ with file_path.open('rb') as file:
221
+ response = requests.put(url, data=file)
222
+
223
+ # Check if the upload was successful
224
+ if response.status_code in [200, 201]: # Success status codes can vary, e.g., 200 OK or 201 Created
225
+ print(f"Successfully uploaded: {file_path}")
226
+ upload_results[file_path_str] = {'success': True, 'status_code': response.status_code}
227
+ else:
228
+ print(f"Failed to upload {file_path.name} to {url}. Status code: {response.status_code}")
229
+ upload_results[file_path_str] = {
230
+ 'success': False,
231
+ 'status_code': response.status_code,
232
+ 'error_message': response.text,
233
+ }
234
+
235
+ return upload_results
236
+
237
+ def archive_files(self, file_list: List[Path], archive_name: str, archive_format: str = 'zip',
238
+ root_dir: Path = None) -> str:
239
+ """
240
+ Archives a list of files into a single archive file while preserving the directory structure.
241
+
242
+ Args:
243
+ file_list (List[Path]): List of file paths to include in the archive.
244
+ archive_name (str): The name of the output archive file without extension.
245
+ archive_format (str): Format of the archive ('zip', 'tar', etc.). Default is 'zip'.
246
+ root_dir (Path): The root directory to use for preserving the relative paths. If None, the common parent of all files will be used.
247
+
248
+ Returns:
249
+ str: The path to the created archive file.
250
+ """
251
+ if root_dir is None:
252
+ # Find the common parent directory of all files
253
+ common_paths = Path(file_list[0]).parents
254
+ for file_path in file_list[1:]:
255
+ common_paths = set(common_paths) & set(Path(file_path).parents)
256
+ root_dir = min(common_paths, key=lambda p: len(p.parts))
257
+
258
+ # Ensure the archive name does not include an extension
259
+ archive_name = Path(archive_name).with_suffix('')
260
+
261
+ # Create a temporary directory to hold the files to be archived
262
+ with tempfile.TemporaryDirectory() as temp_dir:
263
+ temp_dir_path = Path(temp_dir)
264
+ for file_path in file_list:
265
+ if file_path.is_file():
266
+ # Calculate relative path to the root_dir
267
+ relative_path = file_path.relative_to(root_dir)
268
+ # Create any necessary directories
269
+ (temp_dir_path / relative_path).parent.mkdir(parents=True, exist_ok=True)
270
+ # Copy file to the new location preserving the folder structure
271
+ shutil.copy(file_path, temp_dir_path / relative_path)
272
+
273
+ # Create the archive from the temporary directory
274
+ archive_path = shutil.make_archive(archive_name, archive_format, root_dir=temp_dir_path)
275
+
276
+ # Move the archive to the desired directory and return the new path
277
+ final_archive_path = Path(archive_name).with_suffix(f'.{archive_format}')
278
+ shutil.move(archive_path, final_archive_path)
279
+
280
+ return str(final_archive_path)
281
+
282
+ def get_archive_metadata(self, archive_path: str) -> dict[str, Any]:
283
+ """
284
+ Extracts metadata from an archive file.
285
+
286
+ Args:
287
+ archive_path (str): Path to the archive file.
288
+
289
+ Returns:
290
+ dict[str, Any]: Metadata extracted from the archive file.
291
+ """
292
+ # Initialize an empty dictionary for metadata
293
+ metadata = {}
294
+
295
+ # Create a Path object from the archive_path
296
+ archive_file = Path(archive_path)
297
+
298
+ # Check if the archive file exists
299
+ if not archive_file.exists():
300
+ raise FileNotFoundError(f"The archive file {archive_path} does not exist.")
301
+ # metadata[file_uuid] = {'filename': file_name_str, 'updated_at': updated_at}
302
+ # Try to extract metadata from the archive file
303
+ try:
304
+ metadata[archive_file.name] = {
305
+ 'filename': archive_file.name,
306
+ 'updated_at': datetime.fromtimestamp(archive_file.stat().st_mtime).isoformat(),
307
+ }
308
+ except Exception as e:
309
+ # Handle any exception that might occur during metadata extraction
310
+ raise e
311
+
312
+ return metadata
313
+
314
+ def get_version_from_digest(self, file_list) -> str:
315
+ """
316
+ Reads a JSON file named 'digest' from the provided file list, extracts hash values from specified fields, and returns them as a string.
317
+
318
+ Args:
319
+ file_list (list[Path]): List of file paths.
320
+
321
+ Returns:
322
+ str: A string containing the hash values from the 'digest' file.
323
+ """
324
+ # Find the 'digest' file in the file list
325
+ digest_file = next((file for file in file_list if file.name == 'digest'), None)
326
+ if digest_file is None:
327
+ raise FileNotFoundError("No 'digest' file found in the provided file list.")
328
+
329
+ # Read the 'digest' JSON file
330
+ with open(digest_file, 'r') as file:
331
+ data = json.load(file)
332
+
333
+ # Extract the hash values from specified fields
334
+ kompilation_hash = data.get('kompilation', 'Not found')
335
+ foundry_hash = data.get('foundry', 'Not found')
336
+
337
+ # Return the hash values as a string
338
+ return f"{kompilation_hash}{foundry_hash}"
339
+
340
+ def upload_files_s3(self, directory: str) -> str:
341
+ file_list = self._list_local_files(directory)
342
+ if not file_list or len(file_list) == 0:
343
+ raise ValueError(f'No files to upload in dir: {directory}')
344
+
345
+ archive_name = self.get_version_from_digest(file_list)
346
+ archive_path = self.archive_files(file_list, archive_name, archive_format='zip', root_dir=Path(directory))
347
+ metadata = self.get_archive_metadata(archive_path)
348
+ print(f"Uploading {metadata} to S3...")
349
+ vault: str | None = self._vault or self._get_default_vault()
350
+ if vault is None:
351
+ return UPLOAD_FAILURE_MESSAGE
352
+ urls = self._get_upload_urls(metadata=metadata, vault=vault)
353
+
354
+ try:
355
+ self._upload_presigned_url(urls)
356
+ print(UPLOAD_SUCCESS_MESSAGE)
357
+ except Exception as e:
358
+ raise KaasCliException(f"Failed to upload {archive_path} to S3: {e}") from None
359
+ # remove the archive file after uploading
360
+ os.remove(archive_path)
361
+
362
+ def process_archive(self, archive_path: str, extract_to: str) -> None:
363
+ """
364
+ Extracts files from an archive to a specified directory and then removes the archive.
365
+
366
+ Args:
367
+ archive_path (str): The path to the archive file.
368
+ extract_to (str): The directory to extract the files to.
369
+ """
370
+ # Ensure the target directory exists
371
+ os.makedirs(extract_to, exist_ok=True)
372
+
373
+ # Extract the archive
374
+ with ZipFile(archive_path, 'r') as zip_ref:
375
+ zip_ref.extractall(extract_to)
376
+
377
+ # Remove the archive file after extraction
378
+ os.remove(archive_path)
379
+ print(f"Extracted and removed archive {archive_path}")
380
+
381
+ def download_version(self, version_address: str, target_directory: str) -> Any:
382
+ vault_address = version_address.split('/')[0]
383
+ version = version_address.split('/')[1]
384
+ urls = self._get_download_url(vault_address)['data']
385
+ url = urls[version_address]['url']
386
+ if not url:
387
+ return f'Version {version_address} not found'
388
+
389
+ try:
390
+ downloaded_file_path = self.replace_path(version, target_directory)
391
+
392
+ self.download_file(url, self.replace_path(version, target_directory))
393
+ self.process_archive(downloaded_file_path, target_directory)
394
+ except Exception:
395
+ raise KaasCliException(f"Download {version} failed") from None
396
+
397
+ return f'Version {version} downloaded to {target_directory}'
398
+
399
+ def download_last_version(self, target_directory: str) -> Any:
400
+ vault_hash = self._vault or self._get_default_vault()
401
+ if not vault_hash:
402
+ return "Can't find default vault"
403
+
404
+ urls = self._get_download_url(vault_hash)
405
+
406
+ urls_data = urls.get('data', {})
407
+ if not urls_data:
408
+ return 'Something went wrong'
409
+
410
+ # Find the file with the latest date
411
+ latest_file = max(urls_data.items(), key=lambda x: x[1]['lastModified'])[0]
412
+ url = urls_data[latest_file]['url']
413
+
414
+ try:
415
+ downloaded_file_path = self.replace_path(latest_file, target_directory)
416
+ self.download_file(url, self.replace_path(latest_file, target_directory))
417
+ self.process_archive(downloaded_file_path, target_directory)
418
+ except Exception:
419
+ raise KaasCliException(f"Download {latest_file} failed") from None
420
+
421
+ return f'Latest version of {latest_file} downloaded to {target_directory}'
422
+
423
+ def process_archive(self, archive_path: str, extract_to: str) -> None:
424
+ """
425
+ Extracts files from an archive to a specified directory and then removes the archive.
426
+
427
+ Args:
428
+ archive_path (str): The path to the archive file.
429
+ extract_to (str): The directory to extract the files to.
430
+ """
431
+ # Ensure the target directory exists
432
+ os.makedirs(extract_to, exist_ok=True)
433
+
434
+ # Extract the archive
435
+ with ZipFile(archive_path, 'r') as zip_ref:
436
+ zip_ref.extractall(extract_to)
437
+
438
+ # Remove the archive file after extraction
439
+ os.remove(archive_path)
440
+ print(f"Extracted and removed archive {archive_path}")
441
+
442
+ def download_last_version(self, target_directory: str) -> Any:
443
+ vault_hash = self._vault or self._get_default_vault()
444
+ if not vault_hash:
445
+ return "Can't find default vault"
446
+
447
+ urls = self._get_download_url(vault_hash)
448
+
449
+ urls_data = urls.get('data', {})
450
+ if not urls_data:
451
+ return 'Something went wrong'
452
+
453
+
454
+ # Find the file with the latest date
455
+ latest_file = max(urls_data.items(), key=lambda x: x[1]['lastModified'])[0]
456
+ url = urls_data[latest_file]['url']
457
+
458
+ try:
459
+ downloaded_file_path = self.replace_path(latest_file, target_directory)
460
+ self.download_file(url, self.replace_path(latest_file, target_directory))
461
+ self.process_archive(downloaded_file_path, target_directory)
462
+ except Exception:
463
+ raise KaasCliException(f"Download {latest_file} failed") from None
464
+
465
+ return f'Latest version of {latest_file} downloaded to {target_directory}'
466
+
467
+ def download_vault(self, target_directory: str) -> Any:
468
+ vault_hash = self._vault or self._get_default_vault()
469
+ if not vault_hash:
470
+ return "Can't find default vault"
471
+
472
+ urls = self._get_download_url(vault_hash)
473
+
474
+ urls_data = urls.get('data', {})
475
+ if not urls_data:
476
+ return 'Something went wrong'
477
+
478
+ for path in urls_data:
479
+ print(path)
480
+
481
+ url = urls_data[path]
482
+ try:
483
+ self.download_file(url, self.replace_path(path, target_directory))
484
+ except Exception:
485
+ raise KaasCliException(f"Download {path} failed") from None
486
+
487
+ return f'{len(urls_data)} Files downloaded to {target_directory}'
488
+
489
+ @staticmethod
490
+ def _get_upload_data(file_list: list[Path], directory: Path) -> tuple[File_Data, Metadata]:
491
+ data: File_Data = {}
492
+ metadata = {}
493
+
494
+ for file_path in file_list:
495
+ file_uuid = str(uuid4())
496
+ file_name_str = Path(file_path).relative_to(directory).as_posix()
497
+ with file_path.open('rb') as file_object:
498
+ data[f'file_{file_uuid}'] = (file_name_str, file_object.read(), None)
499
+ updated_at = datetime.fromtimestamp(os.path.getmtime(file_path)).strftime('%Y-%m-%d %H:%M:%S')
500
+ # Ensure the file path is converted to a string
501
+ metadata[file_uuid] = {'filename': file_name_str, 'updated_at': updated_at}
502
+
503
+ data['metadata'] = (None, json.dumps(metadata), 'application/json')
504
+
505
+ return data, metadata
506
+
507
+ @staticmethod
508
+ def _get_metadata(file_list: list[Path], directory: Path) -> Metadata:
509
+ data, metadata = KaasClient._get_upload_data(file_list, Path(directory))
510
+ return metadata
511
+
512
+ def login(self) -> DeviceAuth:
513
+ try:
514
+ data = self._session.post(url=f'{self._url}{DEVICE_LOGIN_URL}')
515
+ except Exception:
516
+ raise KaasCliException("Login failed") from None
517
+ return DeviceAuth(**data)
518
+
519
+ def confirm_login(self, device_code: str) -> Confirmation:
520
+ try:
521
+ data = self._session.get(url=f'{self._url}{DEVICE_LOGIN_URL}', params={'device_code': device_code})
522
+ except Exception:
523
+ raise KaasCliException("Login failed") from None
524
+ self._session = AuthenticatedSession(data['token'])
525
+ self._save_session()
526
+ return Confirmation(True)
527
+
528
+ def check(self) -> Confirmation:
529
+ data = self._session.get(url=f'{self._url}{USER_URL}')
530
+ if data:
531
+ return Confirmation(True)
532
+ else:
533
+ return Confirmation(False)
534
+
535
+ def _get_download_url(self, vault_hash: str) -> dict:
536
+ data = self._session.get(url=f'{self._url}{FILES_URL.format(vault_hash)}')
537
+ return data
538
+
539
+ def download_file(self, url: str, folder_path: str) -> None:
540
+ file_path = Path(folder_path)
541
+
542
+ file_path.parent.mkdir(parents=True, exist_ok=True)
543
+ with requests.get(url, stream=True) as response:
544
+ # Check if the request was successful
545
+ response.raise_for_status()
546
+ # Open a local file with write-binary mode in the specified folder
547
+ with open(file_path, 'wb') as file:
548
+ # Write the content of the response to the file in chunks
549
+ for chunk in response.iter_content(chunk_size=8192):
550
+ file.write(chunk)
551
+
552
+ def replace_path(self, input_string: str, target_directory: str) -> str:
553
+
554
+ # Find the index of the first dash '/'
555
+ dash_index = input_string.find('/')
556
+
557
+ # Replace everything up to the first dash with 'test'
558
+ result = target_directory + input_string[dash_index:]
559
+
560
+ return result
561
+
562
+ def read_new_files(self, files: list[str], target_directory: str) -> list[str]:
563
+ new_files = []
564
+ for file_name in files:
565
+ file_path = Path(target_directory, file_name)
566
+ if not file_path.exists():
567
+ new_files.append(file_name)
568
+ return new_files
569
+
570
+ def pull_remote_files(self, files: list[str]) -> list[str]:
571
+ try:
572
+ data = self._session.get(url=f'{self._url}{FILES_LIST_URL}', params={'files': files})
573
+ except Exception:
574
+ raise KaasCliException("Pull remote files failed") from None
575
+ return data
576
+
577
+ def list_vaults(self) -> list[str]:
578
+ try:
579
+ data = self._session.get(url=f'{self._url}{VAULTS_ROOT_URL}')
580
+ except Exception:
581
+ raise KaasCliException("List vaults failed") from None
582
+ return data
583
+
584
+ def list_keys(self, vault: str) -> list[str]:
585
+ try:
586
+ data = self._session.get(url=f'{self._url}{VAULTS_KEY_URL.format(vault)}')['data']
587
+ except Exception:
588
+ raise KaasCliException("List vaults failed") from None
589
+ return data
590
+
591
+ def list_versions(self, vault: str) -> list[str]:
592
+ try:
593
+ data = self._get_download_url(vault)
594
+ print(data)
595
+ except Exception:
596
+ raise KaasCliException("List vaults failed") from None
597
+ return data
598
+
599
+ def logout(self) -> bool:
600
+ """
601
+ Log out the user by clearing the session and authentication token.
602
+ Returns True if the logout was successful, False otherwise.
603
+ """
604
+ try:
605
+ return self._remove_session()
606
+ except Exception as e:
607
+ logging.error(f"Logout failed: {e}")
608
+ return False
609
+
610
+
611
+ QUERY_HELLO: Final = gql(
612
+ """
613
+ query Hello($name: String!) {
614
+ hello(name: $name)
615
+ }
616
+ """
617
+ )
@@ -0,0 +1,36 @@
1
+ import os
2
+ from typing import Any
3
+
4
+ import click
5
+ from dotenv import load_dotenv
6
+
7
+ load_dotenv()
8
+
9
+ from dotenv import load_dotenv
10
+
11
+ from .constants import DEFAULT_K_OUT_FOLDER, DEFAULT_PROD_SERVER_URL
12
+
13
+ # Load environment variables from .flaskenv file
14
+ load_dotenv('.flaskenv')
15
+
16
+ DEFAULT_DIRECTORY = os.getenv('DEFAULT_DIRECTORY') or DEFAULT_K_OUT_FOLDER
17
+ SERVER_URL = os.getenv('SERVER_URL') or DEFAULT_PROD_SERVER_URL
18
+ DEFAULT_PROJECT_ID = os.getenv('DEFAULT_PROJECT_ID')
19
+ DEFAULT_TOKEN = os.getenv('DEFAULT_TOKEN')
20
+
21
+
22
+ class CustomHelpOption(click.Command):
23
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
24
+ self.aliases = kwargs.pop('aliases', [])
25
+ super().__init__(*args, **kwargs)
26
+
27
+ def format_aliases(self) -> str:
28
+ return f"Aliases: {', '.join(repr(alias) for alias in self.aliases)}" if self.aliases else ""
29
+
30
+ def get_short_help_str(self, limit: int = 45) -> str:
31
+ short_help = super().get_short_help_str(limit)
32
+ aliases_text = self.format_aliases()
33
+ return f"{short_help}. {aliases_text}" if aliases_text else short_help
34
+
35
+ def get_help(self, ctx):
36
+ return super().get_help(ctx)
@@ -0,0 +1,27 @@
1
+ from pathlib import Path
2
+ from typing import Final
3
+
4
+ CONFIG_DIR_PATH: Final = Path(__file__).parent.parent / 'config'
5
+ CONFIG_FILE_PATH: Final = CONFIG_DIR_PATH / 'config.ini'
6
+ CONFIG_LOG_PATH: Final = CONFIG_DIR_PATH / 'kaas.log'
7
+ CONFIG_SESSION_PATH: Final = CONFIG_DIR_PATH / 'session.pkl'
8
+
9
+ GRAPHQL_URL: Final = '/graphql'
10
+ FILES_ROOT_URL: Final = '/api/files'
11
+ FILES_URL: Final = '/api/files/{}/url'
12
+ FILES_LIST_URL: Final = '/api/files/list'
13
+ FILES_UPLOAD_URL: Final = '/api/files/upload'
14
+
15
+ DEVICE_LOGIN_URL: Final = '/api/login/github/device'
16
+ USER_URL: Final = '/api/user/'
17
+
18
+ VAULTS_ROOT_URL: Final = '/api/vaults/'
19
+ VAULTS_KEY_URL: Final = '/api/vaults/{}/keys'
20
+
21
+ UPLOAD_SUCCESS_MESSAGE: Final = 'Data successfully uploaded'
22
+ UPLOAD_FAILURE_MESSAGE: Final = 'Failed to upload file'
23
+
24
+ DEFAULT_DEV_SERVER_URL: Final = 'http://127.0.0.1:5000'
25
+ DEFAULT_PROD_SERVER_URL: Final = 'https://kaas.runtimeverification.com/'
26
+
27
+ DEFAULT_K_OUT_FOLDER: Final = './kout'
@@ -0,0 +1,2 @@
1
+ def hello(name: str) -> str:
2
+ return f'Hello, {name}!'
File without changes
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import IO, Any, Optional
4
+
5
+ from click import ClickException, echo
6
+
7
+ File_Data = dict[str, tuple[Optional[str], Any, Optional[str]]]
8
+ Metadata = dict[str, Any]
9
+
10
+
11
+ class KaasCliException(ClickException):
12
+ def show(self, file: IO[Any] | None = None) -> None:
13
+ echo(f'{self.message}', file=file)