• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

SwissDataScienceCenter / renku-data-services / 19325323565

13 Nov 2025 08:29AM UTC coverage: 86.236% (-0.1%) from 86.355%
19325323565

Pull #1094

github

web-flow
Merge 371b823e7 into 0ea39ae9f
Pull Request #1094: feat: add user alerts

244 of 311 new or added lines in 11 files covered. (78.46%)

104 existing lines in 5 files now uncovered.

23050 of 26729 relevant lines covered (86.24%)

1.52 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.52
/components/renku_data_services/notebooks/crs.py
1
"""Custom resource definition with proper names from the autogenerated code."""
2

3
from __future__ import annotations
2✔
4

5
import re
2✔
6
from collections.abc import Mapping, Sequence
2✔
7
from datetime import datetime, timedelta
2✔
8
from typing import Any, cast, override
2✔
9
from urllib.parse import urlunparse
2✔
10

11
from kubernetes.utils import parse_duration, parse_quantity
2✔
12
from kubernetes.utils.duration import format_duration
2✔
13
from pydantic import BaseModel, Field, field_serializer, field_validator, model_serializer
2✔
14
from pydantic.types import HashableItemType
2✔
15
from ulid import ULID
2✔
16

17
from renku_data_services.base_models.core import RESET, ResetType
2✔
18
from renku_data_services.errors import errors
2✔
19
from renku_data_services.notebooks import apispec
2✔
20
from renku_data_services.notebooks.constants import AMALTHEA_SESSION_GVK, JUPYTER_SESSION_GVK
2✔
21
from renku_data_services.notebooks.cr_amalthea_session import Affinity as _Affinity
2✔
22
from renku_data_services.notebooks.cr_amalthea_session import (
2✔
23
    Authentication,
24
    CodeRepository,
25
    DataSource,
26
    EmptyDir,
27
    ExtraContainer,
28
    ExtraVolume,
29
    ExtraVolumeMount,
30
    ImagePullPolicy,
31
    ImagePullSecret,
32
    Ingress,
33
    InitContainer,
34
    MatchExpression,
35
    NodeAffinity,
36
    NodeSelectorTerm,
37
    PodAffinity,
38
    PodAntiAffinity,
39
    Preference,
40
    PreferredDuringSchedulingIgnoredDuringExecutionItem,
41
    ReconcileStrategy,
42
    RemoteSecretRef,
43
    RequiredDuringSchedulingIgnoredDuringExecution,
44
    RequiredDuringSchedulingIgnoredDuringExecutionItem,
45
    Size,
46
    State,
47
    Status,
48
    Storage,
49
    TlsSecret,
50
    Toleration,
51
)
52
from renku_data_services.notebooks.cr_amalthea_session import (
2✔
53
    Culling as _ASCulling,
54
)
55
from renku_data_services.notebooks.cr_amalthea_session import EnvItem2 as SessionEnvItem
2✔
56
from renku_data_services.notebooks.cr_amalthea_session import Item4 as SecretAsVolumeItem
2✔
57
from renku_data_services.notebooks.cr_amalthea_session import Limits6 as _Limits
2✔
58
from renku_data_services.notebooks.cr_amalthea_session import Limits7 as LimitsStr
2✔
59
from renku_data_services.notebooks.cr_amalthea_session import Location as SessionLocation
2✔
60
from renku_data_services.notebooks.cr_amalthea_session import Model as _ASModel
2✔
61
from renku_data_services.notebooks.cr_amalthea_session import (
2✔
62
    PreferredDuringSchedulingIgnoredDuringExecutionItem1 as PreferredPodAffinityItem,
63
)
64
from renku_data_services.notebooks.cr_amalthea_session import (
2✔
65
    PreferredDuringSchedulingIgnoredDuringExecutionItem2 as PreferredPodAntiAffinityItem,
66
)
67
from renku_data_services.notebooks.cr_amalthea_session import Requests6 as _Requests
2✔
68
from renku_data_services.notebooks.cr_amalthea_session import Requests7 as RequestsStr
2✔
69
from renku_data_services.notebooks.cr_amalthea_session import (
2✔
70
    RequiredDuringSchedulingIgnoredDuringExecutionItem1 as RequiredPodAntiAffinityItem,
71
)
72
from renku_data_services.notebooks.cr_amalthea_session import Resources3 as _Resources
2✔
73
from renku_data_services.notebooks.cr_amalthea_session import Secret1 as SecretAsVolume
2✔
74
from renku_data_services.notebooks.cr_amalthea_session import SecretRef as _SecretRef
2✔
75
from renku_data_services.notebooks.cr_amalthea_session import Session as _ASSession
2✔
76
from renku_data_services.notebooks.cr_amalthea_session import ShmSize1 as ShmSizeStr
2✔
77
from renku_data_services.notebooks.cr_amalthea_session import Size1 as SizeStr
2✔
78
from renku_data_services.notebooks.cr_amalthea_session import Spec as _ASSpec
2✔
79
from renku_data_services.notebooks.cr_amalthea_session import Type as AuthenticationType
2✔
80
from renku_data_services.notebooks.cr_amalthea_session import Type1 as CodeRepositoryType
2✔
81
from renku_data_services.notebooks.cr_base import BaseCRD
2✔
82
from renku_data_services.notebooks.cr_jupyter_server import Model as _JSModel
2✔
83
from renku_data_services.notebooks.cr_jupyter_server import Patch
2✔
84
from renku_data_services.notebooks.cr_jupyter_server import Spec as JupyterServerSpec
2✔
85
from renku_data_services.notebooks.cr_jupyter_server import Type as PatchType
2✔
86

87

88
class Metadata(BaseModel):
2✔
89
    """Basic k8s metadata spec."""
90

91
    class Config:
2✔
92
        """Do not exclude unknown properties."""
93

94
        extra = "allow"
2✔
95

96
    name: str
2✔
97
    namespace: str | None = None
2✔
98
    labels: dict[str, str] = Field(default_factory=dict)
2✔
99
    annotations: dict[str, str] = Field(default_factory=dict)
2✔
100
    uid: str | None = None
2✔
101
    creationTimestamp: datetime | None = None
2✔
102
    deletionTimestamp: datetime | None = None
2✔
103

104

105
class ComputeResources(BaseModel):
2✔
106
    """Resource requests from k8s values."""
107

108
    cpu: float | None = None
2✔
109
    memory: int | None = None
2✔
110
    storage: int | None = None
2✔
111
    gpu: int | None = None
2✔
112

113
    @field_validator("cpu", mode="before")
2✔
114
    @classmethod
2✔
115
    def _convert_k8s_cpu(cls, val: Any) -> Any:
2✔
116
        if val is None:
1✔
117
            return None
×
118
        if hasattr(val, "root"):
1✔
119
            val = val.root
×
120
        return float(parse_quantity(val))
1✔
121

122
    @field_validator("gpu", mode="before")
2✔
123
    @classmethod
2✔
124
    def _convert_k8s_gpu(cls, val: Any) -> Any:
2✔
125
        if val is None:
×
126
            return None
×
127
        if hasattr(val, "root"):
×
128
            val = val.root
×
129
        return round(parse_quantity(val), ndigits=None)
×
130

131
    @field_validator("memory", "storage", mode="before")
2✔
132
    @classmethod
2✔
133
    def _convert_k8s_bytes(cls, val: Any) -> Any:
2✔
134
        """Converts to gigabytes of base 10."""
135
        if val is None:
1✔
136
            return None
×
137
        if hasattr(val, "root"):
1✔
138
            val = val.root
×
139
        return round(parse_quantity(val) / 1_000_000_000, ndigits=None)
1✔
140

141

142
class JupyterServerV1Alpha1(_JSModel):
2✔
143
    """Jupyter server CRD."""
144

145
    kind: str = JUPYTER_SESSION_GVK.kind
2✔
146
    apiVersion: str = JUPYTER_SESSION_GVK.group_version
2✔
147
    metadata: Metadata
2✔
148

149
    def get_compute_resources(self) -> ComputeResources:
2✔
150
        """Convert the k8s resource requests and storage into usable values."""
151
        if self.spec is None:
×
152
            return ComputeResources()
×
153
        resource_requests: dict = self.spec.jupyterServer.resources.get("requests", {})
×
154
        resource_requests["storage"] = self.spec.storage.size
×
155
        return ComputeResources.model_validate(resource_requests)
×
156

157
    def resource_class_id(self) -> int:
2✔
158
        """Get the resource class from the annotations."""
159
        if "renku.io/resourceClassId" not in self.metadata.annotations:
×
160
            raise errors.ProgrammingError(
×
161
                message=f"The session with name {self.metadata.name} is missing its renku.io/resourceClassId annotation"
162
            )
163
        i = int(self.metadata.annotations["renku.io/resourceClassId"])
×
164
        return i
×
165

166

167
class CullingDurationParsingMixin(BaseCRD):
2✔
168
    """Amalthea session culling configuration."""
169

170
    @field_serializer("*", mode="wrap")
2✔
171
    def __serialize_duration_field(self, val: Any, nxt: Any, _info: Any) -> Any:
2✔
172
        if isinstance(val, timedelta):
1✔
173
            return format_duration(val)
1✔
174
        if val is RESET:
1✔
175
            return None
1✔
176
        return nxt(val)
×
177

178
    @field_validator("*", mode="wrap")
2✔
179
    @classmethod
2✔
180
    def __deserialize_duration(cls, val: Any, handler: Any) -> Any:
2✔
181
        if isinstance(val, str):
1✔
182
            return safe_parse_duration(val)
1✔
183
        return handler(val)
1✔
184

185

186
class Culling(_ASCulling, CullingDurationParsingMixin):
2✔
187
    """Amalthea session culling configuration.
188

189
    A value of zero for any of the values indicates that the automatic action will never happen.
190
    I.e. a value of zero for `maxIdleDuration` indicates that the session will never be hibernated
191
    no matter how long it is idle.
192
    """
193

194
    pass
2✔
195

196

197
class Requests(_Requests):
2✔
198
    """Resource requests of type integer."""
199

200
    root: int
2✔
201

202

203
class Limits(_Limits):
2✔
204
    """Resource limits of type integer."""
205

206
    root: int
2✔
207

208

209
class Resources(_Resources):
2✔
210
    """Resource requests and limits spec.
211

212
    Overriding these is necessary because of
213
    https://docs.pydantic.dev/2.11/errors/validation_errors/#string_type.
214
    An integer model cannot have a regex pattern for validation in pydantic.
215
    But the code generation applies the pattern constraint to both the int and string variations
216
    of the fields. But the int variation runs and blows up at runtime only when an int is passed
217
    for validation.
218
    """
219

220
    limits: Mapping[str, LimitsStr | Limits] | None = None
2✔
221
    requests: Mapping[str, RequestsStr | Requests] | None = None
2✔
222

223

224
class SecretRef(_SecretRef, RemoteSecretRef):
2✔
225
    """Reference to a secret."""
226

227
    pass
2✔
228

229

230
class Session(_ASSession):
2✔
231
    """Amalthea spec.session schema."""
232

233
    resources: Resources | None = None
2✔
234
    remoteSecretRef: SecretRef | None = None
2✔
235

236

237
class AmaltheaSessionSpec(_ASSpec):
2✔
238
    """Amalthea session specification."""
239

240
    session: Session
2✔
241
    culling: Culling | None = None
2✔
242

243

244
class AmaltheaSessionV1Alpha1(_ASModel):
2✔
245
    """Amalthea session CRD."""
246

247
    kind: str = AMALTHEA_SESSION_GVK.kind
2✔
248
    apiVersion: str = AMALTHEA_SESSION_GVK.group_version
2✔
249
    # Here we overwrite the default from ASModel because it is too weakly typed
250
    metadata: Metadata  # type: ignore[assignment]
2✔
251
    spec: AmaltheaSessionSpec
2✔
252

253
    def get_compute_resources(self) -> ComputeResources:
2✔
254
        """Convert the k8s resource requests and storage into usable values."""
255
        resource_requests: dict = {}
1✔
256
        if self.spec.session.resources is not None:
1✔
257
            reqs = self.spec.session.resources.requests or {}
1✔
258
            reqs = {k: parse_quantity(v.root if hasattr(v, "root") else v) for k, v in reqs.items()}
1✔
259
            resource_requests = {
1✔
260
                **reqs,
261
                "storage": parse_quantity(self.spec.session.storage.size.root),
262
            }
263
        return ComputeResources.model_validate(resource_requests)
1✔
264

265
    @property
2✔
266
    def project_id(self) -> ULID:
2✔
267
        """Get the project ID from the annotations."""
268
        if "renku.io/project_id" not in self.metadata.annotations:
1✔
UNCOV
269
            raise errors.ProgrammingError(
×
270
                message=f"The session with name {self.metadata.name} is missing its project_id annotation"
271
            )
272
        return cast(ULID, ULID.from_str(self.metadata.annotations["renku.io/project_id"]))
1✔
273

274
    @property
2✔
275
    def launcher_id(self) -> ULID:
2✔
276
        """Get the launcher ID from the annotations."""
277
        if "renku.io/launcher_id" not in self.metadata.annotations:
1✔
UNCOV
278
            raise errors.ProgrammingError(
×
279
                message=f"The session with name {self.metadata.name} is missing its launcher_id annotation"
280
            )
281
        return cast(ULID, ULID.from_str(self.metadata.annotations["renku.io/launcher_id"]))
1✔
282

283
    def resource_class_id(self) -> int:
2✔
284
        """Get the resource class from the annotations."""
285
        if "renku.io/resource_class_id" not in self.metadata.annotations:
1✔
UNCOV
286
            raise errors.ProgrammingError(
×
287
                message=f"The session with name {self.metadata.name} is missing its resource_class_id annotation"
288
            )
289
        return int(self.metadata.annotations["renku.io/resource_class_id"])
1✔
290

291
    def as_apispec(self) -> apispec.SessionResponse:
2✔
292
        """Convert the manifest into a form ready to be serialized and sent in a HTTP response."""
293
        if self.status is None:
1✔
294
            raise errors.ProgrammingError(
×
295
                message=f"The manifest for a session with name {self.metadata.name} cannot be serialized "
296
                f"because it is missing a status"
297
            )
298
        if self.spec is None:
1✔
299
            raise errors.ProgrammingError(
×
300
                message=f"The manifest for a session with name {self.metadata.name} cannot be serialized "
301
                "because it is missing the spec field"
302
            )
303
        if self.spec.session.resources is None:
1✔
UNCOV
304
            raise errors.ProgrammingError(
×
305
                message=f"The manifest for a session with name {self.metadata.name} cannot be serialized "
306
                "because it is missing the spec.session.resources field"
307
            )
308
        url = "None"
1✔
309
        if self.base_url is not None:
1✔
310
            url = self.base_url
1✔
311
        ready_containers = 0
1✔
312
        total_containers = 0
1✔
313
        if self.status.initContainerCounts is not None:
1✔
314
            ready_containers += self.status.initContainerCounts.ready or 0
1✔
315
            total_containers += self.status.initContainerCounts.total or 0
1✔
316
        if self.status.containerCounts is not None:
1✔
317
            ready_containers += self.status.containerCounts.ready or 0
1✔
318
            total_containers += self.status.containerCounts.total or 0
1✔
319

320
        if self.status.state in [State.Running, State.Hibernated, State.Failed]:
1✔
321
            # Amalthea is sometimes slow when (un)hibernating and still shows the old status, so we patch it here
322
            # so the client sees the correct state
323
            if not self.spec.hibernated and self.status.state == State.Hibernated:
1✔
UNCOV
324
                state = apispec.State3.starting
×
325
            elif self.spec.hibernated and self.status.state == State.Running:
1✔
UNCOV
326
                state = apispec.State3.hibernated
×
327
            else:
328
                state = apispec.State3(self.status.state.value.lower())
1✔
329
        elif self.status.state == State.RunningDegraded:
1✔
UNCOV
330
            state = apispec.State3.running
×
331
        elif self.status.state == State.NotReady and self.metadata.deletionTimestamp is not None:
1✔
UNCOV
332
            state = apispec.State3.stopping
×
333
        else:
334
            state = apispec.State3.starting
1✔
335

336
        will_hibernate_at: datetime | None = None
1✔
337
        will_delete_at: datetime | None = None
1✔
338
        match self.status, self.spec.culling:
1✔
339
            case (
1✔
340
                Status(idle=True, idleSince=idle_since),
341
                Culling(maxIdleDuration=max_idle),
342
            ) if idle_since and max_idle:
343
                will_hibernate_at = idle_since + max_idle
×
344
            case (
1✔
345
                Status(state=State.Failed, failingSince=failing_since),
346
                Culling(maxFailedDuration=max_failed),
347
            ) if failing_since and max_failed:
348
                will_hibernate_at = failing_since + max_failed
×
349
            case (
1✔
350
                Status(state=State.NotReady),
351
                Culling(maxAge=max_age),
352
            ) if max_age and self.metadata.creationTimestamp:
353
                will_hibernate_at = self.metadata.creationTimestamp + max_age
×
354
            case (
1✔
355
                Status(state=State.Hibernated, hibernatedSince=hibernated_since),
356
                Culling(maxHibernatedDuration=max_hibernated),
357
            ) if hibernated_since and max_hibernated:
UNCOV
358
                will_delete_at = hibernated_since + max_hibernated
×
359

360
        return apispec.SessionResponse(
1✔
361
            image=self.spec.session.image,
362
            name=self.metadata.name,
363
            resources=apispec.SessionResources(
364
                requests=apispec.SessionResourcesRequests.model_validate(
365
                    self.get_compute_resources(), from_attributes=True
366
                )
367
                if self.spec.session.resources.requests is not None
368
                else None,
369
            ),
370
            started=self.metadata.creationTimestamp,
371
            status=apispec.SessionStatus(
372
                state=state,
373
                ready_containers=ready_containers,
374
                total_containers=total_containers,
375
                will_hibernate_at=will_hibernate_at,
376
                will_delete_at=will_delete_at,
377
                message=self.status.error,
378
            ),
379
            url=url,
380
            project_id=str(self.project_id),
381
            launcher_id=str(self.launcher_id),
382
            resource_class_id=self.resource_class_id(),
383
        )
384

385
    @property
2✔
386
    def base_url(self) -> str | None:
2✔
387
        """Get the URL of the session, excluding the default URL from the session launcher."""
388
        if self.status.url and len(self.status.url) > 0:
1✔
389
            return self.status.url
1✔
390
        if self.spec is None or self.spec.ingress is None:
1✔
UNCOV
391
            return None
×
392
        scheme = "https" if self.spec and self.spec.ingress and self.spec.ingress.tlsSecret else "http"
1✔
393
        host = self.spec.ingress.host
1✔
394
        path = self.spec.session.urlPath if self.spec.session.urlPath else "/"
1✔
395
        if not path.endswith("/"):
1✔
396
            path += "/"
1✔
397
        params = None
1✔
398
        query = None
1✔
399
        fragment = None
1✔
400
        url = urlunparse((scheme, host, path, params, query, fragment))
1✔
401
        return url
1✔
402

403

404
class ResourcesPatch(BaseCRD):
2✔
405
    """Resource requests and limits patch."""
406

407
    limits: Mapping[str, LimitsStr | Limits | ResetType] | ResetType | None = None
2✔
408
    requests: Mapping[str, RequestsStr | Requests | ResetType] | ResetType | None = None
2✔
409

410

411
class AmaltheaSessionV1Alpha1SpecSessionPatch(BaseCRD):
2✔
412
    """Patch for the main session config."""
413

414
    resources: ResourcesPatch | ResetType | None = None
2✔
415
    shmSize: int | str | ResetType | None = None
2✔
416
    storage: Storage | ResetType | None = None
2✔
417
    imagePullPolicy: ImagePullPolicy | None = None
2✔
418
    extraVolumeMounts: list[ExtraVolumeMount] | ResetType | None = None
2✔
419

420

421
class AmaltheaSessionV1Alpha1MetadataPatch(BaseCRD):
2✔
422
    """Patch for the metadata of an amalthea session."""
423

424
    annotations: dict[str, str | ResetType] | ResetType | None = None
2✔
425

426

427
class NodeAffinityPatch(BaseCRD):
2✔
428
    """Patch for the node affinity of a session."""
429

430
    preferredDuringSchedulingIgnoredDuringExecution: (
2✔
431
        Sequence[PreferredDuringSchedulingIgnoredDuringExecutionItem] | None | ResetType
432
    ) = None
433
    requiredDuringSchedulingIgnoredDuringExecution: (
2✔
434
        RequiredDuringSchedulingIgnoredDuringExecution | None | ResetType
435
    ) = None
436

437

438
class PodAffinityPatch(BaseCRD):
2✔
439
    """Patch for the pod affinity of a session."""
440

441
    preferredDuringSchedulingIgnoredDuringExecution: Sequence[PreferredPodAffinityItem] | None | ResetType = None
2✔
442
    requiredDuringSchedulingIgnoredDuringExecution: (
2✔
443
        Sequence[RequiredDuringSchedulingIgnoredDuringExecutionItem] | None | ResetType
444
    ) = None
445

446

447
class PodAntiAffinityPatch(BaseCRD):
2✔
448
    """Patch for the pod anti affinity of a session."""
449

450
    preferredDuringSchedulingIgnoredDuringExecution: Sequence[PreferredPodAntiAffinityItem] | None | ResetType = None
2✔
451
    requiredDuringSchedulingIgnoredDuringExecution: Sequence[RequiredPodAntiAffinityItem] | None | ResetType = None
2✔
452

453

454
class AffinityPatch(BaseCRD):
2✔
455
    """Patch for the affinity of a session."""
456

457
    nodeAffinity: NodeAffinityPatch | ResetType | None = None
2✔
458
    podAffinity: PodAffinityPatch | ResetType | None = None
2✔
459
    podAntiAffinity: PodAntiAffinityPatch | ResetType | None = None
2✔
460

461

462
class CullingPatch(CullingDurationParsingMixin):
2✔
463
    """Patch for the culling durations of a session."""
464

465
    maxAge: timedelta | ResetType | None = None
2✔
466
    maxFailedDuration: timedelta | ResetType | None = None
2✔
467
    maxHibernatedDuration: timedelta | ResetType | None = None
2✔
468
    maxIdleDuration: timedelta | ResetType | None = None
2✔
469
    maxStartingDuration: timedelta | ResetType | None = None
2✔
470

471

472
class AmaltheaSessionV1Alpha1SpecPatch(BaseCRD):
2✔
473
    """Patch for the spec of an amalthea session."""
474

475
    extraContainers: list[ExtraContainer] | ResetType | None = None
2✔
476
    extraVolumes: list[ExtraVolume] | ResetType | None = None
2✔
477
    hibernated: bool | None = None
2✔
478
    initContainers: list[InitContainer] | ResetType | None = None
2✔
479
    imagePullSecrets: list[ImagePullSecret] | ResetType | None = None
2✔
480
    priorityClassName: str | ResetType | None = None
2✔
481
    tolerations: list[Toleration] | ResetType | None = None
2✔
482
    affinity: AffinityPatch | ResetType | None = None
2✔
483
    session: AmaltheaSessionV1Alpha1SpecSessionPatch | None = None
2✔
484
    culling: CullingPatch | ResetType | None = None
2✔
485
    service_account_name: str | ResetType | None = None
2✔
486

487

488
class AmaltheaSessionV1Alpha1Patch(BaseCRD):
2✔
489
    """Patch for an amalthea session."""
490

491
    metadata: AmaltheaSessionV1Alpha1MetadataPatch | None = None
2✔
492
    spec: AmaltheaSessionV1Alpha1SpecPatch
2✔
493

494
    def to_rfc7386(self) -> dict[str, Any]:
2✔
495
        """Generate the patch to be applied to the session.
496

497
        Note that when the value for a key in the patch is anything other than a
498
        dictionary then the rfc7386 patch will replace, not merge.
499
        """
500
        return self.model_dump(exclude_none=True, mode="json")
1✔
501

502

503
def safe_parse_duration(val: Any) -> timedelta:
2✔
504
    """Required because parse_duration from k8s can only deal with values with up to 5 digits.
505

506
    Values with 1 unit only (like seconds) and high values will cause things to fail.
507
    For example `parse_duration("1210500s")` will raise ValueError whereas `parse_duration("100s")` will be fine.
508
    This does not make the whole thing 100% foolproof but it eliminates errors like the above which
509
    we have seen in production.
510
    """
511
    if isinstance(val, timedelta):
1✔
UNCOV
512
        return val
×
513
    m = re.match(r"^([0-9]+)(h|m|s|ms)$", str(val))
1✔
514
    if m is not None:
1✔
515
        num = m.group(1)
1✔
516
        unit = m.group(2)
1✔
517
        match unit:
1✔
518
            case "h":
1✔
519
                return timedelta(hours=float(num))
1✔
520
            case "m":
1✔
UNCOV
521
                return timedelta(minutes=float(num))
×
522
            case "s":
1✔
523
                return timedelta(seconds=float(num))
1✔
UNCOV
524
            case "ms":
×
UNCOV
525
                return timedelta(microseconds=float(num))
×
526
    return cast(timedelta, parse_duration(val))
1✔
527

528

529
class Affinity(_Affinity):
2✔
530
    """Model for the affinity of an Amalthea session."""
531

532
    @property
2✔
533
    def is_empty(self) -> bool:
2✔
534
        """Indicates whether there is anything set at all in any of the affinity fields, or they are just all empty."""
535
        node_affinity_is_empty = not (
1✔
536
            self.nodeAffinity
537
            and (
538
                self.nodeAffinity.preferredDuringSchedulingIgnoredDuringExecution
539
                or self.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution
540
            )
541
        )
542
        pod_affinity_is_empty = not (
1✔
543
            self.podAffinity
544
            and (
545
                self.podAffinity.preferredDuringSchedulingIgnoredDuringExecution
546
                or self.podAffinity.requiredDuringSchedulingIgnoredDuringExecution
547
            )
548
        )
549
        pod_antiaffinity_is_empty = not (
1✔
550
            self.podAntiAffinity
551
            and (
552
                self.podAntiAffinity.preferredDuringSchedulingIgnoredDuringExecution
553
                or self.podAntiAffinity.requiredDuringSchedulingIgnoredDuringExecution
554
            )
555
        )
556
        return node_affinity_is_empty and pod_affinity_is_empty and pod_antiaffinity_is_empty
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc