idegym-api 0.8.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.
- idegym/api/__init__.py +9 -0
- idegym/api/auth.py +21 -0
- idegym/api/config.py +186 -0
- idegym/api/ctx.py +7 -0
- idegym/api/data.py +201 -0
- idegym/api/docker.py +71 -0
- idegym/api/download.py +41 -0
- idegym/api/exceptions.py +10 -0
- idegym/api/git.py +103 -0
- idegym/api/health.py +8 -0
- idegym/api/image_build.py +28 -0
- idegym/api/orchestrator/__init__.py +0 -0
- idegym/api/orchestrator/build.py +34 -0
- idegym/api/orchestrator/clients.py +67 -0
- idegym/api/orchestrator/jobs.py +21 -0
- idegym/api/orchestrator/operations.py +58 -0
- idegym/api/orchestrator/servers.py +95 -0
- idegym/api/paths.py +45 -0
- idegym/api/project/__init__.py +0 -0
- idegym/api/project/reset.py +14 -0
- idegym/api/rewards/__init__.py +0 -0
- idegym/api/rewards/compilation.py +19 -0
- idegym/api/rewards/inspection.py +18 -0
- idegym/api/rewards/setup.py +19 -0
- idegym/api/rewards/test.py +26 -0
- idegym/api/status.py +7 -0
- idegym/api/tools/__init__.py +0 -0
- idegym/api/tools/bash.py +19 -0
- idegym/api/tools/file.py +26 -0
- idegym/api/type.py +56 -0
- idegym_api-0.8.0.dist-info/METADATA +10 -0
- idegym_api-0.8.0.dist-info/RECORD +33 -0
- idegym_api-0.8.0.dist-info/WHEEL +4 -0
idegym/api/__init__.py
ADDED
idegym/api/auth.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from base64 import b64encode
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field, SecretStr
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BasicAuth(BaseModel):
|
|
8
|
+
"""Basic HTTP authentication credentials. Both fields are excluded from serialization."""
|
|
9
|
+
|
|
10
|
+
username: Optional[str] = Field(default=None, exclude=True)
|
|
11
|
+
password: Optional[SecretStr] = Field(default=None, exclude=True)
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def base64(self) -> Optional[str]:
|
|
15
|
+
"""Returns Base64-encoded 'username:password' string, or None if username is not set."""
|
|
16
|
+
if not self.username:
|
|
17
|
+
return None
|
|
18
|
+
password = self.password.get_secret_value() if self.password else ""
|
|
19
|
+
value = self.username + ":" + password
|
|
20
|
+
encoded = value.encode()
|
|
21
|
+
return b64encode(encoded).decode()
|
idegym/api/config.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from os.path import abspath, join
|
|
2
|
+
from tempfile import gettempdir
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from idegym.api.auth import BasicAuth
|
|
6
|
+
from idegym.api.data import DataSize
|
|
7
|
+
from idegym.api.type import Duration, HttpUrl, IPvAddress, LogLevelName
|
|
8
|
+
from pydantic import BaseModel, Field, field_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ServerConfig(BaseModel):
|
|
12
|
+
host: IPvAddress = Field(default="0.0.0.0")
|
|
13
|
+
port: int = Field(ge=0, le=65535, default=8000)
|
|
14
|
+
response_buffer_size: DataSize = Field(ge=0, default=DataSize(mb=8))
|
|
15
|
+
shutdown_delay: Duration = Field(default=Duration(seconds=30))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LoggingConfig(BaseModel):
|
|
19
|
+
level: LogLevelName = Field(default="INFO")
|
|
20
|
+
json_format: bool = Field(default=False)
|
|
21
|
+
file_path: str = Field(default=join(gettempdir(), "idegym.log"))
|
|
22
|
+
max_file_size: DataSize = Field(ge=0, default=DataSize(mb=10))
|
|
23
|
+
max_file_count: int = Field(description="Number of log file backups to keep", ge=0, default=5)
|
|
24
|
+
|
|
25
|
+
@field_validator("file_path")
|
|
26
|
+
def validate_file_path(cls, value: str) -> str:
|
|
27
|
+
path = value.strip() if value else None
|
|
28
|
+
if not path:
|
|
29
|
+
field = cls.__pydantic_fields__["file_path"]
|
|
30
|
+
return field.default
|
|
31
|
+
else:
|
|
32
|
+
return abspath(path)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ProjectConfig(BaseModel):
|
|
36
|
+
path: str = Field(default=".project")
|
|
37
|
+
archive: Optional[str] = Field(default=None)
|
|
38
|
+
|
|
39
|
+
@field_validator("path")
|
|
40
|
+
def validate_path(cls, value: str) -> str:
|
|
41
|
+
path = value.strip() if value else None
|
|
42
|
+
if not path:
|
|
43
|
+
field = cls.__pydantic_fields__["path"]
|
|
44
|
+
return field.default
|
|
45
|
+
else:
|
|
46
|
+
return abspath(path)
|
|
47
|
+
|
|
48
|
+
@field_validator("archive")
|
|
49
|
+
def validate_archive_path(cls, value: Optional[str]) -> Optional[str]:
|
|
50
|
+
path = value.strip() if value else None
|
|
51
|
+
if not path:
|
|
52
|
+
field = cls.__pydantic_fields__["archive"]
|
|
53
|
+
return field.default
|
|
54
|
+
else:
|
|
55
|
+
return abspath(path)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DatabaseConfig(BaseModel):
|
|
59
|
+
host: str = Field(default="localhost")
|
|
60
|
+
port: str = Field(default="5432")
|
|
61
|
+
user: str = Field(default="postgres")
|
|
62
|
+
password: str = Field(default="postgres")
|
|
63
|
+
db: str = Field(default="idegym")
|
|
64
|
+
clean_database: bool = Field(description="Drop and recreate all tables on startup", default=False)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def url(self) -> str:
|
|
68
|
+
return f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}:{self.port}/{self.db}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SQLAlchemyConfig(BaseModel):
|
|
72
|
+
pool_size: int = Field(ge=0, default=20)
|
|
73
|
+
max_overflow: int = Field(ge=0, default=5)
|
|
74
|
+
pool_recycle: int = Field(ge=-1, default=1800, description="Connection recycling interval in seconds")
|
|
75
|
+
pool_timeout: int = Field(gt=0, default=1200, description="Connection acquisition timeout in seconds")
|
|
76
|
+
pool_pre_ping: bool = Field(default=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AsyncioConfig(BaseModel):
|
|
80
|
+
debug: bool = Field(default=False)
|
|
81
|
+
dump_interval: int = Field(description="Interval in seconds between asyncio task dumps", ge=1, default=300)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ResourcesConfig(BaseModel):
|
|
85
|
+
default_cpu_request: float = Field(description="Default CPU cores per environment", ge=0, default=1.0)
|
|
86
|
+
default_ram_request: float = Field(description="Default RAM per environment in GB", ge=0, default=2.0)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ConnectionLimitsConfig(BaseModel):
|
|
90
|
+
max_connections_or_asyncio_tasks: int = Field(
|
|
91
|
+
description="The maximum number of concurrent connections that may be established or asyncio tasks in uvicorn.",
|
|
92
|
+
ge=1,
|
|
93
|
+
default=1500,
|
|
94
|
+
)
|
|
95
|
+
unhealthy_connections_or_asyncio_tasks: int = Field(
|
|
96
|
+
description="The maximum number of concurrent connections that"
|
|
97
|
+
" may be established or asyncio tasks in uvicorn after which orchestrator becomes unhealthy.",
|
|
98
|
+
ge=1,
|
|
99
|
+
default=1000,
|
|
100
|
+
)
|
|
101
|
+
max_keepalive_connections: int = Field(
|
|
102
|
+
description="Allow the connection pool to maintain keep-alive connections below this point."
|
|
103
|
+
"Should be less than or equal to `max_connections`",
|
|
104
|
+
ge=1,
|
|
105
|
+
default=20,
|
|
106
|
+
)
|
|
107
|
+
keepalive_expiry: float = Field(
|
|
108
|
+
description="Time limit on idle keep-alive connections in seconds.", ge=1.0, default=5.0
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TracingConfig(BaseModel):
|
|
113
|
+
endpoint: Optional[HttpUrl] = Field(description="OTLP HTTP endpoint for traces", default=None)
|
|
114
|
+
timeout: float = Field(description="Timeout for sending traces in seconds", ge=0, default=10)
|
|
115
|
+
auth: BasicAuth = Field(default_factory=BasicAuth)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def enabled(self) -> bool:
|
|
119
|
+
return bool(self.endpoint)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class OTELConfig(BaseModel):
|
|
123
|
+
service_name: Optional[str] = Field(default=None)
|
|
124
|
+
tracing: TracingConfig = Field(default_factory=TracingConfig)
|
|
125
|
+
attributes: dict[str, str] = Field(description="Extra attributes added to all spans", default_factory=dict)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class WatcherConfig(BaseModel):
|
|
129
|
+
cleanup_interval: Duration = Field(default=Duration(seconds=60))
|
|
130
|
+
inactive_timeout: Duration = Field(
|
|
131
|
+
description="Inactivity timeout after which idle servers/clients are cleaned up",
|
|
132
|
+
default=Duration(minutes=10),
|
|
133
|
+
)
|
|
134
|
+
finished_timeout: Duration = Field(
|
|
135
|
+
description="How long to keep finished servers before deleting them",
|
|
136
|
+
default=Duration(minutes=5),
|
|
137
|
+
)
|
|
138
|
+
request_max_age: Duration = Field(
|
|
139
|
+
description="Maximum age of request records to retain",
|
|
140
|
+
default=Duration(days=14),
|
|
141
|
+
)
|
|
142
|
+
request_stale: Duration = Field(
|
|
143
|
+
description="Age after which IN_PROGRESS requests are marked as finished",
|
|
144
|
+
default=Duration(hours=24),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class OrchestratorConfig(BaseModel):
|
|
149
|
+
host: IPvAddress = Field(default="0.0.0.0")
|
|
150
|
+
port: int = Field(ge=0, le=65535, default=8000)
|
|
151
|
+
workers: int = Field(description="Number of uvicorn worker processes", ge=1, default=1)
|
|
152
|
+
prometheus_multiproc_dir: str = Field(
|
|
153
|
+
description="Directory for Prometheus multiprocess metric files",
|
|
154
|
+
default=join(gettempdir(), "idegym", "prometheus"),
|
|
155
|
+
)
|
|
156
|
+
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
|
|
157
|
+
sqlalchemy: SQLAlchemyConfig = Field(default_factory=SQLAlchemyConfig)
|
|
158
|
+
asyncio: AsyncioConfig = Field(default_factory=AsyncioConfig)
|
|
159
|
+
resources: ResourcesConfig = Field(default_factory=ResourcesConfig)
|
|
160
|
+
watcher: WatcherConfig = Field(default_factory=WatcherConfig)
|
|
161
|
+
client_request_timeout: float = Field(
|
|
162
|
+
description="Client request read timeout in seconds",
|
|
163
|
+
default=60.0 * 60, # 1 hour
|
|
164
|
+
)
|
|
165
|
+
connection_limits: ConnectionLimitsConfig = Field(default_factory=ConnectionLimitsConfig)
|
|
166
|
+
enable_fifo_server_reuse: bool = Field(
|
|
167
|
+
description="Enable FIFO queue for server reuse to ensure fair provisioning",
|
|
168
|
+
default=False,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
@field_validator("prometheus_multiproc_dir")
|
|
172
|
+
def validate_prometheus_multiproc_dir(cls, value: str) -> str:
|
|
173
|
+
path = value.strip() if value else None
|
|
174
|
+
if not path:
|
|
175
|
+
field = cls.__pydantic_fields__["prometheus_multiproc_dir"]
|
|
176
|
+
return field.default
|
|
177
|
+
else:
|
|
178
|
+
return abspath(path)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class Config(BaseModel):
|
|
182
|
+
server: ServerConfig = Field(default_factory=ServerConfig)
|
|
183
|
+
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
|
184
|
+
project: ProjectConfig = Field(default_factory=ProjectConfig)
|
|
185
|
+
otel: OTELConfig = Field(default_factory=OTELConfig)
|
|
186
|
+
orchestrator: OrchestratorConfig = Field(default_factory=OrchestratorConfig)
|
idegym/api/ctx.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from contextvars import ContextVar
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from structlog.contextvars import STRUCTLOG_KEY_PREFIX
|
|
5
|
+
|
|
6
|
+
task_id_context_var: ContextVar[Optional[str]] = ContextVar(STRUCTLOG_KEY_PREFIX + "task_id", default=None)
|
|
7
|
+
task_name_context_var: ContextVar[Optional[str]] = ContextVar(STRUCTLOG_KEY_PREFIX + "task_name", default=None)
|
idegym/api/data.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from enum import IntEnum, auto
|
|
2
|
+
from functools import total_ordering
|
|
3
|
+
from re import Pattern, compile
|
|
4
|
+
from typing import Any, ClassVar, Union
|
|
5
|
+
|
|
6
|
+
from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
|
|
7
|
+
from pydantic.json_schema import JsonSchemaValue
|
|
8
|
+
from pydantic_core import core_schema
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BinaryScaleEnum(IntEnum):
|
|
12
|
+
@staticmethod
|
|
13
|
+
def _generate_next_value_(name: str, start: int, count: int, last_values: list[Any]) -> int:
|
|
14
|
+
return 2 ** (count * 10)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DataUnit(BinaryScaleEnum):
|
|
18
|
+
B = auto()
|
|
19
|
+
KB = auto()
|
|
20
|
+
MB = auto()
|
|
21
|
+
GB = auto()
|
|
22
|
+
TB = auto()
|
|
23
|
+
PB = auto()
|
|
24
|
+
EB = auto()
|
|
25
|
+
ZB = auto()
|
|
26
|
+
YB = auto()
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def bytes(self) -> int:
|
|
30
|
+
return self.value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@total_ordering
|
|
34
|
+
class DataSize:
|
|
35
|
+
PATTERN: ClassVar[Pattern] = compile(r"^([+\-]?\d+)(B|[EGKMPTYZ]B)?$")
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
b: int = 0,
|
|
40
|
+
kb: int = 0,
|
|
41
|
+
mb: int = 0,
|
|
42
|
+
gb: int = 0,
|
|
43
|
+
tb: int = 0,
|
|
44
|
+
pb: int = 0,
|
|
45
|
+
eb: int = 0,
|
|
46
|
+
zb: int = 0,
|
|
47
|
+
yb: int = 0,
|
|
48
|
+
):
|
|
49
|
+
args = {
|
|
50
|
+
DataUnit.KB: kb,
|
|
51
|
+
DataUnit.MB: mb,
|
|
52
|
+
DataUnit.GB: gb,
|
|
53
|
+
DataUnit.TB: tb,
|
|
54
|
+
DataUnit.PB: pb,
|
|
55
|
+
DataUnit.EB: eb,
|
|
56
|
+
DataUnit.ZB: zb,
|
|
57
|
+
DataUnit.YB: yb,
|
|
58
|
+
}
|
|
59
|
+
self._bytes = b + sum(size.bytes * amount for size, amount in args.items() if amount)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def parse(cls, value: str, unit: DataUnit = DataUnit.B) -> "DataSize":
|
|
63
|
+
try:
|
|
64
|
+
normalized = value.replace(" ", "").upper()
|
|
65
|
+
matcher = cls.PATTERN.match(normalized)
|
|
66
|
+
if not matcher:
|
|
67
|
+
raise ValueError(f"'{value}' does not match data size pattern")
|
|
68
|
+
amount, suffix = matcher.groups()
|
|
69
|
+
unit: DataUnit = DataUnit[suffix] if suffix else unit
|
|
70
|
+
return cls(b=unit.bytes * int(amount))
|
|
71
|
+
except Exception as ex:
|
|
72
|
+
raise ValueError(f"'{value}' is not a valid data size") from ex
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def negative(self) -> bool:
|
|
76
|
+
return self.bytes < 0
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def bytes(self) -> int:
|
|
80
|
+
return self._bytes
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def kilobytes(self) -> int:
|
|
84
|
+
return self.bytes // DataUnit.KB.bytes
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def megabytes(self) -> int:
|
|
88
|
+
return self.bytes // DataUnit.MB.bytes
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def gigabytes(self) -> int:
|
|
92
|
+
return self.bytes // DataUnit.GB.bytes
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def terabytes(self) -> int:
|
|
96
|
+
return self.bytes // DataUnit.TB.bytes
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def petabytes(self) -> int:
|
|
100
|
+
return self.bytes // DataUnit.PB.bytes
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def exabytes(self) -> int:
|
|
104
|
+
return self.bytes // DataUnit.EB.bytes
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def zettabytes(self) -> int:
|
|
108
|
+
return self.bytes // DataUnit.ZB.bytes
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def yottabytes(self) -> int:
|
|
112
|
+
return self.bytes // DataUnit.YB.bytes
|
|
113
|
+
|
|
114
|
+
def __eq__(self, other: Union["DataSize", int, str, None]) -> bool:
|
|
115
|
+
if self is other:
|
|
116
|
+
return True
|
|
117
|
+
match other:
|
|
118
|
+
case DataSize():
|
|
119
|
+
return self.bytes == other.bytes
|
|
120
|
+
case int():
|
|
121
|
+
parsed = DataSize(b=other)
|
|
122
|
+
return self.bytes == parsed.bytes
|
|
123
|
+
case str():
|
|
124
|
+
parsed = DataSize.parse(value=other)
|
|
125
|
+
return self.bytes == parsed.bytes
|
|
126
|
+
case _:
|
|
127
|
+
return NotImplemented
|
|
128
|
+
|
|
129
|
+
def __str__(self) -> str:
|
|
130
|
+
return f"{self.bytes}B"
|
|
131
|
+
|
|
132
|
+
def __repr__(self) -> str:
|
|
133
|
+
return f"{self.__class__.__name__}(bytes={self.bytes})"
|
|
134
|
+
|
|
135
|
+
def __hash__(self) -> int:
|
|
136
|
+
return hash(self.bytes)
|
|
137
|
+
|
|
138
|
+
def __lt__(self, other: Union["DataSize", int, str]) -> bool:
|
|
139
|
+
match other:
|
|
140
|
+
case DataSize():
|
|
141
|
+
return self.bytes < other.bytes
|
|
142
|
+
case int():
|
|
143
|
+
parsed = DataSize(b=other)
|
|
144
|
+
return self.bytes < parsed.bytes
|
|
145
|
+
case str():
|
|
146
|
+
parsed = DataSize.parse(value=other)
|
|
147
|
+
return self.bytes < parsed.bytes
|
|
148
|
+
case _:
|
|
149
|
+
return NotImplemented
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def __get_validators__(cls):
|
|
153
|
+
yield cls.validate
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def validate(cls, value: Union["DataSize", int, str]) -> "DataSize":
|
|
157
|
+
match value:
|
|
158
|
+
case cls():
|
|
159
|
+
return value
|
|
160
|
+
case int():
|
|
161
|
+
return cls(b=value)
|
|
162
|
+
case str():
|
|
163
|
+
return cls.parse(value=value)
|
|
164
|
+
case _:
|
|
165
|
+
raise ValueError(f"Cannot convert {value} to {cls.__name__}")
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def __get_pydantic_core_schema__(
|
|
169
|
+
cls,
|
|
170
|
+
_source_type: Any,
|
|
171
|
+
_handler: GetCoreSchemaHandler,
|
|
172
|
+
) -> core_schema.CoreSchema:
|
|
173
|
+
return core_schema.union_schema(
|
|
174
|
+
[
|
|
175
|
+
core_schema.is_instance_schema(cls),
|
|
176
|
+
core_schema.chain_schema(
|
|
177
|
+
[
|
|
178
|
+
core_schema.int_schema(),
|
|
179
|
+
core_schema.no_info_plain_validator_function(cls.validate),
|
|
180
|
+
]
|
|
181
|
+
),
|
|
182
|
+
core_schema.chain_schema(
|
|
183
|
+
[
|
|
184
|
+
core_schema.str_schema(),
|
|
185
|
+
core_schema.no_info_plain_validator_function(cls.parse),
|
|
186
|
+
]
|
|
187
|
+
),
|
|
188
|
+
]
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def __get_pydantic_json_schema__(
|
|
193
|
+
cls,
|
|
194
|
+
_core_schema: core_schema.CoreSchema,
|
|
195
|
+
_handler: GetJsonSchemaHandler,
|
|
196
|
+
) -> JsonSchemaValue:
|
|
197
|
+
return {
|
|
198
|
+
"type": "string",
|
|
199
|
+
"description": "Data size value (e.g. '5MB', '1GB')",
|
|
200
|
+
"examples": ["512B", "10KB", "5MB", "1GB", "2TB"],
|
|
201
|
+
}
|
idegym/api/docker.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from idegym.api.data import DataSize
|
|
5
|
+
from pydantic import BaseModel, Field, model_serializer, model_validator
|
|
6
|
+
from pydantic_core.core_schema import SerializerFunctionWrapHandler
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseImage(StrEnum):
|
|
10
|
+
DEBIAN = "debian-bookworm-20250520-slim"
|
|
11
|
+
UBUNTU = "ubuntu-jammy-20250530"
|
|
12
|
+
|
|
13
|
+
DEFAULT = DEBIAN
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ContainerConfig(BaseModel):
|
|
17
|
+
cpu_period: Optional[int] = Field(
|
|
18
|
+
description="Length of a CPU period in microseconds",
|
|
19
|
+
default=None,
|
|
20
|
+
gt=0,
|
|
21
|
+
)
|
|
22
|
+
cpu_quota: Optional[int] = Field(
|
|
23
|
+
description="Microseconds of CPU time that the container can get in a CPU period",
|
|
24
|
+
default=None,
|
|
25
|
+
gt=0,
|
|
26
|
+
)
|
|
27
|
+
cpu_rt_period: Optional[int] = Field(
|
|
28
|
+
description="Limit CPU real-time period in microseconds",
|
|
29
|
+
default=None,
|
|
30
|
+
gt=0,
|
|
31
|
+
)
|
|
32
|
+
cpu_rt_runtime: Optional[int] = Field(
|
|
33
|
+
description="Limit CPU real-time runtime in microseconds",
|
|
34
|
+
default=None,
|
|
35
|
+
gt=0,
|
|
36
|
+
)
|
|
37
|
+
cpu_shares: Optional[int] = Field(
|
|
38
|
+
description="CPU shares (relative weight)",
|
|
39
|
+
default=None,
|
|
40
|
+
gt=0,
|
|
41
|
+
)
|
|
42
|
+
memory: Optional[DataSize] = Field(
|
|
43
|
+
description="Memory limit",
|
|
44
|
+
ge="6MB",
|
|
45
|
+
default=None,
|
|
46
|
+
)
|
|
47
|
+
memory_reservation: Optional[DataSize] = Field(
|
|
48
|
+
description="Memory soft limit",
|
|
49
|
+
ge="6MB",
|
|
50
|
+
default=None,
|
|
51
|
+
)
|
|
52
|
+
memory_swappiness: Optional[int] = Field(
|
|
53
|
+
description="Tune a container's memory swappiness behavior",
|
|
54
|
+
default=None,
|
|
55
|
+
ge=0,
|
|
56
|
+
le=100,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@model_serializer(mode="wrap")
|
|
60
|
+
def serialize_model(self, nxt: SerializerFunctionWrapHandler) -> dict[str, Any]:
|
|
61
|
+
return {
|
|
62
|
+
key: (str(value) if key in ("memory", "memory_reservation") and value is not None else value)
|
|
63
|
+
for key, value in nxt(self).items()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@model_validator(mode="after")
|
|
67
|
+
def validate_cpu_constraints(self):
|
|
68
|
+
if self.cpu_rt_runtime is not None and self.cpu_rt_period is not None:
|
|
69
|
+
if self.cpu_rt_runtime > self.cpu_rt_period:
|
|
70
|
+
raise ValueError("'cpu_rt_runtime' must be less than or equal to 'cpu_rt_period'")
|
|
71
|
+
return self
|
idegym/api/download.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from idegym.api.type import AuthType, HttpUrl
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Authorization(BaseModel):
|
|
8
|
+
"""Authorization credentials. Both fields are excluded from serialization."""
|
|
9
|
+
|
|
10
|
+
type: Optional[AuthType] = Field(
|
|
11
|
+
default=None,
|
|
12
|
+
exclude=True,
|
|
13
|
+
repr=False,
|
|
14
|
+
)
|
|
15
|
+
token: Optional[str] = Field(
|
|
16
|
+
default=None,
|
|
17
|
+
exclude=True,
|
|
18
|
+
repr=False,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
model_config = ConfigDict(frozen=True)
|
|
22
|
+
|
|
23
|
+
@model_validator(mode="after")
|
|
24
|
+
def validate_auth(self):
|
|
25
|
+
if self.type is not None and self.token is None:
|
|
26
|
+
raise ValueError("Authorization token is required when type is specified")
|
|
27
|
+
return self
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ArchiveDescriptor(BaseModel):
|
|
31
|
+
name: str = Field(pattern=r"^.+\.(?:zip|tar\.gz)$")
|
|
32
|
+
url: HttpUrl
|
|
33
|
+
|
|
34
|
+
model_config = ConfigDict(frozen=True)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DownloadRequest(BaseModel):
|
|
38
|
+
descriptor: ArchiveDescriptor
|
|
39
|
+
auth: Authorization = Field(default_factory=Authorization)
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(frozen=True)
|
idegym/api/exceptions.py
ADDED
idegym/api/git.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
from idegym.api.download import ArchiveDescriptor
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GitServer(StrEnum):
|
|
8
|
+
GITHUB = "github.com"
|
|
9
|
+
GITLAB = "gitlab.com"
|
|
10
|
+
HUGGING_FACE_DATASETS = "huggingface.co/datasets"
|
|
11
|
+
HUGGING_FACE_MODELS = "huggingface.co"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GitRepository(BaseModel):
|
|
15
|
+
server: GitServer
|
|
16
|
+
owner: str = Field(min_length=1)
|
|
17
|
+
name: str = Field(min_length=1)
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(frozen=True)
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def qualified_name(self) -> str:
|
|
23
|
+
return f"{self.owner}/{self.name}"
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def url(self) -> str:
|
|
27
|
+
return f"https://{self.server}/{self.qualified_name}.git"
|
|
28
|
+
|
|
29
|
+
def head(self) -> "GitRepositorySnapshot":
|
|
30
|
+
return self.at("HEAD")
|
|
31
|
+
|
|
32
|
+
def at(self, reference: str) -> "GitRepositorySnapshot":
|
|
33
|
+
return GitRepositorySnapshot(repository=self, reference=reference)
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def parse(cls, url: str) -> "GitRepository":
|
|
37
|
+
if not url.startswith("https://"):
|
|
38
|
+
raise ValueError("URL must start with 'https://'")
|
|
39
|
+
value = url.removeprefix("https://").removesuffix(".git")
|
|
40
|
+
for server in GitServer:
|
|
41
|
+
if value.startswith(server):
|
|
42
|
+
path = value.removeprefix(server)
|
|
43
|
+
_, owner, name, *other = path.split("/")
|
|
44
|
+
if len(other) != 0:
|
|
45
|
+
raise ValueError(f"URL must be in the format 'https://{server}/<owner>/<name>.git', got '{url}'")
|
|
46
|
+
return cls(owner=owner, name=name, server=server)
|
|
47
|
+
raise ValueError(f"Invalid URL: '{url}'")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GitRepositorySnapshot(BaseModel):
|
|
51
|
+
repository: GitRepository
|
|
52
|
+
reference: str = Field(
|
|
53
|
+
description="Branch, tag, commit hash, or HEAD",
|
|
54
|
+
pattern=r"^(HEAD|([\w\-.]+)(/[\w\-.]+)?|refs/(heads|tags|remotes)/[\w\-./]+)$",
|
|
55
|
+
default="HEAD",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
model_config = ConfigDict(frozen=True)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def filename(self) -> str:
|
|
62
|
+
return f"{self.repository.name}-{self.reference}.tar.gz"
|
|
63
|
+
|
|
64
|
+
def descriptor(self) -> ArchiveDescriptor:
|
|
65
|
+
url = self.repository.url.removesuffix(".git")
|
|
66
|
+
match self.repository.server:
|
|
67
|
+
case GitServer.GITHUB:
|
|
68
|
+
url += f"/archive/{self.reference}.tar.gz"
|
|
69
|
+
case GitServer.GITLAB:
|
|
70
|
+
url += f"/-/archive/{self.reference}/{self.repository.name}-{self.reference}.tar.gz"
|
|
71
|
+
case _:
|
|
72
|
+
# Downloading entire repositories from HuggingFace is not supported!
|
|
73
|
+
raise NotImplementedError(f"Unsupported server: '{self.repository.server}'")
|
|
74
|
+
return ArchiveDescriptor(name=self.filename, url=url)
|
|
75
|
+
|
|
76
|
+
def resource(self, path: str) -> "GitRepositoryResource":
|
|
77
|
+
return GitRepositoryResource(snapshot=self, path=path)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class GitRepositoryResource(BaseModel):
|
|
81
|
+
snapshot: GitRepositorySnapshot
|
|
82
|
+
path: str = Field(description="Relative path within the repository")
|
|
83
|
+
|
|
84
|
+
model_config = ConfigDict(frozen=True)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def filename(self) -> str:
|
|
88
|
+
*_, filename = self.path.split("/")
|
|
89
|
+
return filename
|
|
90
|
+
|
|
91
|
+
def descriptor(self) -> ArchiveDescriptor:
|
|
92
|
+
url = self.snapshot.repository.url.removesuffix(".git")
|
|
93
|
+
match self.snapshot.repository.server:
|
|
94
|
+
case GitServer.GITHUB:
|
|
95
|
+
url = url.replace(GitServer.GITHUB, "raw.githubusercontent.com")
|
|
96
|
+
url += f"/{self.snapshot.reference}/{self.path}"
|
|
97
|
+
case GitServer.GITLAB:
|
|
98
|
+
url += f"/-/raw/{self.snapshot.reference}/{self.path}"
|
|
99
|
+
case GitServer.HUGGING_FACE_DATASETS | GitServer.HUGGING_FACE_MODELS:
|
|
100
|
+
url += f"/resolve/{self.snapshot.reference}/{self.path}"
|
|
101
|
+
case _:
|
|
102
|
+
raise NotImplementedError(f"Unsupported server: '{self.snapshot.repository.server}'")
|
|
103
|
+
return ArchiveDescriptor(name=self.filename, url=url)
|
idegym/api/health.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HealthCheckResponse(BaseModel):
|
|
7
|
+
status: str = Field(description="Service health status, e.g. 'healthy'")
|
|
8
|
+
message: Optional[str] = Field(default=None, description="Optional message with details")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from json import dumps as dump_json
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from idegym.api.download import DownloadRequest
|
|
5
|
+
from idegym.utils.hashing import md5
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ImageBuildSpec(BaseModel):
|
|
10
|
+
name: Optional[str] = None
|
|
11
|
+
request: Optional[DownloadRequest] = Field(default=None, description="Optional project download request")
|
|
12
|
+
dockerfile_content: str = Field(description="Fully rendered Dockerfile content", min_length=1)
|
|
13
|
+
labels: dict[str, str] = Field(default_factory=dict, description="Image labels")
|
|
14
|
+
context_path: str = Field(default=".", description="Docker build context path")
|
|
15
|
+
platforms: list[str] = Field(default_factory=list, description="Build target platforms")
|
|
16
|
+
runtime_class_name: str = Field(default="gvisor", description="Kubernetes runtime class name")
|
|
17
|
+
resources: Optional[dict[str, Any]] = Field(default=None, description="Build resources")
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(extra="forbid")
|
|
20
|
+
|
|
21
|
+
def image_version(self) -> str:
|
|
22
|
+
identifiers = []
|
|
23
|
+
if self.request is not None:
|
|
24
|
+
identifiers.append(dump_json(self.request.descriptor.model_dump(mode="json"), sort_keys=True))
|
|
25
|
+
identifiers.append(dump_json(self.labels, sort_keys=True))
|
|
26
|
+
identifiers.append(self.context_path)
|
|
27
|
+
identifiers.append(self.dockerfile_content)
|
|
28
|
+
return md5(*identifiers)
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from idegym.api.orchestrator.jobs import JobPollResult
|
|
2
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
3
|
+
from yaml import YAMLError
|
|
4
|
+
from yaml import safe_load as parse_yaml
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BuildFromYamlRequest(BaseModel):
|
|
8
|
+
namespace: str = Field(default="idegym")
|
|
9
|
+
yaml_content: str = Field(description="YAML content containing image build job definitions")
|
|
10
|
+
|
|
11
|
+
@field_validator("yaml_content")
|
|
12
|
+
def validate_yaml_content(cls, value: str) -> str:
|
|
13
|
+
try:
|
|
14
|
+
parse_yaml(value)
|
|
15
|
+
return value
|
|
16
|
+
except YAMLError as ex:
|
|
17
|
+
raise ValueError("Yaml content is not valid") from ex
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BuildFromYamlResponse(BaseModel):
|
|
21
|
+
job_names: list[str] = Field(description="Names of started Kubernetes build jobs")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BuildJobsSummary(BaseModel):
|
|
25
|
+
total_jobs: int = Field(ge=0)
|
|
26
|
+
failed_jobs: int = Field(ge=0)
|
|
27
|
+
total_time: str = Field(description="Total elapsed time, e.g. '12.34s'")
|
|
28
|
+
jobs_results: list[JobPollResult]
|
|
29
|
+
|
|
30
|
+
@model_validator(mode="after")
|
|
31
|
+
def validate_jobs_results(self):
|
|
32
|
+
if not len(self.jobs_results) == self.total_jobs:
|
|
33
|
+
raise ValueError("Number of jobs results does not match total jobs")
|
|
34
|
+
return self
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AvailabilityStatus(StrEnum):
|
|
9
|
+
ALIVE = "ALIVE"
|
|
10
|
+
FINISHED = "FINISHED"
|
|
11
|
+
REUSED = "REUSED"
|
|
12
|
+
FAILED_TO_START = "FAILED_TO_START"
|
|
13
|
+
STOPPED = "STOPPED"
|
|
14
|
+
KILLED = "KILLED"
|
|
15
|
+
CRASHED = "CRASHED"
|
|
16
|
+
DELETION_FAILED = "DELETION_FAILED"
|
|
17
|
+
RESTART_FAILED = "RESTART_FAILED"
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def is_terminal(self) -> bool:
|
|
21
|
+
"""Returns True if this status represents a terminal state."""
|
|
22
|
+
return self in {
|
|
23
|
+
AvailabilityStatus.FAILED_TO_START,
|
|
24
|
+
AvailabilityStatus.KILLED,
|
|
25
|
+
AvailabilityStatus.STOPPED,
|
|
26
|
+
AvailabilityStatus.CRASHED,
|
|
27
|
+
AvailabilityStatus.DELETION_FAILED,
|
|
28
|
+
AvailabilityStatus.RESTART_FAILED,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RegisterClientRequest(BaseModel):
|
|
33
|
+
name: str = Field(description="Human-readable name for the client")
|
|
34
|
+
nodes_count: int = Field(default=0, ge=0, description="Number of nodes to spin up for the client")
|
|
35
|
+
namespace: str = Field(default="idegym")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SendClientHeartbeatRequest(BaseModel):
|
|
39
|
+
client_id: UUID
|
|
40
|
+
availability: AvailabilityStatus = Field(default=AvailabilityStatus.ALIVE)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class StopClientRequest(BaseModel):
|
|
44
|
+
client_id: UUID
|
|
45
|
+
namespace: str = Field(default="idegym")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class StopClientResponse(BaseModel):
|
|
49
|
+
operation_id: Optional[int] = Field(default=None, description="Async operation ID to poll for client stop status")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FinishClientRequest(BaseModel):
|
|
53
|
+
client_id: UUID
|
|
54
|
+
namespace: str = Field(default="idegym")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RegisteredClientResponse(BaseModel):
|
|
58
|
+
id: UUID
|
|
59
|
+
name: str = Field(default=None)
|
|
60
|
+
nodes_count: int = Field(ge=0)
|
|
61
|
+
namespace: str
|
|
62
|
+
last_heartbeat_time: int = Field(description="Epoch milliseconds")
|
|
63
|
+
availability: str
|
|
64
|
+
created_at: int = Field(description="Epoch milliseconds")
|
|
65
|
+
operation_id: Optional[int] = Field(
|
|
66
|
+
default=None, description="Async operation ID to poll for client registration status"
|
|
67
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class JobStatusResponse(BaseModel):
|
|
7
|
+
id: int = Field(default=0)
|
|
8
|
+
job_name: str
|
|
9
|
+
status: str
|
|
10
|
+
created_at: int = Field(default=0, description="Epoch milliseconds")
|
|
11
|
+
updated_at: int = Field(default=0, description="Epoch milliseconds")
|
|
12
|
+
details: Optional[str] = Field(default=None)
|
|
13
|
+
tag: Optional[str] = Field(default=None)
|
|
14
|
+
request_id: Optional[str] = Field(default=None)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JobPollResult(BaseModel):
|
|
18
|
+
job_name: str
|
|
19
|
+
status: str
|
|
20
|
+
tag: Optional[str] = Field(default=None)
|
|
21
|
+
details: Optional[str] = Field(default=None)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AsyncOperationType(StrEnum):
|
|
9
|
+
REGISTER_CLIENT_WITH_NODES = "REGISTER_CLIENT_WITH_NODES"
|
|
10
|
+
START_SERVER = "START_SERVER"
|
|
11
|
+
RESTART_SERVER = "RESTART_SERVER"
|
|
12
|
+
STOP_SERVER = "STOP_SERVER"
|
|
13
|
+
STOP_CLIENT = "STOP_CLIENT"
|
|
14
|
+
FORWARD_REQUEST = "FORWARD_REQUEST"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AsyncOperationStatus(StrEnum):
|
|
18
|
+
SCHEDULED = "SCHEDULED"
|
|
19
|
+
IN_PROGRESS = "IN_PROGRESS"
|
|
20
|
+
FAILED = "FAILED"
|
|
21
|
+
SUCCEEDED = "SUCCEEDED"
|
|
22
|
+
CANCELLED = "CANCELLED"
|
|
23
|
+
FINISHED_BY_WATCHER = "FINISHED_BY_WATCHER"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AsyncOperationStatusResponse(BaseModel):
|
|
27
|
+
id: int
|
|
28
|
+
request_type: str
|
|
29
|
+
status: str
|
|
30
|
+
|
|
31
|
+
request: Optional[str] = Field(default=None, description="Original request payload (JSON)")
|
|
32
|
+
result: Optional[str] = Field(default=None, description="Operation result payload (JSON or string)")
|
|
33
|
+
|
|
34
|
+
client_id: Optional[UUID] = Field(default=None)
|
|
35
|
+
server_id: Optional[int] = Field(default=None)
|
|
36
|
+
|
|
37
|
+
orchestrator_pod: Optional[str] = Field(default=None)
|
|
38
|
+
|
|
39
|
+
scheduled_at: int = Field(description="Epoch milliseconds")
|
|
40
|
+
started_at: Optional[int] = Field(default=None, description="Epoch milliseconds")
|
|
41
|
+
finished_at: Optional[int] = Field(default=None, description="Epoch milliseconds")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ForwardRequestPayload(BaseModel):
|
|
45
|
+
method: str
|
|
46
|
+
path: str
|
|
47
|
+
headers: dict[str, str] = Field(default_factory=dict, description="Sanitized request headers")
|
|
48
|
+
body: Optional[str] = Field(default=None, description="Request body as text, if present")
|
|
49
|
+
target_url: str = Field(description="Resolved target URL to forward the request to")
|
|
50
|
+
server_id: int
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ForwardRequestResponse(BaseModel):
|
|
54
|
+
async_operation_id: Optional[int] = Field(default=None, description="Async operation ID to poll for request status")
|
|
55
|
+
|
|
56
|
+
status_code: Optional[int] = Field(default=None)
|
|
57
|
+
headers: Optional[dict[str, str]] = Field(default_factory=dict, description="Sanitized response headers")
|
|
58
|
+
body: Optional[str] = Field(default=None)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from idegym.api.type import KubernetesNodeSelector, KubernetesObjectName, OCIImageName
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ServerReuseStrategy(StrEnum):
|
|
10
|
+
NONE = "NONE"
|
|
11
|
+
RESTART = "RESTART"
|
|
12
|
+
RESET = "RESET"
|
|
13
|
+
CHECKPOINT = "CHECKPOINT"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ServerKind(StrEnum):
|
|
17
|
+
IDEGYM = "idegym"
|
|
18
|
+
OPENENV = "openenv"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StartServerRequest(BaseModel):
|
|
22
|
+
client_id: UUID
|
|
23
|
+
namespace: str = Field(default="idegym")
|
|
24
|
+
image_tag: OCIImageName
|
|
25
|
+
server_name: KubernetesObjectName = Field(default="default-idegym-server")
|
|
26
|
+
runtime_class_name: Optional[str] = Field(default=None)
|
|
27
|
+
run_as_root: bool = Field(default=False)
|
|
28
|
+
service_port: int = Field(default=80, ge=0, le=65535)
|
|
29
|
+
container_port: int = Field(default=8000, ge=0, le=65535)
|
|
30
|
+
resources: Optional[dict[str, Any]] = Field(default=None, description="Kubernetes resource requirements")
|
|
31
|
+
node_selector: Optional[KubernetesNodeSelector] = Field(default=None)
|
|
32
|
+
server_start_wait_timeout_in_seconds: int = Field(
|
|
33
|
+
default=60, description="Seconds to wait for server readiness", ge=0
|
|
34
|
+
)
|
|
35
|
+
reuse_strategy: ServerReuseStrategy = Field(
|
|
36
|
+
default=ServerReuseStrategy.RESET,
|
|
37
|
+
description="What to do with an existing matching server instead of starting a new one",
|
|
38
|
+
)
|
|
39
|
+
server_kind: ServerKind = Field(default=ServerKind.IDEGYM, description="Server type: idegym or openenv")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class StopServerRequest(BaseModel):
|
|
43
|
+
client_id: UUID
|
|
44
|
+
namespace: str = Field(default="idegym")
|
|
45
|
+
server_id: int
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FinishServerRequest(BaseModel):
|
|
49
|
+
client_id: UUID
|
|
50
|
+
namespace: str = Field(default="idegym")
|
|
51
|
+
server_id: int
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RestartServerRequest(BaseModel):
|
|
55
|
+
client_id: UUID
|
|
56
|
+
namespace: str = Field(default="idegym")
|
|
57
|
+
server_id: int
|
|
58
|
+
server_start_wait_timeout_in_seconds: int = Field(
|
|
59
|
+
default=60, description="Seconds to wait for server readiness after restart", ge=0
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class StartServerResponse(BaseModel):
|
|
64
|
+
namespace: str
|
|
65
|
+
client_id: UUID
|
|
66
|
+
operation_id: Optional[int] = Field(default=None, description="Async operation ID to poll for server start status")
|
|
67
|
+
server_id: Optional[int] = Field(default=None)
|
|
68
|
+
server_name: Optional[str] = Field(default=None, description="Logical server name as provided in the request")
|
|
69
|
+
generated_name: Optional[str] = Field(default=None, description="Generated Kubernetes resource name")
|
|
70
|
+
service_name: Optional[str] = Field(default=None, description="Kubernetes Service name for the server")
|
|
71
|
+
image_tag: Optional[str] = Field(default=None)
|
|
72
|
+
need_to_reset: bool = Field(default=False, description="True if the reused server requires a project reset")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ErrorResponse(BaseModel):
|
|
76
|
+
status_code: Optional[int] = Field(default=None)
|
|
77
|
+
headers: Optional[dict[str, str]] = Field(default_factory=dict, description="Sanitized response headers")
|
|
78
|
+
body: Optional[str] = Field(default=None)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ServerActionResponse(BaseModel):
|
|
82
|
+
server_name: str
|
|
83
|
+
message: str
|
|
84
|
+
operation_id: Optional[int] = Field(default=None, description="Async operation ID to poll for server action status")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ServerRequestResponse(BaseModel):
|
|
88
|
+
id: UUID
|
|
89
|
+
server_id: int
|
|
90
|
+
request: str = Field(description="Original request payload or summary")
|
|
91
|
+
path: Optional[str] = Field(default=None)
|
|
92
|
+
started_at: int = Field(description="Epoch milliseconds", ge=0)
|
|
93
|
+
result: Optional[str] = Field(default=None)
|
|
94
|
+
finished_at: Optional[int] = Field(default=None, description="Epoch milliseconds", ge=0)
|
|
95
|
+
status: str
|
idegym/api/paths.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
API_BASE_PATH = "/api"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class APIPath(StrEnum):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ActuatorPath(APIPath):
|
|
11
|
+
HEALTH = "/health"
|
|
12
|
+
LOG = "/log"
|
|
13
|
+
METRICS = "/metrics"
|
|
14
|
+
SHUTDOWN = "/shutdown"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OpenenvPath(APIPath):
|
|
18
|
+
"""Path constants for OpenEnv servers deployed alongside IdeGYM without the IdeGYM API."""
|
|
19
|
+
|
|
20
|
+
HEALTH = "/health"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FSPath(APIPath):
|
|
24
|
+
LIST_DIRECTORY = "/fs/ls"
|
|
25
|
+
READ_FILE = "/fs/cat"
|
|
26
|
+
CREATE_FILE = "/fs/touch"
|
|
27
|
+
CREATE_DIRECTORY = "/fs/mkdir"
|
|
28
|
+
DELETE_PATH = "/fs/rm"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ProjectPath(APIPath):
|
|
32
|
+
RESET = "/project/reset"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RewardsPath(APIPath):
|
|
36
|
+
SETUP = "/rewards/setup"
|
|
37
|
+
COMPILATION = "/rewards/compilation"
|
|
38
|
+
TEST = "/rewards/test"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ToolsPath(APIPath):
|
|
42
|
+
BASH = "/tools/bash"
|
|
43
|
+
CREATE_FILE = "/tools/file/create"
|
|
44
|
+
EDIT_FILE = "/tools/file/edit"
|
|
45
|
+
PATCH_FILE = "/tools/file/patch"
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from idegym.api.status import Status
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ResetRequest(BaseModel):
|
|
6
|
+
timeout: float = Field(default=600.0, description="Timeout for the reset operation in seconds")
|
|
7
|
+
graceful_termination_timeout: float = Field(
|
|
8
|
+
default=2.0, description="Timeout in seconds for graceful process termination"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ResetResult(BaseModel):
|
|
13
|
+
status: Status
|
|
14
|
+
output: str
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from idegym.api.status import Status
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CompilationRequest(BaseModel):
|
|
6
|
+
compilation_script: str = Field(default="./gradlew assemble")
|
|
7
|
+
timeout: float = Field(default=600.0, description="Timeout for the compilation in seconds")
|
|
8
|
+
graceful_termination_timeout: float = Field(
|
|
9
|
+
default=2.0, description="Timeout in seconds for graceful process termination"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CompilationResult(BaseModel):
|
|
14
|
+
status: Status
|
|
15
|
+
output: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CompilationError(BaseModel):
|
|
19
|
+
message: str
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from idegym.api.status import Status
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class InspectionRequest(BaseModel):
|
|
8
|
+
files_to_analyze: Optional[list[str]] = Field(default=None)
|
|
9
|
+
timeout: float = Field(default=600.0, description="Timeout for the analysis in seconds")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InspectionResult(BaseModel):
|
|
13
|
+
status: Status
|
|
14
|
+
output: str = Field(description="Analysis output, or error message if analysis failed", default="")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InspectionError(BaseModel):
|
|
18
|
+
message: str
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from idegym.api.status import Status
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SetupRequest(BaseModel):
|
|
6
|
+
setup_check_script: str = Field(default="true")
|
|
7
|
+
timeout: float = Field(default=600.0, description="Timeout for the setup in seconds")
|
|
8
|
+
graceful_termination_timeout: float = Field(
|
|
9
|
+
default=2.0, description="Timeout in seconds for graceful process termination"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SetupResult(BaseModel):
|
|
14
|
+
status: Status
|
|
15
|
+
output: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SetupError(BaseModel):
|
|
19
|
+
message: str
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from idegym.api.status import Status
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TestRequest(BaseModel):
|
|
6
|
+
test_script: str = Field(default="python -m pytest --junitxml=test-results.xml")
|
|
7
|
+
timeout: float = Field(default=600.0, description="Timeout for the test in seconds")
|
|
8
|
+
graceful_termination_timeout: float = Field(
|
|
9
|
+
default=2.0, description="Timeout in seconds for graceful process termination"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestScores(BaseModel):
|
|
14
|
+
total: int = Field(ge=0)
|
|
15
|
+
passed: int = Field(ge=0)
|
|
16
|
+
failed: int = Field(ge=0)
|
|
17
|
+
skipped: int = Field(ge=0)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestReport(BaseModel):
|
|
21
|
+
status: Status
|
|
22
|
+
scores: TestScores
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestError(BaseModel):
|
|
26
|
+
message: str
|
idegym/api/status.py
ADDED
|
File without changes
|
idegym/api/tools/bash.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BashCommandRequest(BaseModel):
|
|
5
|
+
command: str = Field(description="Command to execute as a bash script")
|
|
6
|
+
timeout: float = Field(default=600.0, description="Timeout for the command execution in seconds")
|
|
7
|
+
graceful_termination_timeout: float = Field(
|
|
8
|
+
default=2.0, description="Timeout in seconds for graceful process termination"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BashCommandResponse(BaseModel):
|
|
13
|
+
stdout: str
|
|
14
|
+
stderr: str
|
|
15
|
+
exit_code: int
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BashCommandErrorResponse(BaseModel):
|
|
19
|
+
message: str
|
idegym/api/tools/file.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from idegym.api.status import Status
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class FileRequest(BaseModel):
|
|
6
|
+
file_path: str
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CreateFileRequest(FileRequest):
|
|
10
|
+
content: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EditFileRequest(FileRequest):
|
|
14
|
+
start_line: int = Field(ge=0)
|
|
15
|
+
end_line: int = Field(ge=0)
|
|
16
|
+
new_content: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PatchFileRequest(FileRequest):
|
|
20
|
+
"""Applies a unified diff patch generated by `diff_patch.compute_diff`."""
|
|
21
|
+
|
|
22
|
+
patch: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FileResult(BaseModel):
|
|
26
|
+
status: Status
|
idegym/api/type.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
from typing import Annotated, Literal, Type, TypeAlias
|
|
3
|
+
|
|
4
|
+
from pydantic import AnyHttpUrl, BeforeValidator, IPvAnyAddress, StringConstraints, TypeAdapter
|
|
5
|
+
|
|
6
|
+
ipv_address_adapter = TypeAdapter(IPvAnyAddress)
|
|
7
|
+
http_url_adapter = TypeAdapter(AnyHttpUrl)
|
|
8
|
+
|
|
9
|
+
HttpUrl = Annotated[str, BeforeValidator(lambda value: str(http_url_adapter.validate_python(value)))]
|
|
10
|
+
IPvAddress = Annotated[str, BeforeValidator(lambda value: str(ipv_address_adapter.validate_python(value)))]
|
|
11
|
+
|
|
12
|
+
# https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#rfc-1035-label-names
|
|
13
|
+
KubernetesObjectName = Annotated[
|
|
14
|
+
str,
|
|
15
|
+
StringConstraints(
|
|
16
|
+
min_length=1,
|
|
17
|
+
max_length=63,
|
|
18
|
+
# language=regexp
|
|
19
|
+
pattern="^[a-z]([-a-z0-9]*[a-z0-9])?$",
|
|
20
|
+
),
|
|
21
|
+
]
|
|
22
|
+
# https://github.com/opencontainers/distribution-spec/blob/main/spec.md#workflow-categories
|
|
23
|
+
OCIImageName = Annotated[
|
|
24
|
+
str,
|
|
25
|
+
StringConstraints(
|
|
26
|
+
min_length=1,
|
|
27
|
+
max_length=383, # The maximum length is set to 255 + 128
|
|
28
|
+
# language=regexp
|
|
29
|
+
pattern="^[a-z0-9._/:@-]+$",
|
|
30
|
+
),
|
|
31
|
+
]
|
|
32
|
+
# https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set
|
|
33
|
+
KubernetesLabelKey = Annotated[
|
|
34
|
+
str,
|
|
35
|
+
StringConstraints(
|
|
36
|
+
min_length=1,
|
|
37
|
+
max_length=317, # 253 (prefix) + 1 (/) + 63 (name)
|
|
38
|
+
# language=regexp
|
|
39
|
+
pattern=r"^([a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?/)?[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$",
|
|
40
|
+
),
|
|
41
|
+
]
|
|
42
|
+
KubernetesLabelValue = Annotated[
|
|
43
|
+
str,
|
|
44
|
+
StringConstraints(
|
|
45
|
+
max_length=63,
|
|
46
|
+
# language=regexp
|
|
47
|
+
pattern=r"^([a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?)?$",
|
|
48
|
+
),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
KubernetesNodeSelector: TypeAlias = dict[KubernetesLabelKey, KubernetesLabelValue]
|
|
53
|
+
AuthType: Type[str] = Literal["Basic", "Bearer", "Token"]
|
|
54
|
+
Duration: TypeAlias = timedelta
|
|
55
|
+
LogLevel: TypeAlias = int
|
|
56
|
+
LogLevelName: Type[str] = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: idegym-api
|
|
3
|
+
Version: 0.8.0
|
|
4
|
+
Summary: IdeGYM API
|
|
5
|
+
Author-email: Daniil Mironov <daniil.mironov@jetbrains.com>, Ozren Dabić <ozren.dabic@jetbrains.com>, Vladimir Poliakov <vladimir.poliakov@jetbrains.com>, Vseslav Kasatskii <vseslav.kasatskii@jetbrains.com>
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: idegym-common-utils>=0.8.0
|
|
8
|
+
Requires-Dist: pydantic>=2.10.6
|
|
9
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
10
|
+
Requires-Dist: structlog>=25.4.0
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
idegym/api/__init__.py,sha256=jQ8BJhwmXkWE6I6fVaU6bcAWeoZEnDaSL7AE3mYOSns,227
|
|
2
|
+
idegym/api/auth.py,sha256=vCzMWcYlbpioEHOqS6Hu_Fwyude83n0BwKYaLBLVBYA,771
|
|
3
|
+
idegym/api/config.py,sha256=udYkygaGtqBb7J-yk5Ei7_JRa6OfFe0Ckb8iNx7gM2I,7403
|
|
4
|
+
idegym/api/ctx.py,sha256=3RTOSaZvaAunZe1UPF36KwmW22L9sba98TuA6ogeAe4,340
|
|
5
|
+
idegym/api/data.py,sha256=Dk7rBglat28fWRey_YDWcu6HS9xvoDDbPRe9i7DLSM4,5730
|
|
6
|
+
idegym/api/docker.py,sha256=aZgnnTbaK4uEnr1URK7YBi_pVcFWGdBAwOrp1Tb70_E,2221
|
|
7
|
+
idegym/api/download.py,sha256=sBVk569b16CO8Vs7vxpOSyqrVWJzHMgQGkEF833fnYY,1076
|
|
8
|
+
idegym/api/exceptions.py,sha256=7V4xt_4MtqtLFV43PTBib69kr-uXZGxgqPKfzqMIAv4,240
|
|
9
|
+
idegym/api/git.py,sha256=CDJ879GzImiZo5r5-hhJ7HfEPw7wz8E-Oe0JQ2bGICM,3865
|
|
10
|
+
idegym/api/health.py,sha256=jmhn8JPdVlTQDVoLLzElyL-Z254E-svDwRzzeF3nuJ0,278
|
|
11
|
+
idegym/api/image_build.py,sha256=Nch0zhWuSza9DURdi9flOuxGmu8y1q7_9tPHyEATmc0,1385
|
|
12
|
+
idegym/api/paths.py,sha256=8S232AexPiVLy_27HrMs6fWvvFzMTfloyf2KS2w5Sek,879
|
|
13
|
+
idegym/api/status.py,sha256=Hu3ZggQIqhh1TyvLt7o5wXBakvHCUupKpbD2Ihkz3IE,130
|
|
14
|
+
idegym/api/type.py,sha256=fI8E8vAI9p23j31L-sZMye2VN9QXLHe88ab6Ta8Tjos,1950
|
|
15
|
+
idegym/api/orchestrator/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
idegym/api/orchestrator/build.py,sha256=aBh_77A_Zst_PCSXwqVtt5ezjYVI8nGywsRnsXdMOOg,1219
|
|
17
|
+
idegym/api/orchestrator/clients.py,sha256=7-NTzuEeAk9kJlwgpVW-F7ZjlH0Y2RoaFTr28srEQAs,2043
|
|
18
|
+
idegym/api/orchestrator/jobs.py,sha256=UCrGuzSqt5NF8_QP2KA7Srs_PPz_Wx4SXFGEbIalVVU,624
|
|
19
|
+
idegym/api/orchestrator/operations.py,sha256=XH67IeV4_gjDJOy1O8edPzWWBSvEGKlDl3THgBWXVXA,2063
|
|
20
|
+
idegym/api/orchestrator/servers.py,sha256=8i_ke27p9JhXlvbqfjwhrm-KxzxGiBtuErc_SvQukF4,3603
|
|
21
|
+
idegym/api/project/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
|
+
idegym/api/project/reset.py,sha256=7XJakz35TCNTkPwSleED-wp7umh-Cwf9SgcldjtMe2A,417
|
|
23
|
+
idegym/api/rewards/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
24
|
+
idegym/api/rewards/compilation.py,sha256=pAchlZPQ-K3qv7xOXUs2DDCbTgi6HevWoD8JaGDshhw,545
|
|
25
|
+
idegym/api/rewards/inspection.py,sha256=YZUsjH-eaUF4k064Z9qsfl0LaY3jN4Z0lzvMXatN8so,512
|
|
26
|
+
idegym/api/rewards/setup.py,sha256=omz4mMvb2NmTN_mzcUKH7GQOS_m1AU2Hodx6C0os4EM,507
|
|
27
|
+
idegym/api/rewards/test.py,sha256=t8ztRcETGcnvoWdm1C2S1rwM9JjuTLxVZwxttFaVGsE,694
|
|
28
|
+
idegym/api/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
29
|
+
idegym/api/tools/bash.py,sha256=_Hjlljk1BwEwueQVPNvdmtyG-vpEtjK7URe86xuR2j8,550
|
|
30
|
+
idegym/api/tools/file.py,sha256=JkdktteXmnJE0TLnZyZjLSA-6LKP1eHAtOoj_2fi1Og,492
|
|
31
|
+
idegym_api-0.8.0.dist-info/METADATA,sha256=njU_6ixGDVfOWWia8lvK-u3cQ5lSfA0n0Y88vtoRB5c,442
|
|
32
|
+
idegym_api-0.8.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
33
|
+
idegym_api-0.8.0.dist-info/RECORD,,
|