kscale 0.3.17__tar.gz → 0.3.18__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.
Files changed (48) hide show
  1. {kscale-0.3.17/kscale.egg-info → kscale-0.3.18}/PKG-INFO +1 -1
  2. {kscale-0.3.17 → kscale-0.3.18}/kscale/__init__.py +1 -1
  3. {kscale-0.3.17 → kscale-0.3.18}/kscale/cli.py +6 -0
  4. kscale-0.3.18/kscale/web/cli/clip.py +132 -0
  5. kscale-0.3.18/kscale/web/cli/group.py +241 -0
  6. kscale-0.3.18/kscale/web/cli/permission.py +110 -0
  7. {kscale-0.3.17 → kscale-0.3.18}/kscale/web/cli/user.py +0 -2
  8. {kscale-0.3.17 → kscale-0.3.18}/kscale/web/clients/base.py +1 -1
  9. {kscale-0.3.17 → kscale-0.3.18}/kscale/web/clients/client.py +6 -0
  10. kscale-0.3.18/kscale/web/clients/clip.py +118 -0
  11. kscale-0.3.18/kscale/web/clients/group.py +85 -0
  12. kscale-0.3.18/kscale/web/clients/permission.py +35 -0
  13. {kscale-0.3.17 → kscale-0.3.18}/kscale/web/gen/api.py +100 -3
  14. {kscale-0.3.17 → kscale-0.3.18/kscale.egg-info}/PKG-INFO +1 -1
  15. {kscale-0.3.17 → kscale-0.3.18}/kscale.egg-info/SOURCES.txt +6 -0
  16. {kscale-0.3.17 → kscale-0.3.18}/pyproject.toml +0 -1
  17. {kscale-0.3.17 → kscale-0.3.18}/LICENSE +0 -0
  18. {kscale-0.3.17 → kscale-0.3.18}/MANIFEST.in +0 -0
  19. {kscale-0.3.17 → kscale-0.3.18}/README.md +0 -0
  20. {kscale-0.3.17 → kscale-0.3.18}/kscale/artifacts/__init__.py +0 -0
  21. {kscale-0.3.17 → kscale-0.3.18}/kscale/artifacts/plane.obj +0 -0
  22. {kscale-0.3.17 → kscale-0.3.18}/kscale/artifacts/plane.urdf +0 -0
  23. {kscale-0.3.17 → kscale-0.3.18}/kscale/conf.py +0 -0
  24. {kscale-0.3.17 → kscale-0.3.18}/kscale/py.typed +0 -0
  25. {kscale-0.3.17 → kscale-0.3.18}/kscale/requirements-dev.txt +0 -0
  26. {kscale-0.3.17 → kscale-0.3.18}/kscale/requirements.txt +0 -0
  27. {kscale-0.3.17 → kscale-0.3.18}/kscale/utils/__init__.py +0 -0
  28. {kscale-0.3.17 → kscale-0.3.18}/kscale/utils/api_base.py +0 -0
  29. {kscale-0.3.17 → kscale-0.3.18}/kscale/utils/checksum.py +0 -0
  30. {kscale-0.3.17 → kscale-0.3.18}/kscale/utils/cli.py +0 -0
  31. {kscale-0.3.17 → kscale-0.3.18}/kscale/web/__init__.py +0 -0
  32. {kscale-0.3.17 → kscale-0.3.18}/kscale/web/cli/__init__.py +0 -0
  33. {kscale-0.3.17 → kscale-0.3.18}/kscale/web/cli/robot.py +0 -0
  34. {kscale-0.3.17 → kscale-0.3.18}/kscale/web/cli/robot_class.py +0 -0
  35. {kscale-0.3.17 → kscale-0.3.18}/kscale/web/clients/__init__.py +0 -0
  36. {kscale-0.3.17 → kscale-0.3.18}/kscale/web/clients/robot.py +0 -0
  37. {kscale-0.3.17 → kscale-0.3.18}/kscale/web/clients/robot_class.py +0 -0
  38. {kscale-0.3.17 → kscale-0.3.18}/kscale/web/clients/user.py +0 -0
  39. {kscale-0.3.17 → kscale-0.3.18}/kscale/web/gen/__init__.py +0 -0
  40. {kscale-0.3.17 → kscale-0.3.18}/kscale/web/utils.py +0 -0
  41. {kscale-0.3.17 → kscale-0.3.18}/kscale.egg-info/dependency_links.txt +0 -0
  42. {kscale-0.3.17 → kscale-0.3.18}/kscale.egg-info/entry_points.txt +0 -0
  43. {kscale-0.3.17 → kscale-0.3.18}/kscale.egg-info/not-zip-safe +0 -0
  44. {kscale-0.3.17 → kscale-0.3.18}/kscale.egg-info/requires.txt +0 -0
  45. {kscale-0.3.17 → kscale-0.3.18}/kscale.egg-info/top_level.txt +0 -0
  46. {kscale-0.3.17 → kscale-0.3.18}/setup.cfg +0 -0
  47. {kscale-0.3.17 → kscale-0.3.18}/setup.py +0 -0
  48. {kscale-0.3.17 → kscale-0.3.18}/tests/test_dummy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kscale
3
- Version: 0.3.17
3
+ Version: 0.3.18
4
4
  Summary: The kscale project
5
5
  Home-page: https://github.com/kscalelabs/kscale
6
6
  Author: Benjamin Bolte
@@ -1,6 +1,6 @@
1
1
  """Defines the common interface for the K-Scale Python API."""
2
2
 
3
- __version__ = "0.3.17"
3
+ __version__ = "0.3.18"
4
4
 
5
5
  from pathlib import Path
6
6
 
@@ -6,6 +6,9 @@ import click
6
6
  import colorlogging
7
7
 
8
8
  from kscale.utils.cli import recursive_help
9
+ from kscale.web.cli.clip import cli as clip_cli
10
+ from kscale.web.cli.group import cli as group_cli
11
+ from kscale.web.cli.permission import cli as permission_cli
9
12
  from kscale.web.cli.robot import cli as robot_cli
10
13
  from kscale.web.cli.robot_class import cli as robot_class_cli
11
14
  from kscale.web.cli.user import cli as user_cli
@@ -24,6 +27,9 @@ def cli() -> None:
24
27
  cli.add_command(user_cli, "user")
25
28
  cli.add_command(robot_class_cli, "robots")
26
29
  cli.add_command(robot_cli, "robot")
30
+ cli.add_command(clip_cli, "clips")
31
+ cli.add_command(group_cli, "groups")
32
+ cli.add_command(permission_cli, "permissions")
27
33
 
28
34
  if __name__ == "__main__":
29
35
  # python -m kscale.cli
@@ -0,0 +1,132 @@
1
+ """Defines the CLI for managing clips."""
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.clip import ClipClient
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ @click.group()
15
+ def cli() -> None:
16
+ """Manage clips."""
17
+ pass
18
+
19
+
20
+ @cli.command()
21
+ @coro
22
+ async def list() -> None:
23
+ """Lists all clips for the authenticated user."""
24
+ client = ClipClient()
25
+ clips = await client.get_clips()
26
+ if clips:
27
+ # Prepare table data
28
+ table_data = [
29
+ [
30
+ click.style(clip.id, fg="blue"),
31
+ clip.description or "N/A",
32
+ clip.created_at.strftime("%Y-%m-%d %H:%M:%S"),
33
+ "N/A" if clip.num_downloads is None else f"{clip.num_downloads:,}",
34
+ "N/A" if clip.file_size is None else f"{clip.file_size:,} bytes",
35
+ ]
36
+ for clip in clips
37
+ ]
38
+ click.echo(
39
+ tabulate(
40
+ table_data,
41
+ headers=["ID", "Description", "Created", "Downloads", "Size"],
42
+ tablefmt="simple",
43
+ )
44
+ )
45
+ else:
46
+ click.echo(click.style("No clips found", fg="red"))
47
+
48
+
49
+ @cli.command()
50
+ @click.argument("clip_id")
51
+ @coro
52
+ async def get(clip_id: str) -> None:
53
+ """Get information about a specific clip."""
54
+ client = ClipClient()
55
+ clip = await client.get_clip(clip_id)
56
+ click.echo(f"ID: {click.style(clip.id, fg='blue')}")
57
+ click.echo(f"Description: {click.style(clip.description or 'N/A', fg='green')}")
58
+ click.echo(f"User ID: {click.style(clip.user_id, fg='yellow')}")
59
+ click.echo(f"Created: {clip.created_at.strftime('%Y-%m-%d %H:%M:%S')}")
60
+ click.echo(f"Downloads: {clip.num_downloads or 0}")
61
+ if clip.file_size:
62
+ click.echo(f"File Size: {clip.file_size:,} bytes")
63
+
64
+
65
+ @cli.command()
66
+ @click.option("-d", "--description", type=str, default=None, help="Description for the clip")
67
+ @coro
68
+ async def create(description: str | None = None) -> None:
69
+ """Create a new clip."""
70
+ async with ClipClient() as client:
71
+ clip = await client.create_clip(description)
72
+ click.echo("Clip created:")
73
+ click.echo(f" ID: {click.style(clip.id, fg='blue')}")
74
+ click.echo(f" Description: {click.style(clip.description or 'N/A', fg='green')}")
75
+ click.echo(f" Created: {clip.created_at.strftime('%Y-%m-%d %H:%M:%S')}")
76
+
77
+
78
+ @cli.command()
79
+ @click.argument("clip_id")
80
+ @click.option("-d", "--description", type=str, default=None, help="New description for the clip")
81
+ @coro
82
+ async def update(clip_id: str, description: str | None = None) -> None:
83
+ """Update a clip's metadata."""
84
+ async with ClipClient() as client:
85
+ clip = await client.update_clip(clip_id, description)
86
+ click.echo("Clip updated:")
87
+ click.echo(f" ID: {click.style(clip.id, fg='blue')}")
88
+ click.echo(f" Description: {click.style(clip.description or 'N/A', fg='green')}")
89
+
90
+
91
+ @cli.command()
92
+ @click.argument("clip_id")
93
+ @click.confirmation_option(prompt="Are you sure you want to delete this clip?")
94
+ @coro
95
+ async def delete(clip_id: str) -> None:
96
+ """Delete a clip."""
97
+ async with ClipClient() as client:
98
+ await client.delete_clip(clip_id)
99
+ click.echo(f"Clip deleted: {click.style(clip_id, fg='red')}")
100
+
101
+
102
+ @cli.command()
103
+ @click.argument("clip_id")
104
+ @click.argument("file_path", type=click.Path(exists=True))
105
+ @coro
106
+ async def upload(clip_id: str, file_path: str) -> None:
107
+ """Upload a file for a clip."""
108
+ async with ClipClient() as client:
109
+ response = await client.upload_clip(clip_id, file_path)
110
+ click.echo("File uploaded:")
111
+ click.echo(f" Filename: {click.style(response.filename, fg='green')}")
112
+ click.echo(f" Content Type: {response.content_type}")
113
+
114
+
115
+ @cli.command()
116
+ @click.argument("clip_id")
117
+ @click.option("-o", "--output", type=click.Path(), help="Output file path")
118
+ @coro
119
+ async def download(clip_id: str, output: str | None = None) -> None:
120
+ """Download a clip file."""
121
+ async with ClipClient() as client:
122
+ if output:
123
+ output_path = await client.download_clip_to_file(clip_id, output)
124
+ click.echo(f"Clip downloaded to: {click.style(str(output_path), fg='green')}")
125
+ else:
126
+ download_response = await client.download_clip(clip_id)
127
+ click.echo(f"Download URL: {click.style(download_response.url, fg='blue')}")
128
+ click.echo(f"MD5 Hash: {download_response.md5_hash}")
129
+
130
+
131
+ if __name__ == "__main__":
132
+ cli()
@@ -0,0 +1,241 @@
1
+ """Defines the CLI for managing groups."""
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.group import GroupClient
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ @click.group()
15
+ def cli() -> None:
16
+ """Manage groups."""
17
+ pass
18
+
19
+
20
+ @cli.command()
21
+ @coro
22
+ async def list() -> None:
23
+ """Lists all groups for the authenticated user."""
24
+ client = GroupClient()
25
+ groups = await client.get_groups()
26
+ if groups:
27
+ # Prepare table data
28
+ table_data = [
29
+ [
30
+ click.style(group.id, fg="blue"),
31
+ click.style(group.name, fg="green"),
32
+ group.description or "N/A",
33
+ group.created_at,
34
+ "Active" if group.is_active else "Inactive",
35
+ ]
36
+ for group in groups
37
+ ]
38
+ click.echo(
39
+ tabulate(
40
+ table_data,
41
+ headers=["ID", "Name", "Description", "Created", "Status"],
42
+ tablefmt="simple",
43
+ )
44
+ )
45
+ else:
46
+ click.echo(click.style("No groups found", fg="red"))
47
+
48
+
49
+ @cli.command()
50
+ @click.argument("group_id")
51
+ @coro
52
+ async def get(group_id: str) -> None:
53
+ """Get information about a specific group."""
54
+ client = GroupClient()
55
+ group = await client.get_group(group_id)
56
+ click.echo(f"ID: {click.style(group.id, fg='blue')}")
57
+ click.echo(f"Name: {click.style(group.name, fg='green')}")
58
+ click.echo(f"Description: {click.style(group.description or 'N/A', fg='yellow')}")
59
+ click.echo(f"Owner ID: {click.style(group.owner_id, fg='cyan')}")
60
+ click.echo(f"Created: {group.created_at}")
61
+ click.echo(f"Updated: {group.updated_at}")
62
+ click.echo(f"Status: {'Active' if group.is_active else 'Inactive'}")
63
+
64
+
65
+ @cli.command()
66
+ @click.argument("name")
67
+ @click.option("-d", "--description", type=str, default=None, help="Description for the group")
68
+ @coro
69
+ async def create(name: str, description: str | None = None) -> None:
70
+ """Create a new group."""
71
+ async with GroupClient() as client:
72
+ group = await client.create_group(name, description)
73
+ click.echo("Group created:")
74
+ click.echo(f" ID: {click.style(group.id, fg='blue')}")
75
+ click.echo(f" Name: {click.style(group.name, fg='green')}")
76
+ click.echo(f" Description: {click.style(group.description or 'N/A', fg='yellow')}")
77
+
78
+
79
+ @cli.command()
80
+ @click.argument("group_id")
81
+ @click.option("-n", "--name", type=str, default=None, help="New name for the group")
82
+ @click.option("-d", "--description", type=str, default=None, help="New description for the group")
83
+ @coro
84
+ async def update(group_id: str, name: str | None = None, description: str | None = None) -> None:
85
+ """Update a group's metadata."""
86
+ async with GroupClient() as client:
87
+ group = await client.update_group(group_id, name, description)
88
+ click.echo("Group updated:")
89
+ click.echo(f" ID: {click.style(group.id, fg='blue')}")
90
+ click.echo(f" Name: {click.style(group.name, fg='green')}")
91
+ click.echo(f" Description: {click.style(group.description or 'N/A', fg='yellow')}")
92
+
93
+
94
+ @cli.command()
95
+ @click.argument("group_id")
96
+ @click.confirmation_option(prompt="Are you sure you want to delete this group?")
97
+ @coro
98
+ async def delete(group_id: str) -> None:
99
+ """Delete a group."""
100
+ async with GroupClient() as client:
101
+ await client.delete_group(group_id)
102
+ click.echo(f"Group deleted: {click.style(group_id, fg='red')}")
103
+
104
+
105
+ @cli.group()
106
+ def membership() -> None:
107
+ """Manage group memberships."""
108
+ pass
109
+
110
+
111
+ @membership.command("list")
112
+ @click.argument("group_id")
113
+ @coro
114
+ async def list_memberships(group_id: str) -> None:
115
+ """List all memberships for a group."""
116
+ client = GroupClient()
117
+ memberships = await client.get_group_memberships(group_id)
118
+ if memberships:
119
+ table_data = [
120
+ [
121
+ click.style(membership.id, fg="blue"),
122
+ click.style(membership.user_id, fg="green"),
123
+ membership.status,
124
+ membership.requested_at,
125
+ membership.approved_at or "N/A",
126
+ membership.approved_by or "N/A",
127
+ ]
128
+ for membership in memberships
129
+ ]
130
+ click.echo(
131
+ tabulate(
132
+ table_data,
133
+ headers=["ID", "User ID", "Status", "Requested", "Approved", "Approved By"],
134
+ tablefmt="simple",
135
+ )
136
+ )
137
+ else:
138
+ click.echo(click.style("No memberships found", fg="red"))
139
+
140
+
141
+ @membership.command("request")
142
+ @click.argument("group_id")
143
+ @coro
144
+ async def request_membership(group_id: str) -> None:
145
+ """Request to join a group."""
146
+ async with GroupClient() as client:
147
+ membership = await client.request_group_membership(group_id)
148
+ click.echo("Membership request sent:")
149
+ click.echo(f" ID: {click.style(membership.id, fg='blue')}")
150
+ click.echo(f" Status: {membership.status}")
151
+
152
+
153
+ @membership.command("approve")
154
+ @click.argument("group_id")
155
+ @click.argument("user_id")
156
+ @coro
157
+ async def approve_membership(group_id: str, user_id: str) -> None:
158
+ """Approve a membership request."""
159
+ async with GroupClient() as client:
160
+ membership = await client.approve_group_membership(group_id, user_id)
161
+ click.echo("Membership approved:")
162
+ click.echo(f" ID: {click.style(membership.id, fg='blue')}")
163
+ click.echo(f" Status: {membership.status}")
164
+
165
+
166
+ @membership.command("reject")
167
+ @click.argument("group_id")
168
+ @click.argument("user_id")
169
+ @click.confirmation_option(prompt="Are you sure you want to reject this membership?")
170
+ @coro
171
+ async def reject_membership(group_id: str, user_id: str) -> None:
172
+ """Reject a membership request."""
173
+ async with GroupClient() as client:
174
+ await client.reject_group_membership(group_id, user_id)
175
+ click.echo(f"Membership rejected for user: {click.style(user_id, fg='red')}")
176
+
177
+
178
+ @cli.group()
179
+ def share() -> None:
180
+ """Manage group resource sharing."""
181
+ pass
182
+
183
+
184
+ @share.command("list")
185
+ @click.argument("group_id")
186
+ @coro
187
+ async def list_shares(group_id: str) -> None:
188
+ """List all resources shared with a group."""
189
+ client = GroupClient()
190
+ shares = await client.get_group_shares(group_id)
191
+ if shares:
192
+ table_data = [
193
+ [
194
+ click.style(share.id, fg="blue"),
195
+ share.resource_type,
196
+ click.style(share.resource_id, fg="green"),
197
+ click.style(share.shared_by, fg="yellow"),
198
+ share.shared_at,
199
+ ]
200
+ for share in shares
201
+ ]
202
+ click.echo(
203
+ tabulate(
204
+ table_data,
205
+ headers=["ID", "Type", "Resource ID", "Shared By", "Shared At"],
206
+ tablefmt="simple",
207
+ )
208
+ )
209
+ else:
210
+ click.echo(click.style("No shared resources found", fg="red"))
211
+
212
+
213
+ @share.command("add")
214
+ @click.argument("group_id")
215
+ @click.argument("resource_type")
216
+ @click.argument("resource_id")
217
+ @coro
218
+ async def add_share(group_id: str, resource_type: str, resource_id: str) -> None:
219
+ """Share a resource with a group."""
220
+ async with GroupClient() as client:
221
+ share = await client.share_resource_with_group(group_id, resource_type, resource_id)
222
+ click.echo("Resource shared:")
223
+ click.echo(f" ID: {click.style(share.id, fg='blue')}")
224
+ click.echo(f" Type: {share.resource_type}")
225
+ click.echo(f" Resource ID: {click.style(share.resource_id, fg='green')}")
226
+
227
+
228
+ @share.command("remove")
229
+ @click.argument("group_id")
230
+ @click.argument("share_id")
231
+ @click.confirmation_option(prompt="Are you sure you want to remove this share?")
232
+ @coro
233
+ async def remove_share(group_id: str, share_id: str) -> None:
234
+ """Remove a resource share from a group."""
235
+ async with GroupClient() as client:
236
+ await client.unshare_resource_from_group(group_id, share_id)
237
+ click.echo(f"Share removed: {click.style(share_id, fg='red')}")
238
+
239
+
240
+ if __name__ == "__main__":
241
+ cli()
@@ -0,0 +1,110 @@
1
+ """Defines the CLI for managing permissions."""
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.permission import PermissionClient
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ @click.group()
15
+ def cli() -> None:
16
+ """Manage user permissions."""
17
+ pass
18
+
19
+
20
+ @cli.command("list-all")
21
+ @coro
22
+ async def list_all_permissions() -> None:
23
+ """List all available permissions."""
24
+ client = PermissionClient()
25
+ permissions = await client.get_all_permissions()
26
+ if permissions:
27
+ table_data = [
28
+ [
29
+ click.style(perm.permission, fg="blue"),
30
+ perm.description,
31
+ ]
32
+ for perm in permissions
33
+ ]
34
+ click.echo(
35
+ tabulate(
36
+ table_data,
37
+ headers=["Permission", "Description"],
38
+ tablefmt="simple",
39
+ )
40
+ )
41
+ else:
42
+ click.echo(click.style("No permissions found", fg="red"))
43
+
44
+
45
+ @cli.command()
46
+ @click.option("-u", "--user-id", type=str, default="me", help="User ID (default: me)")
47
+ @coro
48
+ async def list(user_id: str = "me") -> None:
49
+ """List permissions for a user."""
50
+ client = PermissionClient()
51
+ user_perms = await client.get_user_permissions(user_id)
52
+
53
+ click.echo(f"User: {click.style(user_perms.display_name, fg='green')} ({user_perms.email})")
54
+ click.echo(f"User ID: {click.style(user_perms.user_id, fg='blue')}")
55
+ click.echo(f"Status: {'Active' if user_perms.is_active else 'Inactive'}")
56
+ click.echo("\nPermissions:")
57
+
58
+ if user_perms.permissions:
59
+ for perm in user_perms.permissions:
60
+ click.echo(f" • {click.style(perm, fg='yellow')}")
61
+ else:
62
+ click.echo(click.style(" No permissions assigned", fg="red"))
63
+
64
+
65
+ @cli.command()
66
+ @click.argument("user_id")
67
+ @click.argument("permissions", nargs=-1, required=True)
68
+ @coro
69
+ async def set(user_id: str, permissions: tuple[str, ...]) -> None:
70
+ """Set permissions for a user (replaces all existing permissions)."""
71
+ async with PermissionClient() as client:
72
+ user_perms = await client.update_user_permissions(user_id, list(permissions))
73
+
74
+ click.echo("Permissions updated:")
75
+ click.echo(f" User: {click.style(user_perms.display_name, fg='green')}")
76
+ click.echo(f" Permissions: {', '.join([click.style(p, fg='yellow') for p in user_perms.permissions])}")
77
+
78
+
79
+ @cli.command()
80
+ @click.argument("user_id")
81
+ @click.argument("permission")
82
+ @coro
83
+ async def add(user_id: str, permission: str) -> None:
84
+ """Add a permission to a user."""
85
+ async with PermissionClient() as client:
86
+ user_perms = await client.add_user_permission(user_id, permission)
87
+
88
+ click.echo("Permission added:")
89
+ click.echo(f" User: {click.style(user_perms.display_name, fg='green')}")
90
+ click.echo(f" Added: {click.style(permission, fg='yellow')}")
91
+ click.echo(f" All permissions: {', '.join([click.style(p, fg='yellow') for p in user_perms.permissions])}")
92
+
93
+
94
+ @cli.command()
95
+ @click.argument("user_id")
96
+ @click.argument("permission")
97
+ @coro
98
+ async def remove(user_id: str, permission: str) -> None:
99
+ """Remove a permission from a user."""
100
+ async with PermissionClient() as client:
101
+ user_perms = await client.remove_user_permission(user_id, permission)
102
+
103
+ click.echo("Permission removed:")
104
+ click.echo(f" User: {click.style(user_perms.display_name, fg='green')}")
105
+ click.echo(f" Removed: {click.style(permission, fg='red')}")
106
+ click.echo(f" Remaining permissions: {', '.join([click.style(p, fg='yellow') for p in user_perms.permissions])}")
107
+
108
+
109
+ if __name__ == "__main__":
110
+ cli()
@@ -30,8 +30,6 @@ async def me() -> None:
30
30
  ["Email verified", profile.email_verified],
31
31
  ["User ID", profile.user.user_id],
32
32
  ["Is admin", profile.user.is_admin],
33
- ["Can upload", profile.user.can_upload],
34
- ["Can test", profile.user.can_test],
35
33
  ],
36
34
  headers=["Key", "Value"],
37
35
  tablefmt="simple",
@@ -90,7 +90,7 @@ class BaseClient:
90
90
  logger.info("Use KSCALE_VERBOSE_ERROR=1 to see the full error message")
91
91
  logger.info("If this persists, please create an issue here: https://github.com/kscalelabs/kscale")
92
92
 
93
- logger.error("Got error %d from the K-Scale API", error_code)
93
+ logger.error("Got error %d from the K-Scale API %s endpoint %s", error_code, method, url)
94
94
  if isinstance(error_json, Mapping):
95
95
  for key, value in error_json.items():
96
96
  logger.error(" [%s] %s", key, value)
@@ -1,6 +1,9 @@
1
1
  """Defines a unified client for the K-Scale WWW API."""
2
2
 
3
3
  from kscale.web.clients.base import BaseClient
4
+ from kscale.web.clients.clip import ClipClient
5
+ from kscale.web.clients.group import GroupClient
6
+ from kscale.web.clients.permission import PermissionClient
4
7
  from kscale.web.clients.robot import RobotClient
5
8
  from kscale.web.clients.robot_class import RobotClassClient
6
9
  from kscale.web.clients.user import UserClient
@@ -10,6 +13,9 @@ class WWWClient(
10
13
  RobotClient,
11
14
  RobotClassClient,
12
15
  UserClient,
16
+ ClipClient,
17
+ GroupClient,
18
+ PermissionClient,
13
19
  BaseClient,
14
20
  ):
15
21
  pass
@@ -0,0 +1,118 @@
1
+ """Defines the client for interacting with the K-Scale clip endpoints."""
2
+
3
+ import hashlib
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ import httpx
8
+
9
+ from kscale.web.clients.base import BaseClient
10
+ from kscale.web.gen.api import (
11
+ Clip,
12
+ ClipDownloadResponse,
13
+ ClipUploadResponse,
14
+ )
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ UPLOAD_TIMEOUT = 300.0
19
+ DOWNLOAD_TIMEOUT = 60.0
20
+
21
+
22
+ class ClipClient(BaseClient):
23
+ async def get_clips(self) -> list[Clip]:
24
+ """Get all clips for the authenticated user."""
25
+ data = await self._request("GET", "/clips/", auth=True)
26
+ return [Clip.model_validate(item) for item in data]
27
+
28
+ async def get_clip(self, clip_id: str) -> Clip:
29
+ """Get a specific clip by ID."""
30
+ data = await self._request("GET", f"/clips/{clip_id}", auth=True)
31
+ return Clip.model_validate(data)
32
+
33
+ async def create_clip(self, description: str | None = None) -> Clip:
34
+ """Create a new clip."""
35
+ data = {}
36
+ if description is not None:
37
+ data["description"] = description
38
+ response_data = await self._request("POST", "/clips/", data=data, auth=True)
39
+ return Clip.model_validate(response_data)
40
+
41
+ async def update_clip(self, clip_id: str, new_description: str | None = None) -> Clip:
42
+ """Update a clip's metadata."""
43
+ data = {}
44
+ if new_description is not None:
45
+ data["new_description"] = new_description
46
+ response_data = await self._request("POST", f"/clips/{clip_id}", data=data, auth=True)
47
+ return Clip.model_validate(response_data)
48
+
49
+ async def delete_clip(self, clip_id: str) -> None:
50
+ """Delete a clip."""
51
+ await self._request("DELETE", f"/clips/{clip_id}", auth=True)
52
+
53
+ async def upload_clip(self, clip_id: str, file_path: str | Path) -> ClipUploadResponse:
54
+ """Upload a file for a clip."""
55
+ if not (file_path := Path(file_path)).exists():
56
+ raise FileNotFoundError(f"File not found: {file_path}")
57
+
58
+ # Determine content type based on file extension
59
+ ext = file_path.suffix.lower()
60
+ content_type_map = {
61
+ ".mp4": "video/mp4",
62
+ ".avi": "video/x-msvideo",
63
+ ".mov": "video/quicktime",
64
+ ".mkv": "video/x-matroska",
65
+ ".webm": "video/webm",
66
+ ".json": "application/json",
67
+ ".txt": "text/plain",
68
+ }
69
+ content_type = content_type_map.get(ext, "application/octet-stream")
70
+
71
+ # Get upload URL
72
+ data = await self._request(
73
+ "PUT",
74
+ f"/clips/{clip_id}/upload",
75
+ data={"filename": file_path.name, "content_type": content_type},
76
+ auth=True,
77
+ )
78
+ response = ClipUploadResponse.model_validate(data)
79
+
80
+ # Upload the file
81
+ async with httpx.AsyncClient(timeout=httpx.Timeout(UPLOAD_TIMEOUT)) as client:
82
+ async with client.stream(
83
+ "PUT",
84
+ response.url,
85
+ content=file_path.read_bytes(),
86
+ headers={"Content-Type": response.content_type},
87
+ ) as r:
88
+ r.raise_for_status()
89
+
90
+ return response
91
+
92
+ async def download_clip(self, clip_id: str) -> ClipDownloadResponse:
93
+ """Get download URL and metadata for a clip."""
94
+ data = await self._request("GET", f"/clips/{clip_id}/download", auth=True)
95
+ return ClipDownloadResponse.model_validate(data)
96
+
97
+ async def download_clip_to_file(self, clip_id: str, output_path: str | Path) -> Path:
98
+ """Download a clip to a local file."""
99
+ download_response = await self.download_clip(clip_id)
100
+ output_path = Path(output_path)
101
+
102
+ async with httpx.AsyncClient(timeout=httpx.Timeout(DOWNLOAD_TIMEOUT)) as client:
103
+ async with client.stream("GET", download_response.url) as response:
104
+ response.raise_for_status()
105
+ with output_path.open("wb") as f:
106
+ async for chunk in response.aiter_bytes():
107
+ f.write(chunk)
108
+
109
+ # Verify download integrity if hash is provided
110
+ if download_response.md5_hash:
111
+ with output_path.open("rb") as f:
112
+ file_hash = hashlib.md5(f.read()).hexdigest()
113
+ if file_hash != download_response.md5_hash:
114
+ raise ValueError(
115
+ f"Downloaded file hash mismatch: expected {download_response.md5_hash}, got {file_hash}"
116
+ )
117
+
118
+ return output_path
@@ -0,0 +1,85 @@
1
+ """Defines the client for interacting with the K-Scale group endpoints."""
2
+
3
+ from kscale.web.clients.base import BaseClient
4
+ from kscale.web.gen.api import (
5
+ GroupMembershipResponse,
6
+ GroupResponse,
7
+ GroupShareResponse,
8
+ )
9
+
10
+
11
+ class GroupClient(BaseClient):
12
+ async def get_groups(self) -> list[GroupResponse]:
13
+ """Get all groups for the authenticated user."""
14
+ data = await self._request("GET", "/groups/", auth=True)
15
+ return [GroupResponse.model_validate(item) for item in data]
16
+
17
+ async def get_group(self, group_id: str) -> GroupResponse:
18
+ """Get a specific group by ID."""
19
+ data = await self._request("GET", f"/groups/{group_id}", auth=True)
20
+ return GroupResponse.model_validate(data)
21
+
22
+ async def create_group(self, name: str, description: str | None = None) -> GroupResponse:
23
+ """Create a new group."""
24
+ data = {"name": name}
25
+ if description is not None:
26
+ data["description"] = description
27
+ response_data = await self._request("POST", "/groups/", data=data, auth=True)
28
+ return GroupResponse.model_validate(response_data)
29
+
30
+ async def update_group(
31
+ self,
32
+ group_id: str,
33
+ name: str | None = None,
34
+ description: str | None = None,
35
+ ) -> GroupResponse:
36
+ """Update a group's metadata."""
37
+ data = {}
38
+ if name is not None:
39
+ data["name"] = name
40
+ if description is not None:
41
+ data["description"] = description
42
+ response_data = await self._request("POST", f"/groups/{group_id}", data=data, auth=True)
43
+ return GroupResponse.model_validate(response_data)
44
+
45
+ async def delete_group(self, group_id: str) -> None:
46
+ """Delete a group."""
47
+ await self._request("DELETE", f"/groups/{group_id}", auth=True)
48
+
49
+ # Group membership management
50
+ async def get_group_memberships(self, group_id: str) -> list[GroupMembershipResponse]:
51
+ """Get all memberships for a group."""
52
+ data = await self._request("GET", f"/groups/{group_id}/memberships", auth=True)
53
+ return [GroupMembershipResponse.model_validate(item) for item in data]
54
+
55
+ async def request_group_membership(self, group_id: str) -> GroupMembershipResponse:
56
+ """Request to join a group."""
57
+ data = await self._request("POST", f"/groups/{group_id}/memberships", auth=True)
58
+ return GroupMembershipResponse.model_validate(data)
59
+
60
+ async def approve_group_membership(self, group_id: str, user_id: str) -> GroupMembershipResponse:
61
+ """Approve a membership request."""
62
+ data = await self._request("POST", f"/groups/{group_id}/memberships/{user_id}/approve", auth=True)
63
+ return GroupMembershipResponse.model_validate(data)
64
+
65
+ async def reject_group_membership(self, group_id: str, user_id: str) -> None:
66
+ """Reject a membership request."""
67
+ await self._request("DELETE", f"/groups/{group_id}/memberships/{user_id}", auth=True)
68
+
69
+ # Group sharing management
70
+ async def get_group_shares(self, group_id: str) -> list[GroupShareResponse]:
71
+ """Get all resources shared with a group."""
72
+ data = await self._request("GET", f"/groups/{group_id}/shares", auth=True)
73
+ return [GroupShareResponse.model_validate(item) for item in data]
74
+
75
+ async def share_resource_with_group(
76
+ self, group_id: str, resource_type: str, resource_id: str
77
+ ) -> GroupShareResponse:
78
+ """Share a resource with a group."""
79
+ data = {"resource_type": resource_type, "resource_id": resource_id}
80
+ response_data = await self._request("POST", f"/groups/{group_id}/shares", data=data, auth=True)
81
+ return GroupShareResponse.model_validate(response_data)
82
+
83
+ async def unshare_resource_from_group(self, group_id: str, share_id: str) -> None:
84
+ """Remove a resource share from a group."""
85
+ await self._request("DELETE", f"/groups/{group_id}/shares/{share_id}", auth=True)
@@ -0,0 +1,35 @@
1
+ """Defines the client for interacting with the K-Scale permission endpoints."""
2
+
3
+ from kscale.web.clients.base import BaseClient
4
+ from kscale.web.gen.api import PermissionResponse, UserPermissionsResponse
5
+
6
+
7
+ class PermissionClient(BaseClient):
8
+ async def get_all_permissions(self) -> list[PermissionResponse]:
9
+ """Get all available permissions."""
10
+ data = await self._request("GET", "/permissions/list")
11
+ return [PermissionResponse.model_validate(item) for item in data]
12
+
13
+ async def get_user_permissions(self, user_id: str = "me") -> UserPermissionsResponse:
14
+ """Get permissions for a specific user."""
15
+ data = {"user_id": user_id}
16
+ data = await self._request("GET", "/permissions/user", data=data, auth=True)
17
+ return UserPermissionsResponse.model_validate(data)
18
+
19
+ async def update_user_permissions(self, user_id: str, permissions: list[str]) -> UserPermissionsResponse:
20
+ """Update permissions for a user."""
21
+ data = {"user_id": user_id, "permissions": permissions}
22
+ response_data = await self._request("PUT", "/permissions/user", data=data, auth=True)
23
+ return UserPermissionsResponse.model_validate(response_data)
24
+
25
+ async def add_user_permission(self, user_id: str, permission: str) -> UserPermissionsResponse:
26
+ """Add a single permission to a user."""
27
+ data = {"user_id": user_id, "permission": permission}
28
+ response_data = await self._request("POST", "/permissions/user", data=data, auth=True)
29
+ return UserPermissionsResponse.model_validate(response_data)
30
+
31
+ async def remove_user_permission(self, user_id: str, permission: str) -> UserPermissionsResponse:
32
+ """Remove a single permission from a user."""
33
+ params = {"user_id": user_id, "permission": permission}
34
+ response_data = await self._request("DELETE", "/permissions/user", params=params, auth=True)
35
+ return UserPermissionsResponse.model_validate(response_data)
@@ -2,7 +2,7 @@
2
2
 
3
3
  # generated by datamodel-codegen:
4
4
  # filename: openapi.json
5
- # timestamp: 2025-07-10T20:02:21+00:00
5
+ # timestamp: 2025-09-03T23:04:34+00:00
6
6
 
7
7
  from __future__ import annotations
8
8
 
@@ -24,6 +24,7 @@ class APIKey(BaseModel):
24
24
  last_used_at: Optional[datetime] = Field(None, title="Last Used At")
25
25
  expires_at: Optional[datetime] = Field(None, title="Expires At")
26
26
  is_active: Optional[bool] = Field(True, title="Is Active")
27
+ is_admin: Optional[bool] = Field(False, title="Is Admin")
27
28
 
28
29
 
29
30
  class APIKeyRequest(BaseModel):
@@ -78,6 +79,14 @@ class ActuatorMetadataOutput(BaseModel):
78
79
  error_gain: Optional[str] = Field(None, title="Error Gain")
79
80
 
80
81
 
82
+ class AddClipRequest(BaseModel):
83
+ description: Optional[str] = Field(None, title="Description")
84
+
85
+
86
+ class AddPermissionRequest(BaseModel):
87
+ permission: str = Field(..., title="Permission")
88
+
89
+
81
90
  class AddRobotClassRequest(BaseModel):
82
91
  description: Optional[str] = Field(None, title="Description")
83
92
 
@@ -111,6 +120,31 @@ class AgentUploadResponse(BaseModel):
111
120
  content_type: str = Field(..., title="Content Type")
112
121
 
113
122
 
123
+ class Clip(BaseModel):
124
+ id: str = Field(..., title="Id")
125
+ description: Optional[str] = Field(None, title="Description")
126
+ user_id: str = Field(..., title="User Id")
127
+ created_at: datetime = Field(..., title="Created At")
128
+ num_downloads: Optional[int] = Field(0, title="Num Downloads")
129
+ file_size: Optional[int] = Field(None, title="File Size")
130
+
131
+
132
+ class ClipDownloadResponse(BaseModel):
133
+ url: str = Field(..., title="Url")
134
+ md5_hash: str = Field(..., title="Md5 Hash")
135
+
136
+
137
+ class ClipUploadRequest(BaseModel):
138
+ filename: str = Field(..., title="Filename")
139
+ content_type: str = Field(..., title="Content Type")
140
+
141
+
142
+ class ClipUploadResponse(BaseModel):
143
+ url: str = Field(..., title="Url")
144
+ filename: str = Field(..., title="Filename")
145
+ content_type: str = Field(..., title="Content Type")
146
+
147
+
114
148
  class CreateAPIKeyRequest(BaseModel):
115
149
  name: str = Field(..., title="Name")
116
150
  permissions: List[str] = Field(..., title="Permissions")
@@ -126,6 +160,45 @@ class CreateAgentRequest(BaseModel):
126
160
  description: Optional[str] = Field(None, title="Description")
127
161
 
128
162
 
163
+ class CreateGroupRequest(BaseModel):
164
+ name: str = Field(..., title="Name")
165
+ description: Optional[str] = Field(None, title="Description")
166
+
167
+
168
+ class GroupMembershipResponse(BaseModel):
169
+ id: str = Field(..., title="Id")
170
+ group_id: str = Field(..., title="Group Id")
171
+ user_id: str = Field(..., title="User Id")
172
+ status: str = Field(..., title="Status")
173
+ requested_at: str = Field(..., title="Requested At")
174
+ approved_at: Optional[str] = Field(..., title="Approved At")
175
+ approved_by: Optional[str] = Field(..., title="Approved By")
176
+
177
+
178
+ class GroupResponse(BaseModel):
179
+ id: str = Field(..., title="Id")
180
+ name: str = Field(..., title="Name")
181
+ description: Optional[str] = Field(..., title="Description")
182
+ owner_id: str = Field(..., title="Owner Id")
183
+ created_at: str = Field(..., title="Created At")
184
+ updated_at: str = Field(..., title="Updated At")
185
+ is_active: bool = Field(..., title="Is Active")
186
+
187
+
188
+ class GroupShareRequest(BaseModel):
189
+ resource_type: str = Field(..., title="Resource Type")
190
+ resource_id: str = Field(..., title="Resource Id")
191
+
192
+
193
+ class GroupShareResponse(BaseModel):
194
+ id: str = Field(..., title="Id")
195
+ group_id: str = Field(..., title="Group Id")
196
+ resource_type: str = Field(..., title="Resource Type")
197
+ resource_id: str = Field(..., title="Resource Id")
198
+ shared_by: str = Field(..., title="Shared By")
199
+ shared_at: str = Field(..., title="Shared At")
200
+
201
+
129
202
  class JointMetadataInput(BaseModel):
130
203
  id: Optional[int] = Field(None, title="Id")
131
204
  kp: Optional[Union[float, str]] = Field(None, title="Kp")
@@ -161,6 +234,11 @@ class OIDCInfo(BaseModel):
161
234
  client_id: str = Field(..., title="Client Id")
162
235
 
163
236
 
237
+ class PermissionResponse(BaseModel):
238
+ permission: str = Field(..., title="Permission")
239
+ description: str = Field(..., title="Description")
240
+
241
+
164
242
  class Robot(BaseModel):
165
243
  id: str = Field(..., title="Id")
166
244
  robot_name: str = Field(..., title="Robot Name")
@@ -217,6 +295,15 @@ class UpdateAgentRequest(BaseModel):
217
295
  description: Optional[str] = Field(None, title="Description")
218
296
 
219
297
 
298
+ class UpdateClipRequest(BaseModel):
299
+ new_description: Optional[str] = Field(None, title="New Description")
300
+
301
+
302
+ class UpdateGroupRequest(BaseModel):
303
+ name: Optional[str] = Field(None, title="Name")
304
+ description: Optional[str] = Field(None, title="Description")
305
+
306
+
220
307
  class UpdateRobotClassRequest(BaseModel):
221
308
  new_class_name: Optional[str] = Field(None, title="New Class Name")
222
309
  new_description: Optional[str] = Field(None, title="New Description")
@@ -228,11 +315,21 @@ class UpdateRobotRequest(BaseModel):
228
315
  new_description: Optional[str] = Field(None, title="New Description")
229
316
 
230
317
 
318
+ class UpdateUserPermissionsRequest(BaseModel):
319
+ permissions: List[str] = Field(..., title="Permissions")
320
+
321
+
322
+ class UserPermissionsResponse(BaseModel):
323
+ user_id: str = Field(..., title="User Id")
324
+ display_name: str = Field(..., title="Display Name")
325
+ email: str = Field(..., title="Email")
326
+ permissions: List[str] = Field(..., title="Permissions")
327
+ is_active: bool = Field(..., title="Is Active")
328
+
329
+
231
330
  class UserResponse(BaseModel):
232
331
  user_id: str = Field(..., title="User Id")
233
332
  is_admin: bool = Field(..., title="Is Admin")
234
- can_upload: bool = Field(..., title="Can Upload")
235
- can_test: bool = Field(..., title="Can Test")
236
333
 
237
334
 
238
335
  class ValidationError(BaseModel):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kscale
3
- Version: 0.3.17
3
+ Version: 0.3.18
4
4
  Summary: The kscale project
5
5
  Home-page: https://github.com/kscalelabs/kscale
6
6
  Author: Benjamin Bolte
@@ -26,12 +26,18 @@ kscale/utils/cli.py
26
26
  kscale/web/__init__.py
27
27
  kscale/web/utils.py
28
28
  kscale/web/cli/__init__.py
29
+ kscale/web/cli/clip.py
30
+ kscale/web/cli/group.py
31
+ kscale/web/cli/permission.py
29
32
  kscale/web/cli/robot.py
30
33
  kscale/web/cli/robot_class.py
31
34
  kscale/web/cli/user.py
32
35
  kscale/web/clients/__init__.py
33
36
  kscale/web/clients/base.py
34
37
  kscale/web/clients/client.py
38
+ kscale/web/clients/clip.py
39
+ kscale/web/clients/group.py
40
+ kscale/web/clients/permission.py
35
41
  kscale/web/clients/robot.py
36
42
  kscale/web/clients/robot_class.py
37
43
  kscale/web/clients/user.py
@@ -74,7 +74,6 @@ select = [
74
74
  ]
75
75
 
76
76
  ignore = [
77
- "ANN101", "ANN102",
78
77
  "D101", "D102", "D103", "D104", "D105", "D106", "D107",
79
78
  "N812", "N817",
80
79
  "PLR0911", "PLR0912", "PLR0913", "PLR0915", "PLR2004",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes