kscale 0.0.11__py3-none-any.whl → 0.1.0__py3-none-any.whl

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.
kscale/__init__.py CHANGED
@@ -1,9 +1,9 @@
1
1
  """Defines the common interface for the K-Scale Python API."""
2
2
 
3
- __version__ = "0.0.11"
3
+ __version__ = "0.1.0"
4
4
 
5
5
  from pathlib import Path
6
6
 
7
- from kscale.api import KScale
7
+ from kscale.api import K
8
8
 
9
9
  ROOT_DIR = Path(__file__).parent
kscale/api.py CHANGED
@@ -1,14 +1,11 @@
1
1
  """Defines common functionality for the K-Scale API."""
2
2
 
3
- from kscale.store.api import StoreAPI
4
3
  from kscale.utils.api_base import APIBase
4
+ from kscale.web.api import WebAPI
5
5
 
6
6
 
7
- class KScale(
8
- StoreAPI,
7
+ class K(
8
+ WebAPI,
9
9
  APIBase,
10
10
  ):
11
11
  """Defines a common interface for the K-Scale API."""
12
-
13
- def __init__(self, api_key: str | None = None) -> None:
14
- self.api_key = api_key
kscale/cli.py ADDED
@@ -0,0 +1,32 @@
1
+ """Defines the top-level KOL CLI."""
2
+
3
+ import logging
4
+
5
+ import click
6
+ import colorlogging
7
+
8
+ from kscale.utils.cli import recursive_help
9
+ from kscale.web.cli.robot import cli as robot_cli
10
+ from kscale.web.cli.robot_class import cli as robot_class_cli
11
+ from kscale.web.cli.token import cli as token_cli
12
+ from kscale.web.cli.user import cli as user_cli
13
+
14
+
15
+ @click.group()
16
+ def cli() -> None:
17
+ """Command line interface for interacting with the K-Scale web API."""
18
+ colorlogging.configure()
19
+
20
+ # Suppress aiohttp access logging
21
+ logging.getLogger("httpx").setLevel(logging.WARNING)
22
+ logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
23
+
24
+
25
+ cli.add_command(token_cli, "token")
26
+ cli.add_command(user_cli, "user")
27
+ cli.add_command(robot_class_cli, "robots")
28
+ cli.add_command(robot_cli, "robot")
29
+
30
+ if __name__ == "__main__":
31
+ # python -m kscale.cli
32
+ print(recursive_help(cli))
kscale/conf.py CHANGED
@@ -8,6 +8,9 @@ from pathlib import Path
8
8
 
9
9
  from omegaconf import II, OmegaConf
10
10
 
11
+ # This is the public API endpoint for the K-Scale WWW API.
12
+ DEFAULT_API_ROOT = "https://api.kscale.dev"
13
+
11
14
 
12
15
  def get_path() -> Path:
13
16
  if "KSCALE_CONFIG_DIR" in os.environ:
@@ -16,14 +19,14 @@ def get_path() -> Path:
16
19
 
17
20
 
18
21
  @dataclass
19
- class StoreSettings:
20
- api_key: str | None = field(default=None)
22
+ class WWWSettings:
23
+ api_root: str = field(default=DEFAULT_API_ROOT)
21
24
  cache_dir: str = field(default=II("oc.env:KSCALE_CACHE_DIR,'~/.kscale/cache/'"))
22
25
 
23
26
 
24
27
  @dataclass
25
28
  class Settings:
26
- store: StoreSettings = field(default_factory=StoreSettings)
29
+ www: WWWSettings = field(default_factory=WWWSettings)
27
30
 
28
31
  def save(self) -> None:
29
32
  (dir_path := get_path()).mkdir(parents=True, exist_ok=True)
kscale/requirements.txt CHANGED
@@ -3,8 +3,24 @@
3
3
  # Configuration
4
4
  omegaconf
5
5
  email_validator
6
+ colorlogging
6
7
 
7
8
  # HTTP requests
9
+ aiohttp
10
+ cryptography
8
11
  httpx
9
- requests
10
12
  pydantic
13
+ pyjwt
14
+ requests
15
+ yarl
16
+
17
+ # CLI
18
+ aiofiles
19
+ click
20
+ tabulate
21
+
22
+ # Async
23
+ async-lru
24
+
25
+ # K-Scale
26
+ krec
@@ -0,0 +1,41 @@
1
+ """Utility functions for file checksums."""
2
+
3
+ import hashlib
4
+ from pathlib import Path
5
+ from typing import Tuple
6
+
7
+ CHUNK_SIZE = 8192
8
+
9
+
10
+ async def calculate_sha256(file_path: str | Path) -> Tuple[str, int]:
11
+ """Calculate SHA256 checksum and size of a file.
12
+
13
+ Args:
14
+ file_path: Path to the file
15
+
16
+ Returns:
17
+ Tuple of (checksum hex string, file size in bytes)
18
+ """
19
+ sha256_hash = hashlib.sha256()
20
+ file_size = 0
21
+
22
+ with open(file_path, "rb") as f:
23
+ for chunk in iter(lambda: f.read(CHUNK_SIZE), b""):
24
+ sha256_hash.update(chunk)
25
+ file_size += len(chunk)
26
+
27
+ return sha256_hash.hexdigest(), file_size
28
+
29
+
30
+ class FileChecksum:
31
+ """Helper class for handling file checksums."""
32
+
33
+ @staticmethod
34
+ async def calculate(file_path: str | Path) -> Tuple[str, int]:
35
+ """Calculate SHA256 checksum and size of a file."""
36
+ return await calculate_sha256(file_path)
37
+
38
+ @staticmethod
39
+ def update_hash(hash_obj: "hashlib._Hash", chunk: bytes) -> None:
40
+ """Update a hash object with new data."""
41
+ hash_obj.update(chunk)
kscale/utils/cli.py ADDED
@@ -0,0 +1,28 @@
1
+ """Defines utilities for working with asyncio."""
2
+
3
+ import asyncio
4
+ import textwrap
5
+ from functools import wraps
6
+ from typing import Any, Callable, Coroutine, ParamSpec, TypeVar
7
+
8
+ import click
9
+
10
+ T = TypeVar("T")
11
+ P = ParamSpec("P")
12
+
13
+
14
+ def coro(f: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, T]:
15
+ @wraps(f)
16
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
17
+ return asyncio.run(f(*args, **kwargs))
18
+
19
+ return wrapper
20
+
21
+
22
+ def recursive_help(cmd: click.Command, parent: click.Context | None = None, indent: int = 0) -> str:
23
+ ctx = click.core.Context(cmd, info_name=cmd.name, parent=parent)
24
+ help_text = cmd.get_help(ctx)
25
+ commands = getattr(cmd, "commands", {})
26
+ for sub in commands.values():
27
+ help_text += recursive_help(sub, ctx, indent + 2)
28
+ return textwrap.indent(help_text, " " * indent)
kscale/web/api.py ADDED
@@ -0,0 +1,14 @@
1
+ """Defines a common interface for the K-Scale WWW API."""
2
+
3
+ from kscale.utils.api_base import APIBase
4
+ from kscale.web.clients.client import WWWClient
5
+ from kscale.web.gen.api import ProfileResponse
6
+
7
+
8
+ class WebAPI(APIBase):
9
+ async def www_client(self) -> WWWClient:
10
+ return WWWClient()
11
+
12
+ async def get_profile_info(self) -> ProfileResponse:
13
+ client = await self.www_client()
14
+ return await client.get_profile_info()
@@ -0,0 +1,100 @@
1
+ """Defines the CLI for getting information about robots."""
2
+
3
+ import click
4
+ from tabulate import tabulate
5
+
6
+ from kscale.utils.cli import coro
7
+ from kscale.web.clients.robot import RobotClient
8
+
9
+
10
+ @click.group()
11
+ def cli() -> None:
12
+ """Get information about robots."""
13
+ pass
14
+
15
+
16
+ @cli.command()
17
+ @coro
18
+ async def list() -> None:
19
+ client = RobotClient()
20
+ robots = await client.get_all_robots()
21
+ if robots:
22
+ table_data = [
23
+ [
24
+ click.style(robot.id, fg="blue"),
25
+ click.style(robot.robot_name, fg="green"),
26
+ click.style(robot.class_id, fg="yellow"),
27
+ robot.description or "N/A",
28
+ ]
29
+ for robot in robots
30
+ ]
31
+ click.echo(tabulate(table_data, headers=["ID", "Name", "Class", "Description"], tablefmt="simple"))
32
+ else:
33
+ click.echo(click.style("No robots found", fg="red"))
34
+
35
+
36
+ @cli.command()
37
+ @click.option("-u", "--user-id", type=str, default="me")
38
+ @coro
39
+ async def user(user_id: str = "me") -> None:
40
+ client = RobotClient()
41
+ robots = await client.get_user_robots(user_id)
42
+ if robots:
43
+ table_data = [
44
+ [
45
+ click.style(robot.id, fg="blue"),
46
+ click.style(robot.robot_name, fg="green"),
47
+ click.style(robot.class_id, fg="yellow"),
48
+ robot.description or "N/A",
49
+ ]
50
+ for robot in robots
51
+ ]
52
+ click.echo(tabulate(table_data, headers=["ID", "Name", "Class", "Description"], tablefmt="simple"))
53
+ else:
54
+ click.echo(click.style("No robots found", fg="red"))
55
+
56
+
57
+ @cli.command()
58
+ @click.argument("robot_id")
59
+ @coro
60
+ async def id(robot_id: str) -> None:
61
+ client = RobotClient()
62
+ robot = await client.get_robot_by_id(robot_id)
63
+ click.echo("Robot:")
64
+ click.echo(f" ID: {click.style(robot.id, fg='blue')}")
65
+ click.echo(f" Name: {click.style(robot.robot_name, fg='green')}")
66
+ click.echo(f" Class: {click.style(robot.class_name, fg='yellow')}")
67
+ click.echo(f" Description: {click.style(robot.description or 'N/A', fg='yellow')}")
68
+
69
+
70
+ @cli.command()
71
+ @click.argument("robot_name")
72
+ @coro
73
+ async def name(robot_name: str) -> None:
74
+ client = RobotClient()
75
+ robot = await client.get_robot_by_name(robot_name)
76
+ click.echo("Robot:")
77
+ click.echo(f" ID: {click.style(robot.id, fg='blue')}")
78
+ click.echo(f" Name: {click.style(robot.robot_name, fg='green')}")
79
+ click.echo(f" Class: {click.style(robot.class_name, fg='yellow')}")
80
+ click.echo(f" Description: {click.style(robot.description or 'N/A', fg='yellow')}")
81
+
82
+
83
+ @cli.command()
84
+ @click.argument("class_name")
85
+ @click.argument("name")
86
+ @click.option("-c", "--class-name", type=str, required=True)
87
+ @click.option("-d", "--description", type=str, default=None)
88
+ @coro
89
+ async def add(name: str, class_name: str, description: str | None = None) -> None:
90
+ client = RobotClient()
91
+ robot = await client.add_robot(name, class_name, description)
92
+ click.echo("Robot added:")
93
+ click.echo(f" ID: {click.style(robot.id, fg='blue')}")
94
+ click.echo(f" Name: {click.style(robot.robot_name, fg='green')}")
95
+ click.echo(f" Class: {click.style(robot.class_name, fg='yellow')}")
96
+ click.echo(f" Description: {click.style(robot.description or 'N/A', fg='yellow')}")
97
+
98
+
99
+ if __name__ == "__main__":
100
+ cli()
@@ -0,0 +1,113 @@
1
+ """Defines the CLI for getting information about robot classes."""
2
+
3
+ import logging
4
+
5
+ import click
6
+ from tabulate import tabulate
7
+
8
+ from kscale.utils.cli import coro
9
+ from kscale.web.clients.robot_class import RobotClassClient
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ @click.group()
15
+ def cli() -> None:
16
+ """Get information about robot classes."""
17
+ pass
18
+
19
+
20
+ @cli.command()
21
+ @coro
22
+ async def list() -> None:
23
+ """Lists all robot classes."""
24
+ client = RobotClassClient()
25
+ robot_classes = await client.get_robot_classes()
26
+ if robot_classes:
27
+ # Prepare table data
28
+ table_data = [
29
+ [
30
+ click.style(rc.id, fg="blue"),
31
+ click.style(rc.class_name, fg="green"),
32
+ rc.description or "N/A",
33
+ ]
34
+ for rc in robot_classes
35
+ ]
36
+ click.echo(tabulate(table_data, headers=["ID", "Name", "Description"], tablefmt="simple"))
37
+ else:
38
+ click.echo(click.style("No robot classes found", fg="red"))
39
+
40
+
41
+ @cli.command()
42
+ @click.argument("name")
43
+ @click.option("-d", "--description", type=str, default=None)
44
+ @coro
45
+ async def add(
46
+ name: str,
47
+ description: str | None = None,
48
+ ) -> None:
49
+ """Adds a new robot class."""
50
+ async with RobotClassClient() as client:
51
+ robot_class = await client.create_robot_class(name, description)
52
+ click.echo("Robot class created:")
53
+ click.echo(f" ID: {click.style(robot_class.id, fg='blue')}")
54
+ click.echo(f" Name: {click.style(robot_class.class_name, fg='green')}")
55
+ click.echo(f" Description: {click.style(robot_class.description or 'N/A', fg='yellow')}")
56
+
57
+
58
+ @cli.command()
59
+ @click.argument("current_name")
60
+ @click.option("-n", "--name", type=str, default=None)
61
+ @click.option("-d", "--description", type=str, default=None)
62
+ @coro
63
+ async def update(current_name: str, name: str | None = None, description: str | None = None) -> None:
64
+ """Updates a robot class."""
65
+ async with RobotClassClient() as client:
66
+ robot_class = await client.update_robot_class(current_name, name, description)
67
+ click.echo("Robot class updated:")
68
+ click.echo(f" ID: {click.style(robot_class.id, fg='blue')}")
69
+ click.echo(f" Name: {click.style(robot_class.class_name, fg='green')}")
70
+ click.echo(f" Description: {click.style(robot_class.description or 'N/A', fg='yellow')}")
71
+
72
+
73
+ @cli.command()
74
+ @click.argument("name")
75
+ @coro
76
+ async def delete(name: str) -> None:
77
+ """Deletes a robot class."""
78
+ async with RobotClassClient() as client:
79
+ await client.delete_robot_class(name)
80
+ click.echo(f"Robot class deleted: {click.style(name, fg='red')}")
81
+
82
+
83
+ @cli.group()
84
+ def urdf() -> None:
85
+ """Handle the robot class URDF."""
86
+ pass
87
+
88
+
89
+ @urdf.command()
90
+ @click.argument("class_name")
91
+ @click.argument("urdf_file")
92
+ @coro
93
+ async def upload(class_name: str, urdf_file: str) -> None:
94
+ """Uploads a URDF file to a robot class."""
95
+ async with RobotClassClient() as client:
96
+ response = await client.upload_robot_class_urdf(class_name, urdf_file)
97
+ click.echo("URDF uploaded:")
98
+ click.echo(f" Filename: {click.style(response.filename, fg='green')}")
99
+
100
+
101
+ @urdf.command()
102
+ @click.argument("class_name")
103
+ @click.option("--no-cache", is_flag=True, default=False)
104
+ @coro
105
+ async def download(class_name: str, no_cache: bool) -> None:
106
+ """Downloads a URDF file from a robot class."""
107
+ async with RobotClassClient() as client:
108
+ urdf_file = await client.download_robot_class_urdf(class_name, cache=not no_cache)
109
+ click.echo(f"URDF downloaded: {click.style(urdf_file, fg='green')}")
110
+
111
+
112
+ if __name__ == "__main__":
113
+ cli()
@@ -0,0 +1,33 @@
1
+ """Defines the CLI for interacting with K-Scale's OpenID Connect server."""
2
+
3
+ import logging
4
+
5
+ import click
6
+
7
+ from kscale.utils.cli import coro
8
+ from kscale.web.clients.base import BaseClient
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @click.group()
14
+ def cli() -> None:
15
+ """Retrieve an OICD token from the K-Scale authentication server."""
16
+ pass
17
+
18
+
19
+ @cli.command()
20
+ @coro
21
+ async def get() -> None:
22
+ """Get a bearer token from OpenID Connect."""
23
+ logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
24
+ async with BaseClient() as client:
25
+ try:
26
+ token = await client.get_bearer_token()
27
+ logger.info("Bearer token: %s", token)
28
+ except Exception:
29
+ logger.exception("Error getting bearer token")
30
+
31
+
32
+ if __name__ == "__main__":
33
+ cli()
kscale/web/cli/user.py ADDED
@@ -0,0 +1,33 @@
1
+ """Defines the CLI for getting information about the current user."""
2
+
3
+ import logging
4
+
5
+ import click
6
+
7
+ from kscale.utils.cli import coro
8
+ from kscale.web.clients.user import UserClient
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @click.group()
14
+ def cli() -> None:
15
+ """Get information about the currently-authenticated user."""
16
+ pass
17
+
18
+
19
+ @cli.command()
20
+ @coro
21
+ async def me() -> None:
22
+ client = UserClient()
23
+ profile = await client.get_profile_info()
24
+ logger.info("Email: %s", profile.email)
25
+ logger.info("Email verified: %s", profile.email_verified)
26
+ logger.info("User ID: %s", profile.user.user_id)
27
+ logger.info("Is admin: %s", profile.user.is_admin)
28
+ logger.info("Can upload: %s", profile.user.can_upload)
29
+ logger.info("Can test: %s", profile.user.can_test)
30
+
31
+
32
+ if __name__ == "__main__":
33
+ cli()
File without changes