kscale 0.0.2__py3-none-any.whl → 0.0.5__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 +1,5 @@
1
- __version__ = "0.0.2"
1
+ """Defines the common interface for the K-Scale Python API."""
2
+
3
+ __version__ = "0.0.5"
4
+
5
+ from kscale.api import KScale
kscale/api.py ADDED
@@ -0,0 +1,14 @@
1
+ """Defines common functionality for the K-Scale API."""
2
+
3
+ from kscale.store.api import StoreAPI
4
+ from kscale.utils.api_base import APIBase
5
+
6
+
7
+ class KScale(
8
+ StoreAPI,
9
+ APIBase,
10
+ ):
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/conf.py CHANGED
@@ -17,7 +17,7 @@ def get_path() -> Path:
17
17
 
18
18
  @dataclass
19
19
  class StoreSettings:
20
- api_key: str = field(default=II("oc.env:KSCALE_API_KEY,"))
20
+ api_key: str | None = field(default=None)
21
21
  cache_dir: str = field(default=II("oc.env:KSCALE_CACHE_DIR,'~/.kscale/cache/'"))
22
22
 
23
23
 
kscale/store/__init__.py CHANGED
@@ -1 +0,0 @@
1
- __version__ = "0.0.1"
kscale/store/api.py ADDED
@@ -0,0 +1,20 @@
1
+ """Defines a common interface for the K-Scale Store API."""
2
+
3
+ from pathlib import Path
4
+
5
+ from kscale.store.urdf import download_urdf
6
+ from kscale.utils.api_base import APIBase
7
+
8
+
9
+ class StoreAPI(APIBase):
10
+ def __init__(
11
+ self,
12
+ *,
13
+ api_key: str | None = None,
14
+ ) -> None:
15
+ super().__init__()
16
+
17
+ self.api_key = api_key
18
+
19
+ async def urdf(self, artifact_id: str) -> Path:
20
+ return await download_urdf(artifact_id)
kscale/store/cli.py CHANGED
@@ -1,12 +1,13 @@
1
1
  """Defines the top-level KOL CLI."""
2
2
 
3
3
  import argparse
4
+ import asyncio
4
5
  from typing import Sequence
5
6
 
6
7
  from kscale.store import pybullet, urdf
7
8
 
8
9
 
9
- def main(args: Sequence[str] | None = None) -> None:
10
+ async def main(args: Sequence[str] | None = None) -> None:
10
11
  parser = argparse.ArgumentParser(description="K-Scale OnShape Library", add_help=False)
11
12
  parser.add_argument(
12
13
  "subcommand",
@@ -20,11 +21,15 @@ def main(args: Sequence[str] | None = None) -> None:
20
21
 
21
22
  match parsed_args.subcommand:
22
23
  case "urdf":
23
- urdf.main(remaining_args)
24
+ await urdf.main(remaining_args)
24
25
  case "pybullet":
25
- pybullet.main(remaining_args)
26
+ await pybullet.main(remaining_args)
27
+
28
+
29
+ def sync_main(args: Sequence[str] | None = None) -> None:
30
+ asyncio.run(main(args))
26
31
 
27
32
 
28
33
  if __name__ == "__main__":
29
34
  # python3 -m kscale.store.cli
30
- main()
35
+ sync_main()
kscale/store/client.py ADDED
@@ -0,0 +1,79 @@
1
+ """Defines a typed client for the K-Scale Store API."""
2
+
3
+ import logging
4
+ from types import TracebackType
5
+ from typing import Any, Dict, Type
6
+ from urllib.parse import urljoin
7
+
8
+ import httpx
9
+ from pydantic import BaseModel
10
+
11
+ from kscale.store.gen.api import (
12
+ NewListingRequest,
13
+ NewListingResponse,
14
+ SingleArtifactResponse,
15
+ UploadArtifactResponse,
16
+ )
17
+ from kscale.store.utils import API_ROOT, get_api_key
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class KScaleStoreClient:
23
+ def __init__(self, base_url: str = API_ROOT) -> None:
24
+ self.base_url = base_url
25
+ self.client = httpx.AsyncClient(
26
+ base_url=self.base_url,
27
+ headers={"Authorization": f"Bearer {get_api_key()}"},
28
+ )
29
+
30
+ async def _request(
31
+ self,
32
+ method: str,
33
+ endpoint: str,
34
+ *,
35
+ params: Dict[str, Any] | None = None,
36
+ data: BaseModel | None = None,
37
+ files: Dict[str, Any] | None = None,
38
+ ) -> Dict[str, Any]:
39
+ url = urljoin(self.base_url, endpoint)
40
+ kwargs: Dict[str, Any] = {"params": params}
41
+
42
+ if data:
43
+ kwargs["json"] = data.dict(exclude_unset=True)
44
+ if files:
45
+ kwargs["files"] = files
46
+
47
+ response = await self.client.request(method, url, **kwargs)
48
+ if response.is_error:
49
+ logger.error(f"Error response from K-Scale Store: {response.text}")
50
+ response.raise_for_status()
51
+ return response.json()
52
+
53
+ async def get_artifact_info(self, artifact_id: str) -> SingleArtifactResponse:
54
+ data = await self._request("GET", f"/artifacts/info/{artifact_id}")
55
+ return SingleArtifactResponse(**data)
56
+
57
+ async def upload_artifact(self, listing_id: str, file_path: str) -> UploadArtifactResponse:
58
+ with open(file_path, "rb") as f:
59
+ files = {"files": (f.name, f, "application/gzip")}
60
+ data = await self._request("POST", f"/artifacts/upload/{listing_id}", files=files)
61
+ return UploadArtifactResponse(**data)
62
+
63
+ async def create_listing(self, request: NewListingRequest) -> NewListingResponse:
64
+ data = await self._request("POST", "/listings", data=request)
65
+ return NewListingResponse(**data)
66
+
67
+ async def close(self) -> None:
68
+ await self.client.aclose()
69
+
70
+ async def __aenter__(self) -> "KScaleStoreClient":
71
+ return self
72
+
73
+ async def __aexit__(
74
+ self,
75
+ exc_type: Type[BaseException] | None,
76
+ exc_val: BaseException | None,
77
+ exc_tb: TracebackType | None,
78
+ ) -> None:
79
+ await self.close()
kscale/store/gen/api.py CHANGED
@@ -2,12 +2,12 @@
2
2
 
3
3
  # generated by datamodel-codegen:
4
4
  # filename: openapi.json
5
- # timestamp: 2024-08-19T06:07:36+00:00
5
+ # timestamp: 2024-09-04T04:33:58+00:00
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
9
  from enum import Enum
10
- from typing import List, Optional, Union
10
+ from typing import Dict, List, Optional, Union
11
11
 
12
12
  from pydantic import BaseModel, EmailStr, Field
13
13
 
@@ -21,8 +21,9 @@ class AuthResponse(BaseModel):
21
21
  api_key: str = Field(..., title="Api Key")
22
22
 
23
23
 
24
- class BodySetUrdfUrdfUploadListingIdPost(BaseModel):
25
- file: bytes = Field(..., title="File")
24
+ class BodyPullOnshapeDocumentOnshapePullListingIdGet(BaseModel):
25
+ suffix_to_joint_effort: Optional[Dict[str, float]] = Field(None, title="Suffix To Joint Effort")
26
+ suffix_to_joint_velocity: Optional[Dict[str, float]] = Field(None, title="Suffix To Joint Velocity")
26
27
 
27
28
 
28
29
  class BodyUploadArtifactsUploadListingIdPost(BaseModel):
@@ -51,7 +52,13 @@ class GetListingResponse(BaseModel):
51
52
  description: Optional[str] = Field(..., title="Description")
52
53
  child_ids: List[str] = Field(..., title="Child Ids")
53
54
  tags: List[str] = Field(..., title="Tags")
55
+ onshape_url: Optional[str] = Field(..., title="Onshape Url")
54
56
  can_edit: bool = Field(..., title="Can Edit")
57
+ created_at: int = Field(..., title="Created At")
58
+ views: int = Field(..., title="Views")
59
+ score: int = Field(..., title="Score")
60
+ user_vote: Optional[bool] = Field(..., title="User Vote")
61
+ creator_id: str = Field(..., title="Creator Id")
55
62
 
56
63
 
57
64
  class GetTokenResponse(BaseModel):
@@ -82,42 +89,6 @@ class KeysResponseItem(BaseModel):
82
89
  permissions: Optional[List[Permission]] = Field(..., title="Permissions")
83
90
 
84
91
 
85
- class ArtifactType(Enum):
86
- image = "image"
87
-
88
-
89
- class ArtifactType1(Enum):
90
- urdf = "urdf"
91
- mjcf = "mjcf"
92
-
93
-
94
- class ArtifactType2(Enum):
95
- stl = "stl"
96
- obj = "obj"
97
- dae = "dae"
98
- ply = "ply"
99
-
100
-
101
- class ArtifactType3(Enum):
102
- tgz = "tgz"
103
- zip = "zip"
104
-
105
-
106
- class ListArtifactsItem(BaseModel):
107
- artifact_id: str = Field(..., title="Artifact Id")
108
- listing_id: str = Field(..., title="Listing Id")
109
- name: str = Field(..., title="Name")
110
- artifact_type: Union[ArtifactType, ArtifactType1, ArtifactType2, ArtifactType3] = Field(..., title="Artifact Type")
111
- description: Optional[str] = Field(..., title="Description")
112
- timestamp: int = Field(..., title="Timestamp")
113
- urls: ArtifactUrls
114
- is_new: Optional[bool] = Field(None, title="Is New")
115
-
116
-
117
- class ListArtifactsResponse(BaseModel):
118
- artifacts: List[ListArtifactsItem] = Field(..., title="Artifacts")
119
-
120
-
121
92
  class ListKeysResponse(BaseModel):
122
93
  keys: List[KeysResponseItem] = Field(..., title="Keys")
123
94
 
@@ -130,9 +101,16 @@ class ListListingsResponse(BaseModel):
130
101
  class Listing(BaseModel):
131
102
  id: str = Field(..., title="Id")
132
103
  user_id: str = Field(..., title="User Id")
104
+ created_at: int = Field(..., title="Created At")
105
+ updated_at: int = Field(..., title="Updated At")
133
106
  name: str = Field(..., title="Name")
134
107
  child_ids: List[str] = Field(..., title="Child Ids")
135
- description: Optional[str] = Field(..., title="Description")
108
+ description: Optional[str] = Field(None, title="Description")
109
+ onshape_url: Optional[str] = Field(None, title="Onshape Url")
110
+ views: Optional[int] = Field(0, title="Views")
111
+ upvotes: Optional[int] = Field(0, title="Upvotes")
112
+ downvotes: Optional[int] = Field(0, title="Downvotes")
113
+ score: Optional[int] = Field(0, title="Score")
136
114
 
137
115
 
138
116
  class ListingInfoResponse(BaseModel):
@@ -141,6 +119,11 @@ class ListingInfoResponse(BaseModel):
141
119
  description: Optional[str] = Field(..., title="Description")
142
120
  child_ids: List[str] = Field(..., title="Child Ids")
143
121
  image_url: Optional[str] = Field(..., title="Image Url")
122
+ onshape_url: Optional[str] = Field(..., title="Onshape Url")
123
+ created_at: int = Field(..., title="Created At")
124
+ views: int = Field(..., title="Views")
125
+ score: int = Field(..., title="Score")
126
+ user_vote: Optional[bool] = Field(..., title="User Vote")
144
127
 
145
128
 
146
129
  class LoginRequest(BaseModel):
@@ -163,6 +146,10 @@ class MyUserInfoResponse(BaseModel):
163
146
  github_id: Optional[str] = Field(..., title="Github Id")
164
147
  google_id: Optional[str] = Field(..., title="Google Id")
165
148
  permissions: Optional[List[Permission1]] = Field(..., title="Permissions")
149
+ first_name: Optional[str] = Field(..., title="First Name")
150
+ last_name: Optional[str] = Field(..., title="Last Name")
151
+ name: Optional[str] = Field(..., title="Name")
152
+ bio: Optional[str] = Field(..., title="Bio")
166
153
 
167
154
 
168
155
  class NewKeyRequest(BaseModel):
@@ -200,6 +187,48 @@ class PublicUsersInfoResponse(BaseModel):
200
187
  users: List[PublicUserInfoResponseItem] = Field(..., title="Users")
201
188
 
202
189
 
190
+ class SetRequest(BaseModel):
191
+ onshape_url: Optional[str] = Field(..., title="Onshape Url")
192
+
193
+
194
+ class ArtifactType(Enum):
195
+ image = "image"
196
+
197
+
198
+ class ArtifactType1(Enum):
199
+ urdf = "urdf"
200
+ mjcf = "mjcf"
201
+
202
+
203
+ class ArtifactType2(Enum):
204
+ stl = "stl"
205
+ obj = "obj"
206
+ dae = "dae"
207
+ ply = "ply"
208
+
209
+
210
+ class ArtifactType3(Enum):
211
+ tgz = "tgz"
212
+ zip = "zip"
213
+
214
+
215
+ class SingleArtifactResponse(BaseModel):
216
+ artifact_id: str = Field(..., title="Artifact Id")
217
+ listing_id: str = Field(..., title="Listing Id")
218
+ name: str = Field(..., title="Name")
219
+ artifact_type: Union[ArtifactType, ArtifactType1, ArtifactType2, ArtifactType3] = Field(..., title="Artifact Type")
220
+ description: Optional[str] = Field(..., title="Description")
221
+ timestamp: int = Field(..., title="Timestamp")
222
+ urls: ArtifactUrls
223
+ is_new: Optional[bool] = Field(None, title="Is New")
224
+
225
+
226
+ class SortOption(Enum):
227
+ newest = "newest"
228
+ most_viewed = "most_viewed"
229
+ most_upvoted = "most_upvoted"
230
+
231
+
203
232
  class UpdateArtifactRequest(BaseModel):
204
233
  name: Optional[str] = Field(None, title="Name")
205
234
  description: Optional[str] = Field(None, title="Description")
@@ -212,18 +241,19 @@ class UpdateListingRequest(BaseModel):
212
241
  tags: Optional[List[str]] = Field(None, title="Tags")
213
242
 
214
243
 
215
- class UploadArtifactResponse(BaseModel):
216
- artifacts: List[ListArtifactsItem] = Field(..., title="Artifacts")
217
-
218
-
219
- class UrdfInfo(BaseModel):
220
- artifact_id: str = Field(..., title="Artifact Id")
221
- url: str = Field(..., title="Url")
244
+ class UpdateUserRequest(BaseModel):
245
+ email: Optional[str] = Field(None, title="Email")
246
+ password: Optional[str] = Field(None, title="Password")
247
+ github_id: Optional[str] = Field(None, title="Github Id")
248
+ google_id: Optional[str] = Field(None, title="Google Id")
249
+ first_name: Optional[str] = Field(None, title="First Name")
250
+ last_name: Optional[str] = Field(None, title="Last Name")
251
+ name: Optional[str] = Field(None, title="Name")
252
+ bio: Optional[str] = Field(None, title="Bio")
222
253
 
223
254
 
224
- class UrdfResponse(BaseModel):
225
- urdf: Optional[UrdfInfo]
226
- listing_id: str = Field(..., title="Listing Id")
255
+ class UploadArtifactResponse(BaseModel):
256
+ artifacts: List[SingleArtifactResponse] = Field(..., title="Artifacts")
227
257
 
228
258
 
229
259
  class UserInfoResponseItem(BaseModel):
@@ -235,7 +265,7 @@ class UserPublic(BaseModel):
235
265
  id: str = Field(..., title="Id")
236
266
  email: str = Field(..., title="Email")
237
267
  permissions: Optional[List[Permission1]] = Field(None, title="Permissions")
238
- created_at: Optional[int] = Field(None, title="Created At")
268
+ created_at: int = Field(..., title="Created At")
239
269
  updated_at: Optional[int] = Field(None, title="Updated At")
240
270
  first_name: Optional[str] = Field(None, title="First Name")
241
271
  last_name: Optional[str] = Field(None, title="Last Name")
@@ -265,3 +295,7 @@ class GetBatchListingsResponse(BaseModel):
265
295
 
266
296
  class HTTPValidationError(BaseModel):
267
297
  detail: Optional[List[ValidationError]] = Field(None, title="Detail")
298
+
299
+
300
+ class ListArtifactsResponse(BaseModel):
301
+ artifacts: List[SingleArtifactResponse] = Field(..., title="Artifacts")
kscale/store/pybullet.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """Simple script to interact with a URDF in PyBullet."""
2
2
 
3
3
  import argparse
4
+ import asyncio
4
5
  import itertools
5
6
  import logging
6
7
  import math
@@ -8,12 +9,14 @@ import time
8
9
  from pathlib import Path
9
10
  from typing import Sequence
10
11
 
12
+ from kscale.store.urdf import download_urdf
13
+
11
14
  logger = logging.getLogger(__name__)
12
15
 
13
16
 
14
- def main(args: Sequence[str] | None = None) -> None:
17
+ async def main(args: Sequence[str] | None = None) -> None:
15
18
  parser = argparse.ArgumentParser(description="Show a URDF in PyBullet")
16
- parser.add_argument("urdf", nargs="?", help="Path to the URDF file")
19
+ parser.add_argument("listing_id", help="Listing ID for the URDF")
17
20
  parser.add_argument("--dt", type=float, default=0.01, help="Time step")
18
21
  parser.add_argument("-n", "--hide-gui", action="store_true", help="Hide the GUI")
19
22
  parser.add_argument("--no-merge", action="store_true", help="Do not merge fixed links")
@@ -23,6 +26,9 @@ def main(args: Sequence[str] | None = None) -> None:
23
26
  parser.add_argument("--show-collision", action="store_true", help="Show collision meshes")
24
27
  parsed_args = parser.parse_args(args)
25
28
 
29
+ # Gets the URDF path.
30
+ urdf_path = await download_urdf(parsed_args.listing_id)
31
+
26
32
  try:
27
33
  import pybullet as p # type: ignore[import-not-found]
28
34
  except ImportError:
@@ -46,13 +52,6 @@ def main(args: Sequence[str] | None = None) -> None:
46
52
  # Loads the floor plane.
47
53
  floor = p.loadURDF(str((Path(__file__).parent / "bullet" / "plane.urdf").resolve()))
48
54
 
49
- urdf_path = Path("robot" if parsed_args.urdf is None else parsed_args.urdf)
50
- if urdf_path.is_dir():
51
- try:
52
- urdf_path = next(urdf_path.glob("*.urdf"))
53
- except StopIteration:
54
- raise FileNotFoundError(f"No URDF files found in {urdf_path}")
55
-
56
55
  # Load the robot URDF.
57
56
  start_position = [0.0, 0.0, 1.0]
58
57
  start_orientation = p.getQuaternionFromEuler([0.0, 0.0, 0.0])
@@ -175,4 +174,4 @@ def main(args: Sequence[str] | None = None) -> None:
175
174
 
176
175
  if __name__ == "__main__":
177
176
  # python -m kscale.store.pybullet
178
- main()
177
+ asyncio.run(main())
kscale/store/urdf.py CHANGED
@@ -3,167 +3,169 @@
3
3
  import argparse
4
4
  import asyncio
5
5
  import logging
6
- import os
6
+ import shutil
7
7
  import sys
8
8
  import tarfile
9
9
  from pathlib import Path
10
- from typing import Literal, Sequence
10
+ from typing import Literal, Sequence, get_args
11
11
 
12
12
  import httpx
13
13
  import requests
14
14
 
15
15
  from kscale.conf import Settings
16
- from kscale.store.gen.api import UrdfResponse
16
+ from kscale.store.client import KScaleStoreClient
17
+ from kscale.store.gen.api import SingleArtifactResponse
18
+ from kscale.store.utils import get_api_key
17
19
 
18
20
  # Set up logging
19
21
  logging.basicConfig(level=logging.INFO)
20
22
  logger = logging.getLogger(__name__)
21
23
 
22
24
 
23
- def get_api_key() -> str:
24
- api_key = Settings.load().store.api_key
25
- if not api_key:
26
- raise ValueError(
27
- "API key not found! Get one here and set it as the `KSCALE_API_KEY` environment variable or in your"
28
- "config file: https://kscale.store/keys"
29
- )
30
- return api_key
31
-
32
-
33
25
  def get_cache_dir() -> Path:
34
26
  return Path(Settings.load().store.cache_dir).expanduser().resolve()
35
27
 
36
28
 
37
- def fetch_urdf_info(listing_id: str) -> UrdfResponse:
38
- url = f"https://api.kscale.store/urdf/info/{listing_id}"
39
- headers = {
40
- "Authorization": f"Bearer {get_api_key()}",
41
- }
42
- response = requests.get(url, headers=headers)
43
- response.raise_for_status()
44
- return UrdfResponse(**response.json())
29
+ def get_artifact_dir(artifact_id: str) -> Path:
30
+ cache_dir = get_cache_dir() / artifact_id
31
+ cache_dir.mkdir(parents=True, exist_ok=True)
32
+ return cache_dir
45
33
 
46
34
 
47
- async def download_artifact(artifact_url: str, cache_dir: Path) -> str:
48
- filename = os.path.join(cache_dir, artifact_url.split("/")[-1])
35
+ async def fetch_urdf_info(artifact_id: str, cache_dir: Path) -> SingleArtifactResponse:
36
+ response_path = cache_dir / "response.json"
37
+ if response_path.exists():
38
+ return SingleArtifactResponse.model_validate_json(response_path.read_text())
39
+ async with KScaleStoreClient() as client:
40
+ response = await client.get_artifact_info(artifact_id)
41
+ response_path.write_text(response.model_dump_json())
42
+ return response
43
+
44
+
45
+ async def download_artifact(artifact_url: str, cache_dir: Path) -> Path:
46
+ filename = cache_dir / Path(artifact_url).name
49
47
  headers = {
50
48
  "Authorization": f"Bearer {get_api_key()}",
51
49
  }
52
50
 
53
- if not os.path.exists(filename):
54
- logger.info("Downloading artifact from %s" % artifact_url)
51
+ if not filename.exists():
52
+ logger.info("Downloading artifact from %s", artifact_url)
55
53
 
56
54
  async with httpx.AsyncClient() as client:
57
55
  response = await client.get(artifact_url, headers=headers)
58
56
  response.raise_for_status()
59
- with open(filename, "wb") as f:
60
- for chunk in response.iter_bytes(chunk_size=8192):
61
- f.write(chunk)
62
- logger.info("Artifact downloaded to %s" % filename)
57
+ filename.write_bytes(response.content)
58
+ logger.info("Artifact downloaded to %s", filename)
63
59
  else:
64
- logger.info("Artifact already cached at %s" % filename)
60
+ logger.info("Artifact already cached at %s", filename)
65
61
 
66
62
  # Extract the .tgz file
67
- extract_dir = os.path.join(cache_dir, os.path.splitext(os.path.basename(filename))[0])
68
- if not os.path.exists(extract_dir):
69
- logger.info(f"Extracting {filename} to {extract_dir}")
63
+ extract_dir = cache_dir / filename.stem
64
+ if not extract_dir.exists():
65
+ logger.info("Extracting %s to %s", filename, extract_dir)
70
66
  with tarfile.open(filename, "r:gz") as tar:
71
67
  tar.extractall(path=extract_dir)
72
- logger.info("Extraction complete")
73
68
  else:
74
- logger.info("Artifact already extracted at %s" % extract_dir)
69
+ logger.info("Artifact already extracted at %s", extract_dir)
75
70
 
76
71
  return extract_dir
77
72
 
78
73
 
79
- def create_tarball(folder_path: str | Path, output_filename: str, cache_dir: Path) -> str:
80
- tarball_path = os.path.join(cache_dir, output_filename)
74
+ def create_tarball(folder_path: Path, output_filename: str, cache_dir: Path) -> Path:
75
+ tarball_path = cache_dir / output_filename
81
76
  with tarfile.open(tarball_path, "w:gz") as tar:
82
- for root, _, files in os.walk(folder_path):
83
- for file in files:
84
- file_path = os.path.join(root, file)
85
- arcname = os.path.relpath(file_path, start=folder_path)
86
- tar.add(file_path, arcname=arcname)
87
- logger.info("Added %s as %s" % (file_path, arcname))
88
- logger.info("Created tarball %s" % tarball_path)
77
+ for file_path in folder_path.rglob("*"):
78
+ if file_path.is_file() and file_path.suffix.lower() in (".urdf", ".mjcf", ".stl", ".obj", ".dae"):
79
+ tar.add(file_path, arcname=file_path.relative_to(folder_path))
80
+ logger.info("Added %s to tarball", file_path)
81
+ else:
82
+ logger.warning("Skipping %s", file_path)
83
+ logger.info("Created tarball %s", tarball_path)
89
84
  return tarball_path
90
85
 
91
86
 
92
- async def upload_artifact(tarball_path: str, listing_id: str) -> None:
93
- url = f"https://api.kscale.store/urdf/upload/{listing_id}"
94
- headers = {
95
- "Authorization": f"Bearer {get_api_key()}",
96
- }
87
+ async def download_urdf(artifact_id: str) -> Path:
88
+ cache_dir = get_artifact_dir(artifact_id)
89
+ try:
90
+ urdf_info = await fetch_urdf_info(artifact_id, cache_dir)
91
+ artifact_url = urdf_info.urls.large
92
+ return await download_artifact(artifact_url, cache_dir)
93
+
94
+ except requests.RequestException:
95
+ logger.exception("Failed to fetch URDF info")
96
+ raise
97
+
98
+
99
+ async def show_urdf_info(artifact_id: str) -> None:
100
+ try:
101
+ urdf_info = await fetch_urdf_info(artifact_id, get_artifact_dir(artifact_id))
102
+ logger.info("URDF Artifact ID: %s", urdf_info.artifact_id)
103
+ logger.info("URDF URL: %s", urdf_info.urls.large)
104
+ except requests.RequestException:
105
+ logger.exception("Failed to fetch URDF info")
106
+ raise
107
+
108
+
109
+ async def remove_local_urdf(artifact_id: str) -> None:
110
+ try:
111
+ if artifact_id.lower() == "all":
112
+ cache_dir = get_cache_dir()
113
+ if cache_dir.exists():
114
+ logger.info("Removing all local caches at %s", cache_dir)
115
+ shutil.rmtree(cache_dir)
116
+ else:
117
+ logger.error("No local caches found")
118
+ else:
119
+ artifact_dir = get_artifact_dir(artifact_id)
120
+ if artifact_dir.exists():
121
+ logger.info("Removing local cache at %s", artifact_dir)
122
+ shutil.rmtree(artifact_dir)
123
+ else:
124
+ logger.error("No local cache found for artifact %s", artifact_id)
125
+
126
+ except Exception:
127
+ logger.error("Failed to remove local cache")
128
+ raise
129
+
130
+
131
+ async def upload_urdf(listing_id: str, args: Sequence[str]) -> None:
132
+ parser = argparse.ArgumentParser(description="K-Scale URDF Store", add_help=False)
133
+ parser.add_argument("root_dir", type=Path, help="The path to the root directory to upload")
134
+ parsed_args = parser.parse_args(args)
97
135
 
98
- async with httpx.AsyncClient() as client:
99
- with open(tarball_path, "rb") as f:
100
- files = {"file": (f.name, f, "application/gzip")}
101
- response = await client.post(url, headers=headers, files=files)
136
+ root_dir = parsed_args.root_dir
137
+ tarball_path = create_tarball(root_dir, "robot.tgz", get_artifact_dir(listing_id))
138
+
139
+ async with KScaleStoreClient() as client:
140
+ response = await client.upload_artifact(listing_id, str(tarball_path))
141
+
142
+ logger.info("Uploaded artifacts: %s", [artifact.artifact_id for artifact in response.artifacts])
102
143
 
103
- response.raise_for_status()
104
144
 
105
- logger.info("Uploaded artifact to %s" % url)
145
+ Command = Literal["download", "info", "upload", "remove-local"]
106
146
 
107
147
 
108
- def main(args: Sequence[str] | None = None) -> None:
148
+ async def main(args: Sequence[str] | None = None) -> None:
109
149
  parser = argparse.ArgumentParser(description="K-Scale URDF Store", add_help=False)
110
- parser.add_argument(
111
- "command",
112
- choices=["get", "info", "upload"],
113
- help="The command to run",
114
- )
115
- parser.add_argument("listing_id", help="The listing ID to operate on")
150
+ parser.add_argument("command", choices=get_args(Command), help="The command to run")
151
+ parser.add_argument("id", help="The ID to use (artifact when downloading, listing when uploading)")
116
152
  parsed_args, remaining_args = parser.parse_known_args(args)
117
153
 
118
- command: Literal["get", "info", "upload"] = parsed_args.command
119
- listing_id: str = parsed_args.listing_id
120
-
121
- def get_listing_dir() -> Path:
122
- (cache_dir := get_cache_dir() / listing_id).mkdir(parents=True, exist_ok=True)
123
- return cache_dir
154
+ command: Command = parsed_args.command
155
+ id: str = parsed_args.id
124
156
 
125
157
  match command:
126
- case "get":
127
- try:
128
- urdf_info = fetch_urdf_info(listing_id)
129
-
130
- if urdf_info.urdf:
131
- artifact_url = urdf_info.urdf.url
132
- asyncio.run(download_artifact(artifact_url, get_listing_dir()))
133
- else:
134
- logger.info("No URDF found for listing %s" % listing_id)
135
- except requests.RequestException as e:
136
- logger.error("Failed to fetch URDF info: %s" % e)
137
- sys.exit(1)
158
+ case "download":
159
+ await download_urdf(id)
138
160
 
139
161
  case "info":
140
- try:
141
- urdf_info = fetch_urdf_info(listing_id)
142
-
143
- if urdf_info.urdf:
144
- logger.info("URDF Artifact ID: %s" % urdf_info.urdf.artifact_id)
145
- logger.info("URDF URL: %s" % urdf_info.urdf.url)
146
- else:
147
- logger.info("No URDF found for listing %s" % listing_id)
148
- except requests.RequestException as e:
149
- logger.error("Failed to fetch URDF info: %s" % e)
150
- sys.exit(1)
151
-
152
- case "upload":
153
- parser = argparse.ArgumentParser(description="Upload a URDF artifact to the K-Scale store")
154
- parser.add_argument("folder_path", help="The path to the folder containing the URDF files")
155
- parsed_args = parser.parse_args(remaining_args)
156
- folder_path = Path(parsed_args.folder_path).expanduser().resolve()
162
+ await show_urdf_info(id)
157
163
 
158
- output_filename = f"{listing_id}.tgz"
159
- tarball_path = create_tarball(folder_path, output_filename, get_listing_dir())
164
+ case "remove-local":
165
+ await remove_local_urdf(id)
160
166
 
161
- try:
162
- urdf_info = fetch_urdf_info(listing_id)
163
- asyncio.run(upload_artifact(tarball_path, listing_id))
164
- except requests.RequestException as e:
165
- logger.error("Failed to upload artifact: %s" % e)
166
- sys.exit(1)
167
+ case "upload":
168
+ await upload_urdf(id, remaining_args)
167
169
 
168
170
  case _:
169
171
  logger.error("Invalid command")
@@ -171,4 +173,4 @@ def main(args: Sequence[str] | None = None) -> None:
171
173
 
172
174
 
173
175
  if __name__ == "__main__":
174
- main()
176
+ asyncio.run(main())
kscale/store/utils.py ADDED
@@ -0,0 +1,19 @@
1
+ """Utility functions for interacting with the K-Scale Store API."""
2
+
3
+ import os
4
+
5
+ from kscale.conf import Settings
6
+
7
+ API_ROOT = "https://api.kscale.store"
8
+
9
+
10
+ def get_api_key() -> str:
11
+ api_key = Settings.load().store.api_key
12
+ if api_key is None:
13
+ api_key = os.getenv("KSCALE_API_KEY")
14
+ if not api_key:
15
+ raise ValueError(
16
+ "API key not found! Get one here and set it as the `KSCALE_API_KEY` environment variable or in your "
17
+ "config file: https://kscale.store/keys"
18
+ )
19
+ return api_key
File without changes
@@ -0,0 +1,6 @@
1
+ """Defines the base class for the K-Scale API."""
2
+
3
+
4
+ class APIBase:
5
+ def __init__(self) -> None:
6
+ pass
@@ -1,15 +1,16 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: kscale
3
- Version: 0.0.2
3
+ Version: 0.0.5
4
4
  Summary: The kscale project
5
5
  Home-page: https://github.com/kscalelabs/kscale
6
6
  Author: Benjamin Bolte
7
7
  Requires-Python: >=3.11
8
8
  Description-Content-Type: text/markdown
9
9
  License-File: LICENSE
10
- Requires-Dist: omegaconf
11
- Requires-Dist: httpx
12
- Requires-Dist: requests
10
+ Requires-Dist: omegaconf ==2.3.0
11
+ Requires-Dist: email-validator ==2.2.0
12
+ Requires-Dist: httpx ==0.27.0
13
+ Requires-Dist: requests ==2.32.2
13
14
  Provides-Extra: dev
14
15
  Requires-Dist: black ; extra == 'dev'
15
16
  Requires-Dist: darglint ; extra == 'dev'
@@ -50,3 +51,32 @@ This is a command line tool for interacting with various services provided by K-
50
51
  ```bash
51
52
  pip install kscale
52
53
  ```
54
+
55
+ ## Usage
56
+
57
+ ### CLI
58
+
59
+ Download a URDF from the K-Scale Store:
60
+
61
+ ```bash
62
+ kscale urdf download <artifact_id>
63
+ ```
64
+
65
+ Upload a URDF to the K-Scale Store:
66
+
67
+ ```bash
68
+ kscale urdf upload <artifact_id> <root_dir>
69
+ ```
70
+
71
+ ### Python API
72
+
73
+ Reference a URDF by ID from the K-Scale Store:
74
+
75
+ ```python
76
+ from kscale import KScale
77
+
78
+ async def main():
79
+ kscale = KScale()
80
+ urdf_dir_path = await kscale.store.urdf("123456")
81
+ print(urdf_dir_path)
82
+ ```
@@ -0,0 +1,21 @@
1
+ kscale/__init__.py,sha256=e1NdSlxsBKi5qyOIjXC3vAPQXleZwh2hyVlLHcKQsq8,117
2
+ kscale/api.py,sha256=xBtKj8rgZ400r1Xx9LRY0AzSgIIttoXdejmhHhdVGS0,333
3
+ kscale/conf.py,sha256=9fShFaYTbnrm_eiGjmy8ZtC4Q4m6PQkWPyoF3eNyov8,1424
4
+ kscale/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ kscale/store/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ kscale/store/api.py,sha256=L-QZMDeOsNo8ovIRjwfBlidYKD510hdoKrmrue7C5Wk,454
7
+ kscale/store/cli.py,sha256=8ygg_1tZzOOHJotEIgSN9pfumcriPmA31sI_FCFQiTo,859
8
+ kscale/store/client.py,sha256=HEyTzgCOXdLCQKdFKwNglvTBtXVipul7cxbdjz53Pb4,2544
9
+ kscale/store/pybullet.py,sha256=viTQCE2jT72miPKZpFKj4f4zGLLYtjhRFxGqzxd2Z8M,7504
10
+ kscale/store/urdf.py,sha256=zMUfJNNpZkoLf-SnhMO28f8Qj39hmZrA0vqLL3sZSqE,6069
11
+ kscale/store/utils.py,sha256=vQSFd9fByDUUSD0dAA65T_WI5R55vY-HOQjIJg_u2jw,536
12
+ kscale/store/gen/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ kscale/store/gen/api.py,sha256=82D41J6pg9KWdgD0lx7NggLcNS32SpnN8DqE3Md6ON0,9559
14
+ kscale/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ kscale/utils/api_base.py,sha256=Kk_WtRDdJHmOg6NtHmVxVrcfARSUkhfr29ypLch_pO0,112
16
+ kscale-0.0.5.dist-info/LICENSE,sha256=HCN2bImAzUOXldAZZI7JZ9PYq6OwMlDAP_PpX1HnuN0,1071
17
+ kscale-0.0.5.dist-info/METADATA,sha256=jAkpxXCrUa8b30BY32OaW6eIoR0VFHQzKgzO26EDFGM,2514
18
+ kscale-0.0.5.dist-info/WHEEL,sha256=uCRv0ZEik_232NlR4YDw4Pv3Ajt5bKvMH13NUU7hFuI,91
19
+ kscale-0.0.5.dist-info/entry_points.txt,sha256=PaVs1ivqB0BBdGUsiFkxGUYjGLz05VqagxwRVwi4yV4,54
20
+ kscale-0.0.5.dist-info/top_level.txt,sha256=C2ynjYwopg6YjgttnI2dJjasyq3EKNmYp-IfQg9Xms4,7
21
+ kscale-0.0.5.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (73.0.1)
2
+ Generator: setuptools (74.1.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kscale = kscale.store.cli:sync_main
@@ -1,15 +0,0 @@
1
- kscale/__init__.py,sha256=QvlVh4JTl3JL7jQAja76yKtT-IvF4631ASjWY1wS6AQ,22
2
- kscale/conf.py,sha256=dnO8qii7JeMPMdjfuxnFXURRM4krwzL404RnwDkyL2M,1441
3
- kscale/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- kscale/store/__init__.py,sha256=sXLh7g3KC4QCFxcZGBTpG2scR7hmmBsMjq6LqRptkRg,22
5
- kscale/store/cli.py,sha256=sf6F8ZLMenNpxi9dPB6kpaUOmRdfNBcKSnAcoN7EMo0,733
6
- kscale/store/pybullet.py,sha256=2Pog9wPSyyhmhTJY6x5KhuUQgenpTPIvp_9wxOy9ZaU,7622
7
- kscale/store/urdf.py,sha256=-dp0DEMoDNIqieG9egPv_XaV7Cg0GthMI_QIYvG2Xjk,6185
8
- kscale/store/gen/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
- kscale/store/gen/api.py,sha256=7r6KvlZyB-kWKSCixc-YOtaWPy2mn0v11d2tuLlRMSc,7700
10
- kscale-0.0.2.dist-info/LICENSE,sha256=HCN2bImAzUOXldAZZI7JZ9PYq6OwMlDAP_PpX1HnuN0,1071
11
- kscale-0.0.2.dist-info/METADATA,sha256=fNvMO1rM9-UfnHjp3BZ4t4FmcQ-CRPAmBDAdXRkBu-8,2028
12
- kscale-0.0.2.dist-info/WHEEL,sha256=Mdi9PDNwEZptOjTlUcAth7XJDFtKrHYaQMPulZeBCiQ,91
13
- kscale-0.0.2.dist-info/entry_points.txt,sha256=Vfj9O643497OONpgwy5UJLJ5a5Q1CfHZSuYKDB9D_GI,49
14
- kscale-0.0.2.dist-info/top_level.txt,sha256=C2ynjYwopg6YjgttnI2dJjasyq3EKNmYp-IfQg9Xms4,7
15
- kscale-0.0.2.dist-info/RECORD,,
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- kscale = kscale.store.cli:main