idegym-api 0.9.0__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 (40) hide show
  1. idegym_api-0.9.0/.gitignore +294 -0
  2. idegym_api-0.9.0/PKG-INFO +11 -0
  3. idegym_api-0.9.0/pyproject.toml +19 -0
  4. idegym_api-0.9.0/src/idegym/api/__init__.py +9 -0
  5. idegym_api-0.9.0/src/idegym/api/auth.py +21 -0
  6. idegym_api-0.9.0/src/idegym/api/capabilities.py +5 -0
  7. idegym_api-0.9.0/src/idegym/api/config.py +203 -0
  8. idegym_api-0.9.0/src/idegym/api/cpu.py +144 -0
  9. idegym_api-0.9.0/src/idegym/api/ctx.py +7 -0
  10. idegym_api-0.9.0/src/idegym/api/docker.py +71 -0
  11. idegym_api-0.9.0/src/idegym/api/download.py +41 -0
  12. idegym_api-0.9.0/src/idegym/api/exceptions.py +10 -0
  13. idegym_api-0.9.0/src/idegym/api/git.py +103 -0
  14. idegym_api-0.9.0/src/idegym/api/health.py +8 -0
  15. idegym_api-0.9.0/src/idegym/api/image_build.py +29 -0
  16. idegym_api-0.9.0/src/idegym/api/inspect.py +39 -0
  17. idegym_api-0.9.0/src/idegym/api/memory.py +191 -0
  18. idegym_api-0.9.0/src/idegym/api/orchestrator/__init__.py +0 -0
  19. idegym_api-0.9.0/src/idegym/api/orchestrator/build.py +34 -0
  20. idegym_api-0.9.0/src/idegym/api/orchestrator/clients.py +70 -0
  21. idegym_api-0.9.0/src/idegym/api/orchestrator/jobs.py +21 -0
  22. idegym_api-0.9.0/src/idegym/api/orchestrator/mcp.py +16 -0
  23. idegym_api-0.9.0/src/idegym/api/orchestrator/operations.py +59 -0
  24. idegym_api-0.9.0/src/idegym/api/orchestrator/servers.py +149 -0
  25. idegym_api-0.9.0/src/idegym/api/orchestrator/snapshots.py +19 -0
  26. idegym_api-0.9.0/src/idegym/api/paths.py +46 -0
  27. idegym_api-0.9.0/src/idegym/api/plugin.py +231 -0
  28. idegym_api-0.9.0/src/idegym/api/project/__init__.py +0 -0
  29. idegym_api-0.9.0/src/idegym/api/project/reset.py +14 -0
  30. idegym_api-0.9.0/src/idegym/api/resources.py +54 -0
  31. idegym_api-0.9.0/src/idegym/api/rewards/__init__.py +0 -0
  32. idegym_api-0.9.0/src/idegym/api/rewards/compilation.py +19 -0
  33. idegym_api-0.9.0/src/idegym/api/rewards/inspection.py +18 -0
  34. idegym_api-0.9.0/src/idegym/api/rewards/setup.py +19 -0
  35. idegym_api-0.9.0/src/idegym/api/rewards/test.py +26 -0
  36. idegym_api-0.9.0/src/idegym/api/status.py +7 -0
  37. idegym_api-0.9.0/src/idegym/api/tools/__init__.py +0 -0
  38. idegym_api-0.9.0/src/idegym/api/tools/bash.py +19 -0
  39. idegym_api-0.9.0/src/idegym/api/tools/file.py +26 -0
  40. idegym_api-0.9.0/src/idegym/api/type.py +56 -0
@@ -0,0 +1,294 @@
1
+ ### Python template
2
+ # Byte-compiled / optimized / DLL files
3
+ __pycache__/
4
+ *.py[cod]
5
+ *$py.class
6
+
7
+ # C extensions
8
+ *.so
9
+
10
+ # Distribution / packaging
11
+ .Python
12
+ build/
13
+ develop-eggs/
14
+ dist/
15
+ downloads/
16
+ eggs/
17
+ .eggs/
18
+ lib/
19
+ lib64/
20
+ parts/
21
+ sdist/
22
+ var/
23
+ wheels/
24
+ share/python-wheels/
25
+ *.egg-info/
26
+ .installed.cfg
27
+ *.egg
28
+ MANIFEST
29
+
30
+ # PyInstaller
31
+ *.manifest
32
+ *.spec
33
+
34
+ # Installer logs
35
+ pip-log.txt
36
+ pip-delete-this-directory.txt
37
+
38
+ # Unit test / coverage reports
39
+ htmlcov/
40
+ .tox/
41
+ .nox/
42
+ .coverage
43
+ .coverage.*
44
+ .cache
45
+ nosetests.xml
46
+ coverage.xml
47
+ *.cover
48
+ *.py,cover
49
+ .hypothesis/
50
+ .pytest_cache/
51
+ cover/
52
+
53
+ # Translations
54
+ *.mo
55
+ *.pot
56
+
57
+ # Django stuff:
58
+ *.log
59
+ local_settings.py
60
+ db.sqlite3
61
+ db.sqlite3-journal
62
+
63
+ # Flask stuff:
64
+ instance/
65
+ .webassets-cache
66
+
67
+ # Scrapy stuff:
68
+ .scrapy
69
+
70
+ # Sphinx documentation
71
+ docs/_build/
72
+
73
+ # PyBuilder
74
+ .pybuilder/
75
+ target/
76
+
77
+ # Jupyter Notebook
78
+ .ipynb_checkpoints
79
+
80
+ # IPython
81
+ profile_default/
82
+ ipython_config.py
83
+
84
+ # pdm
85
+ .pdm.toml
86
+ .pdm-python
87
+ .pdm-build/
88
+
89
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
90
+ __pypackages__/
91
+
92
+ # Celery stuff
93
+ celerybeat-schedule
94
+ celerybeat.pid
95
+
96
+ # SageMath parsed files
97
+ *.sage.py
98
+
99
+ # Environments
100
+ .env
101
+ .venv
102
+ env/
103
+ venv/
104
+ ENV/
105
+ env.bak/
106
+ venv.bak/
107
+
108
+ # Spyder project settings
109
+ .spyderproject
110
+ .spyproject
111
+
112
+ # Rope project settings
113
+ .ropeproject
114
+
115
+ # mkdocs documentation
116
+ /site
117
+
118
+ # mypy
119
+ .mypy_cache/
120
+ .dmypy.json
121
+ dmypy.json
122
+
123
+ # Pyre type checker
124
+ .pyre/
125
+
126
+ # pytype static type analyzer
127
+ .pytype/
128
+
129
+ # Cython debug symbols
130
+ cython_debug/
131
+
132
+ ### PyCharm template
133
+ # User-specific stuff
134
+ .idea/**/workspace.xml
135
+ .idea/**/tasks.xml
136
+ .idea/**/usage.statistics.xml
137
+ .idea/**/dictionaries
138
+ .idea/**/shelf
139
+
140
+ # AWS User-specific
141
+ .idea/**/aws.xml
142
+
143
+ # Generated files
144
+ .idea/**/contentModel.xml
145
+
146
+ # Sensitive or high-churn files
147
+ .idea/**/dataSources/
148
+ .idea/**/dataSources.ids
149
+ .idea/**/dataSources.local.xml
150
+ .idea/**/dataSources.xml
151
+ .idea/**/sqlDataSources.xml
152
+ .idea/**/dynamic.xml
153
+ .idea/**/uiDesigner.xml
154
+ .idea/**/dbnavigator.xml
155
+
156
+ # Gradle
157
+ .idea/**/gradle.xml
158
+ .idea/**/libraries
159
+
160
+ # Module files
161
+ *.iml
162
+
163
+ # Project structure
164
+ .idea/**/misc.xml
165
+ .idea/**/modules.xml
166
+ .idea/pySourceRootDetection.xml
167
+ .idea/**/inspectionProfiles/
168
+ .idea/**/runConfigurations/
169
+
170
+ # CMake
171
+ cmake-build-*/
172
+
173
+ # Mongo Explorer plugin
174
+ .idea/**/mongoSettings.xml
175
+
176
+ # File-based project format
177
+ *.iws
178
+
179
+ # IntelliJ
180
+ out/
181
+
182
+ # mpeltonen/sbt-idea plugin
183
+ .idea_modules/
184
+
185
+ # JIRA plugin
186
+ atlassian-ide-plugin.xml
187
+
188
+ # Cursive Clojure plugin
189
+ .idea/replstate.xml
190
+
191
+ # SonarLint plugin
192
+ .idea/sonarlint/
193
+
194
+ # Crashlytics plugin (for Android Studio and IntelliJ)
195
+ com_crashlytics_export_strings.xml
196
+ crashlytics.properties
197
+ crashlytics-build.properties
198
+ fabric.properties
199
+
200
+ # Editor-based Rest Client
201
+ .idea/httpRequests
202
+
203
+ # Android studio 3.1+ serialized cache file
204
+ .idea/caches/build_file_checksums.ser
205
+
206
+ ### Linux template
207
+ # General
208
+ *~
209
+
210
+ # temporary files which can be created if a process still has a handle open of a deleted file
211
+ .fuse_hidden*
212
+
213
+ # KDE directory preferences
214
+ .directory
215
+
216
+ # Linux trash folder which might appear on any partition or disk
217
+ .Trash-*
218
+
219
+ # .nfs files are created when an open file is removed but is still being accessed
220
+ .nfs*
221
+
222
+ ### macOS template
223
+ # General
224
+ .DS_Store
225
+ .AppleDouble
226
+ .LSOverride
227
+
228
+ # Icon must end with two \r
229
+ Icon
230
+
231
+ # Thumbnails
232
+ ._*
233
+
234
+ # Files that might appear in the root of a volume
235
+ .DocumentRevisions-V100
236
+ .fseventsd
237
+ .Spotlight-V100
238
+ .TemporaryItems
239
+ .Trashes
240
+ .VolumeIcon.icns
241
+ .com.apple.timemachine.donotpresent
242
+
243
+ # Directories potentially created on remote AFP share
244
+ .AppleDB
245
+ .AppleDesktop
246
+ Network Trash Folder
247
+ Temporary Items
248
+ .apdisk
249
+
250
+ ### Archives template
251
+ # It's better to unpack these files and commit the raw source because
252
+ # git has its own built in compression methods.
253
+ *.7z
254
+ *.jar
255
+ *.rar
256
+ *.zip
257
+ !plugins/pycharm/project-opener/project-opener.zip
258
+ !plugins/idea/project-opener/project-opener.zip
259
+ *.gz
260
+ *.gzip
261
+ *.tgz
262
+ *.bzip
263
+ *.bzip2
264
+ *.bz2
265
+ *.xz
266
+ *.lzma
267
+ *.cab
268
+ *.xar
269
+ *.zst
270
+ *.tzst
271
+
272
+ # Packing-only formats
273
+ *.iso
274
+ *.tar
275
+
276
+ # Package management formats
277
+ *.dmg
278
+ *.xpi
279
+ *.gem
280
+ *.deb
281
+ *.rpm
282
+ *.msi
283
+ *.msm
284
+ *.msp
285
+ *.txz
286
+
287
+ ### Dev Container template
288
+ .*.temp.dockerfile
289
+
290
+ ### Project template
291
+ .project/
292
+ /test-results.xml
293
+ examples/*.json
294
+ examples/**/*.patch
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: idegym-api
3
+ Version: 0.9.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.9.0
8
+ Requires-Dist: kubernetes>=35.0.0
9
+ Requires-Dist: pydantic>=2.10.6
10
+ Requires-Dist: pyyaml>=6.0.2
11
+ Requires-Dist: structlog>=25.4.0
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "idegym-api"
3
+ version = "0.9.0"
4
+ description = "IdeGYM API"
5
+ authors = [
6
+ { name = "Daniil Mironov", email = "daniil.mironov@jetbrains.com" },
7
+ { name = "Ozren Dabić", email = "ozren.dabic@jetbrains.com" },
8
+ { name = "Vladimir Poliakov", email = "vladimir.poliakov@jetbrains.com" },
9
+ { name = "Vseslav Kasatskii", email = "vseslav.kasatskii@jetbrains.com" },
10
+ ]
11
+ requires-python = ">=3.12"
12
+ dependencies = ["idegym-common-utils>=0.9.0", "kubernetes>=35.0.0", "pydantic>=2.10.6", "pyyaml>=6.0.2", "structlog>=25.4.0"]
13
+
14
+ [build-system]
15
+ requires = ["hatchling"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/idegym"]
@@ -0,0 +1,9 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("idegym-api")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0.dev0"
7
+
8
+ if __version__ == "0.0.0.dev0":
9
+ __version__ = "latest"
@@ -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()
@@ -0,0 +1,5 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class CapabilitiesResponse(BaseModel):
5
+ plugins: list[str] = Field(description="Names of server plugins loaded in the running container")
@@ -0,0 +1,203 @@
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.memory import MemoryQuantity
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: MemoryQuantity = Field(ge=0, default=MemoryQuantity(mi=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: MemoryQuantity = Field(ge=0, default=MemoryQuantity(mi=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 NodePoolConfig(BaseModel):
85
+ enabled: bool = Field(description="Enable dedicated node pool scheduling", default=False)
86
+ taint_key: str = Field(description="Taint key applied to dedicated pool nodes", default="jetbrains.com/idegym")
87
+ preference_weight: int = Field(
88
+ description="Weight (1-100) for preferring dedicated pool nodes", ge=1, le=100, default=100
89
+ )
90
+
91
+
92
+ class ResourcesConfig(BaseModel):
93
+ default_cpu_request: float = Field(description="Default CPU cores per environment", ge=0, default=1.0)
94
+ default_ram_request: float = Field(description="Default RAM per environment in GB", ge=0, default=2.0)
95
+
96
+
97
+ class ConnectionLimitsConfig(BaseModel):
98
+ max_connections_or_asyncio_tasks: int = Field(
99
+ description="The maximum number of concurrent connections that may be established or asyncio tasks in uvicorn.",
100
+ ge=1,
101
+ default=1500,
102
+ )
103
+ unhealthy_connections_or_asyncio_tasks: int = Field(
104
+ description="The maximum number of concurrent connections that"
105
+ " may be established or asyncio tasks in uvicorn after which orchestrator becomes unhealthy.",
106
+ ge=1,
107
+ default=1000,
108
+ )
109
+ max_keepalive_connections: int = Field(
110
+ description="Allow the connection pool to maintain keep-alive connections below this point."
111
+ "Should be less than or equal to `max_connections`",
112
+ ge=1,
113
+ default=20,
114
+ )
115
+ keepalive_expiry: float = Field(
116
+ description="Time limit on idle keep-alive connections in seconds.", ge=1.0, default=5.0
117
+ )
118
+
119
+
120
+ class TracingConfig(BaseModel):
121
+ endpoint: Optional[HttpUrl] = Field(description="OTLP HTTP endpoint for traces", default=None)
122
+ timeout: float = Field(description="Timeout for sending traces in seconds", ge=0, default=10)
123
+ auth: BasicAuth = Field(default_factory=BasicAuth)
124
+
125
+ @property
126
+ def enabled(self) -> bool:
127
+ return bool(self.endpoint)
128
+
129
+
130
+ class OTELConfig(BaseModel):
131
+ service_name: Optional[str] = Field(default=None)
132
+ tracing: TracingConfig = Field(default_factory=TracingConfig)
133
+ attributes: dict[str, str] = Field(description="Extra attributes added to all spans", default_factory=dict)
134
+
135
+
136
+ class PodSnapshotConfig(BaseModel):
137
+ enabled: bool = Field(default=False)
138
+ service_account_name: str = Field(
139
+ description="Kubernetes service account shared by all snapshot-enabled pods", default="idegym"
140
+ )
141
+
142
+
143
+ class WatcherConfig(BaseModel):
144
+ cleanup_interval: Duration = Field(default=Duration(seconds=60))
145
+ inactive_timeout: Duration = Field(
146
+ description="Inactivity timeout after which idle servers/clients are cleaned up",
147
+ default=Duration(minutes=10),
148
+ )
149
+ finished_timeout: Duration = Field(
150
+ description="How long to keep finished servers before deleting them",
151
+ default=Duration(minutes=5),
152
+ )
153
+ request_max_age: Duration = Field(
154
+ description="Maximum age of request records to retain",
155
+ default=Duration(days=14),
156
+ )
157
+ request_stale: Duration = Field(
158
+ description="Age after which IN_PROGRESS requests are marked as finished",
159
+ default=Duration(hours=24),
160
+ )
161
+
162
+
163
+ class OrchestratorConfig(BaseModel):
164
+ host: IPvAddress = Field(default="0.0.0.0")
165
+ port: int = Field(ge=0, le=65535, default=8000)
166
+ workers: int = Field(description="Number of uvicorn worker processes", ge=1, default=1)
167
+ prometheus_multiproc_dir: str = Field(
168
+ description="Directory for Prometheus multiprocess metric files",
169
+ default=join(gettempdir(), "idegym", "prometheus"),
170
+ )
171
+ database: DatabaseConfig = Field(default_factory=DatabaseConfig)
172
+ sqlalchemy: SQLAlchemyConfig = Field(default_factory=SQLAlchemyConfig)
173
+ asyncio: AsyncioConfig = Field(default_factory=AsyncioConfig)
174
+ resources: ResourcesConfig = Field(default_factory=ResourcesConfig)
175
+ node_pool: NodePoolConfig = Field(default_factory=NodePoolConfig)
176
+ watcher: WatcherConfig = Field(default_factory=WatcherConfig)
177
+ client_request_timeout: float = Field(
178
+ description="Client request read timeout in seconds",
179
+ default=60.0 * 60, # 1 hour
180
+ )
181
+ connection_limits: ConnectionLimitsConfig = Field(default_factory=ConnectionLimitsConfig)
182
+ pod_snapshot: PodSnapshotConfig = Field(default_factory=PodSnapshotConfig)
183
+ enable_fifo_server_reuse: bool = Field(
184
+ description="Enable FIFO queue for server reuse to ensure fair provisioning",
185
+ default=False,
186
+ )
187
+
188
+ @field_validator("prometheus_multiproc_dir")
189
+ def validate_prometheus_multiproc_dir(cls, value: str) -> str:
190
+ path = value.strip() if value else None
191
+ if not path:
192
+ field = cls.__pydantic_fields__["prometheus_multiproc_dir"]
193
+ return field.default
194
+ else:
195
+ return abspath(path)
196
+
197
+
198
+ class Config(BaseModel):
199
+ server: ServerConfig = Field(default_factory=ServerConfig)
200
+ logging: LoggingConfig = Field(default_factory=LoggingConfig)
201
+ project: ProjectConfig = Field(default_factory=ProjectConfig)
202
+ otel: OTELConfig = Field(default_factory=OTELConfig)
203
+ orchestrator: OrchestratorConfig = Field(default_factory=OrchestratorConfig)
@@ -0,0 +1,144 @@
1
+ from functools import total_ordering
2
+ from re import Pattern, compile
3
+ from typing import Any, ClassVar, Union
4
+
5
+ from kubernetes.utils import parse_quantity
6
+ from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
7
+ from pydantic.json_schema import JsonSchemaValue
8
+ from pydantic_core import core_schema
9
+
10
+
11
+ @total_ordering
12
+ class CpuQuantity:
13
+ """
14
+ CPU quantity supporting fractional/whole cores and millicore notation.
15
+
16
+ Stores values internally as integer millicores.
17
+ Accepts strings like ``"500m"``/``"1.5"`` or
18
+ numeric cores as ``int``/``float``.
19
+ """
20
+
21
+ PATTERN: ClassVar[Pattern] = compile(r"^(\d*\.?\d+)(m)?$")
22
+
23
+ def __init__(self, *, cores: int | float = 0, millicores: int = 0):
24
+ total = round(cores * 1000) + millicores
25
+ if total < 0:
26
+ raise ValueError("CpuQuantity cannot be negative")
27
+ self._millicores = total
28
+
29
+ @classmethod
30
+ def parse(cls, value: str) -> "CpuQuantity":
31
+ try:
32
+ normalized = value.strip()
33
+ matcher = cls.PATTERN.match(normalized)
34
+ if not matcher:
35
+ raise ValueError(f"'{value}' does not match CPU quantity pattern")
36
+ amount, suffix = matcher.groups()
37
+ if suffix == "m" and "." in amount:
38
+ raise ValueError(f"Millicore values must be integers: {amount}")
39
+ cores = parse_quantity(normalized)
40
+ if cores < 0:
41
+ raise ValueError("CpuQuantity cannot be negative")
42
+ return cls(millicores=int(cores * 1000))
43
+ except ValueError:
44
+ raise
45
+ except Exception as ex:
46
+ raise ValueError(f"'{value}' is not a valid CPU quantity") from ex
47
+
48
+ @property
49
+ def millicores(self) -> int:
50
+ return self._millicores
51
+
52
+ @property
53
+ def cores(self) -> float:
54
+ return self._millicores / 1000
55
+
56
+ def __eq__(self, other: object) -> bool:
57
+ if self is other:
58
+ return True
59
+ match other:
60
+ case CpuQuantity():
61
+ return self._millicores == other._millicores
62
+ case int() | float():
63
+ return self._millicores == round(other * 1000)
64
+ case str():
65
+ return self._millicores == CpuQuantity.parse(other)._millicores
66
+ case _:
67
+ return False
68
+
69
+ def __lt__(self, other: Union["CpuQuantity", int, float, str]) -> bool:
70
+ match other:
71
+ case CpuQuantity():
72
+ return self._millicores < other._millicores
73
+ case int() | float():
74
+ return self._millicores < round(other * 1000)
75
+ case str():
76
+ return self._millicores < CpuQuantity.parse(other)._millicores
77
+ case _:
78
+ return NotImplemented
79
+
80
+ def __str__(self) -> str:
81
+ if self._millicores % 1000 == 0:
82
+ return str(self._millicores // 1000)
83
+ return f"{self._millicores}m"
84
+
85
+ def __repr__(self) -> str:
86
+ return f"CpuQuantity(millicores={self._millicores})"
87
+
88
+ def __hash__(self) -> int:
89
+ return hash(self._millicores)
90
+
91
+ @classmethod
92
+ def validate(cls, value: Union["CpuQuantity", int, float, str]) -> "CpuQuantity":
93
+ match value:
94
+ case cls():
95
+ return value
96
+ case int() | float():
97
+ return cls(cores=value)
98
+ case str():
99
+ return cls.parse(value)
100
+ case _:
101
+ raise ValueError(f"Cannot convert {value} to {cls.__name__}")
102
+
103
+ @classmethod
104
+ def __get_pydantic_core_schema__(
105
+ cls,
106
+ _source_type: Any,
107
+ _handler: GetCoreSchemaHandler,
108
+ ) -> core_schema.CoreSchema:
109
+ return core_schema.union_schema(
110
+ [
111
+ core_schema.is_instance_schema(cls),
112
+ core_schema.chain_schema(
113
+ [
114
+ core_schema.int_schema(),
115
+ core_schema.no_info_plain_validator_function(cls.validate),
116
+ ]
117
+ ),
118
+ core_schema.chain_schema(
119
+ [
120
+ core_schema.float_schema(),
121
+ core_schema.no_info_plain_validator_function(cls.validate),
122
+ ]
123
+ ),
124
+ core_schema.chain_schema(
125
+ [
126
+ core_schema.str_schema(),
127
+ core_schema.no_info_plain_validator_function(cls.parse),
128
+ ]
129
+ ),
130
+ ],
131
+ serialization=core_schema.plain_serializer_function_ser_schema(str),
132
+ )
133
+
134
+ @classmethod
135
+ def __get_pydantic_json_schema__(
136
+ cls,
137
+ _core_schema: core_schema.CoreSchema,
138
+ _handler: GetJsonSchemaHandler,
139
+ ) -> JsonSchemaValue:
140
+ return {
141
+ "type": "string",
142
+ "description": "CPU quantity in cores or millicores (e.g. '500m', '1', '2.5')",
143
+ "examples": ["100m", "250m", "500m", "1", "1500m", "2"],
144
+ }