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

SwissDataScienceCenter / renku-data-services / 18943682560

30 Oct 2025 02:14PM UTC coverage: 86.796% (-0.04%) from 86.84%
18943682560

Pull #1073

github

web-flow
Merge a9e53817a into 6b6ef3e65
Pull Request #1073: fix: update API spec for patching session environments

15 of 15 new or added lines in 3 files covered. (100.0%)

14 existing lines in 6 files now uncovered.

22724 of 26181 relevant lines covered (86.8%)

1.52 hits per line

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

95.33
/components/renku_data_services/session/models.py
1
"""Models for sessions."""
2

3
import typing
2✔
4
from dataclasses import dataclass
2✔
5
from datetime import datetime, timedelta
2✔
6
from enum import StrEnum
2✔
7
from pathlib import PurePosixPath
2✔
8
from typing import TYPE_CHECKING
2✔
9

10
from ulid import ULID
2✔
11

12
from renku_data_services import errors
2✔
13
from renku_data_services.base_models.core import ResetType
2✔
14
from renku_data_services.session import crs
2✔
15

16
if TYPE_CHECKING:
2✔
17
    from renku_data_services.session import apispec
×
18

19
from .constants import ENV_VARIABLE_NAME_MATCHER, ENV_VARIABLE_REGEX
2✔
20

21

22
@dataclass(frozen=True, eq=True, kw_only=True)
2✔
23
class Member:
2✔
24
    """Member model."""
25

26
    id: str
2✔
27

28

29
class EnvironmentKind(StrEnum):
2✔
30
    """The type of environment."""
31

32
    GLOBAL = "GLOBAL"
2✔
33
    CUSTOM = "CUSTOM"
2✔
34

35

36
class EnvironmentImageSource(StrEnum):
2✔
37
    """The source of the environment image."""
38

39
    image = "image"
2✔
40
    build = "build"
2✔
41

42

43
class BuilderVariant(StrEnum):
2✔
44
    """The type of environment builder."""
45

46
    python = "python"
2✔
47

48

49
class FrontendVariant(StrEnum):
2✔
50
    """The environment frontend choice."""
51

52
    vscodium = "vscodium"
2✔
53
    jupyterlab = "jupyterlab"
2✔
54
    ttyd = "ttyd"
2✔
55

56

57
@dataclass(kw_only=True, frozen=True, eq=True)
2✔
58
class UnsavedBuildParameters:
2✔
59
    """The parameters of a build."""
60

61
    repository: str
2✔
62
    builder_variant: str
2✔
63
    frontend_variant: str
2✔
64
    repository_revision: str | None = None
2✔
65
    context_dir: str | None = None
2✔
66

67

68
@dataclass(kw_only=True, frozen=True, eq=True)
2✔
69
class BuildParameters(UnsavedBuildParameters):
2✔
70
    """BuildParameters saved in the database."""
71

72
    id: ULID
2✔
73

74

75
@dataclass(kw_only=True, frozen=True, eq=True)
2✔
76
class UnsavedEnvironment:
2✔
77
    """Session environment model that has not been saved."""
78

79
    name: str
2✔
80
    description: str | None = None
2✔
81
    container_image: str
2✔
82
    default_url: str
2✔
83
    port: int = 8888
2✔
84
    working_directory: PurePosixPath | None = None
2✔
85
    mount_directory: PurePosixPath | None = None
2✔
86
    uid: int = 1000
2✔
87
    gid: int = 1000
2✔
88
    environment_kind: EnvironmentKind
2✔
89
    environment_image_source: EnvironmentImageSource
2✔
90
    args: list[str] | None = None
2✔
91
    command: list[str] | None = None
2✔
92
    is_archived: bool = False
2✔
93
    strip_path_prefix: bool = False
2✔
94

95
    def __post_init__(self) -> None:
2✔
96
        if self.working_directory and not self.working_directory.is_absolute():
2✔
97
            raise errors.ValidationError(message="The working directory for a session is supposed to be absolute")
×
98
        if self.mount_directory and not self.mount_directory.is_absolute():
2✔
UNCOV
99
            raise errors.ValidationError(message="The mount directory for a session is supposed to be absolute")
×
100
        if self.working_directory and self.working_directory.is_reserved():
2✔
101
            raise errors.ValidationError(
×
102
                message="The requested value for the working directory is reserved by the OS and cannot be used."
103
            )
104
        if self.mount_directory and self.mount_directory.is_reserved():
2✔
105
            raise errors.ValidationError(
×
106
                message="The requested value for the mount directory is reserved by the OS and cannot be used."
107
            )
108

109

110
@dataclass(frozen=True, eq=True, kw_only=True)
2✔
111
class Environment(UnsavedEnvironment):
2✔
112
    """Session environment model."""
113

114
    id: ULID
2✔
115
    creation_date: datetime
2✔
116
    created_by: Member
2✔
117
    container_image: str
2✔
118
    default_url: str
2✔
119
    port: int
2✔
120
    working_directory: PurePosixPath | None
2✔
121
    mount_directory: PurePosixPath | None
2✔
122
    uid: int
2✔
123
    gid: int
2✔
124
    build_parameters: BuildParameters | None
2✔
125
    build_parameters_id: ULID | None
2✔
126

127

128
@dataclass(kw_only=True, frozen=True, eq=True)
2✔
129
class BuildParametersPatch:
2✔
130
    """Patch for parameters of a build."""
131

132
    repository: str | None = None
2✔
133
    builder_variant: str | None = None
2✔
134
    frontend_variant: str | None = None
2✔
135
    repository_revision: str | None = None
2✔
136
    context_dir: str | None = None
2✔
137

138

139
@dataclass(eq=True, kw_only=True)
2✔
140
class EnvironmentPatch:
2✔
141
    """Model for changes requested on a session environment."""
142

143
    name: str | None = None
2✔
144
    description: str | None = None
2✔
145
    container_image: str | None = None
2✔
146
    default_url: str | None = None
2✔
147
    port: int | None = None
2✔
148
    working_directory: PurePosixPath | ResetType | None = None
2✔
149
    mount_directory: PurePosixPath | ResetType | None = None
2✔
150
    uid: int | None = None
2✔
151
    gid: int | None = None
2✔
152
    args: list[str] | ResetType | None = None
2✔
153
    command: list[str] | ResetType | None = None
2✔
154
    is_archived: bool | None = None
2✔
155
    build_parameters: BuildParametersPatch | None = None
2✔
156
    environment_image_source: EnvironmentImageSource | None = None
2✔
157
    strip_path_prefix: bool | None = None
2✔
158

159

160
# TODO: Verify that these limits are compatible with k8s
161
MAX_NUMBER_ENV_VARIABLES: typing.Final[int] = 32
2✔
162
MAX_LENGTH_ENV_VARIABLES_NAME: typing.Final[int] = 256
2✔
163
MAX_LENGTH_ENV_VARIABLES_VALUE: typing.Final[int] = 1000
2✔
164

165

166
@dataclass(frozen=True, eq=True, kw_only=True)
2✔
167
class EnvVar:
2✔
168
    """Model for an environment variable."""
169

170
    name: str
2✔
171
    value: str | None = None
2✔
172

173
    @classmethod
2✔
174
    def from_dict(cls, env_dict: dict[str, str | None]) -> list["EnvVar"]:
2✔
175
        """Create a list of EnvVar instances from a dictionary."""
176
        return [cls(name=name, value=value) for name, value in env_dict.items()]
1✔
177

178
    @classmethod
2✔
179
    def from_apispec(cls, env_variables: list["apispec.EnvVar"]) -> list["EnvVar"]:
2✔
180
        """Create a list of EnvVar instances from apispec objects."""
181
        return [cls(name=env_var.name, value=env_var.value) for env_var in env_variables]
2✔
182

183
    @classmethod
2✔
184
    def to_dict(cls, env_variables: list["EnvVar"]) -> dict[str, str | None]:
2✔
185
        """Convert to dict."""
186
        return {var.name: var.value for var in env_variables}
1✔
187

188
    def __post_init__(self) -> None:
2✔
189
        error_msgs: list[str] = []
2✔
190
        if len(self.name) > MAX_LENGTH_ENV_VARIABLES_NAME:
2✔
191
            error_msgs.append(
×
192
                f"Env variable name '{self.name}' is longer than {MAX_LENGTH_ENV_VARIABLES_NAME} characters."
193
            )
194
        if self.name.upper().startswith("RENKU"):
2✔
195
            error_msgs.append(f"Env variable name '{self.name}' should not start with 'RENKU'.")
1✔
196
        if ENV_VARIABLE_NAME_MATCHER.match(self.name) is None:
2✔
197
            error_msgs.append(f"Env variable name '{self.name}' must match the regex '{ENV_VARIABLE_REGEX}'.")
1✔
198
        if self.value and len(self.value) > MAX_LENGTH_ENV_VARIABLES_VALUE:
2✔
199
            error_msgs.append(
×
200
                f"Env variable value for '{self.name}' is longer than {MAX_LENGTH_ENV_VARIABLES_VALUE} characters."
201
            )
202

203
        if error_msgs:
2✔
204
            if len(error_msgs) == 1:
1✔
205
                raise errors.ValidationError(message=error_msgs[0])
1✔
206
            raise errors.ValidationError(message="\n".join(error_msgs))
×
207

208

209
@dataclass(frozen=True, eq=True, kw_only=True)
2✔
210
class UnsavedSessionLauncher:
2✔
211
    """Session launcher model that has not been persisted in the DB."""
212

213
    project_id: ULID
2✔
214
    name: str
2✔
215
    description: str | None
2✔
216
    resource_class_id: int | None
2✔
217
    disk_storage: int | None
2✔
218
    env_variables: list[EnvVar] | None
2✔
219
    environment: str | UnsavedEnvironment | UnsavedBuildParameters
2✔
220
    """When a string is passed for the environment it should be the ID of an existing environment."""
2✔
221

222

223
@dataclass(frozen=True, eq=True, kw_only=True)
2✔
224
class SessionLauncher(UnsavedSessionLauncher):
2✔
225
    """Session launcher model."""
226

227
    id: ULID
2✔
228
    creation_date: datetime
2✔
229
    created_by: Member
2✔
230
    environment: Environment
2✔
231

232

233
@dataclass(frozen=True, eq=True, kw_only=True)
2✔
234
class SessionLauncherPatch:
2✔
235
    """Model for changes requested on a session launcher."""
236

237
    name: str | None = None
2✔
238
    description: str | None = None
2✔
239
    # NOTE: When unsaved environment is used it means a brand-new environment should be created for the
240
    # launcher with the update of the launcher.
241
    environment: str | EnvironmentPatch | UnsavedEnvironment | UnsavedBuildParameters | None = None
2✔
242
    resource_class_id: int | None | ResetType = None
2✔
243
    disk_storage: int | None | ResetType = None
2✔
244
    env_variables: list[EnvVar] | None | ResetType = None
2✔
245

246

247
@dataclass(frozen=True, eq=True, kw_only=True)
2✔
248
class BuildResult:
2✔
249
    """Model to represent the result of a build of a container image."""
250

251
    image: str
2✔
252
    completed_at: datetime
2✔
253
    repository_url: str
2✔
254
    repository_git_commit_sha: str
2✔
255

256

257
class BuildStatus(StrEnum):
2✔
258
    """The status of a build."""
259

260
    in_progress = "in_progress"
2✔
261
    failed = "failed"
2✔
262
    cancelled = "cancelled"
2✔
263
    succeeded = "succeeded"
2✔
264

265

266
@dataclass(frozen=True, eq=True, kw_only=True)
2✔
267
class Build:
2✔
268
    """Model to represent the build of a container image."""
269

270
    id: ULID
2✔
271
    environment_id: ULID
2✔
272
    created_at: datetime
2✔
273
    status: BuildStatus
2✔
274
    result: BuildResult | None = None
2✔
275
    error_reason: str | None = None
2✔
276

277
    @property
2✔
278
    def k8s_name(self) -> str:
2✔
279
        """Returns the name of the corresponding Shipwright BuildRun."""
280
        name = f"renku-{self.id}"
×
281
        return name.lower()
×
282

283

284
@dataclass(frozen=True, eq=True, kw_only=True)
2✔
285
class UnsavedBuild:
2✔
286
    """Model to represent a requested container image build."""
287

288
    environment_id: ULID
2✔
289

290

291
@dataclass(frozen=True, eq=True, kw_only=True)
2✔
292
class BuildPatch:
2✔
293
    """Model to represent the requested update to a container image build."""
294

295
    status: BuildStatus | None = None
2✔
296

297

298
@dataclass(frozen=True, eq=True, kw_only=True)
2✔
299
class ShipwrightBuildRunParams:
2✔
300
    """Model to represent the parameters used to create a new Shipwright BuildRun."""
301

302
    name: str
2✔
303
    git_repository: str
2✔
304
    run_image: str
2✔
305
    output_image: str
2✔
306
    build_strategy_name: str
2✔
307
    push_secret_name: str
2✔
308
    retention_after_failed: timedelta | None = None
2✔
309
    retention_after_succeeded: timedelta | None = None
2✔
310
    build_timeout: timedelta | None = None
2✔
311
    node_selector: dict[str, str] | None = None
2✔
312
    tolerations: list[crs.Toleration] | None = None
2✔
313
    labels: dict[str, str] | None = None
2✔
314
    annotations: dict[str, str] | None = None
2✔
315
    frontend: str = FrontendVariant.vscodium.value
2✔
316
    build_image: str | None = None
2✔
317
    git_repository_revision: str | None = None
2✔
318
    context_dir: str | None = None
2✔
319

320

321
@dataclass(frozen=True, eq=True, kw_only=True)
2✔
322
class ShipwrightBuildStatusUpdateContent:
2✔
323
    """Model to represent an update about a build from Shipwright."""
324

325
    status: BuildStatus
2✔
326
    result: BuildResult | None = None
2✔
327
    completed_at: datetime | None = None
2✔
328
    error_reason: str | None = None
2✔
329

330

331
@dataclass(frozen=True, eq=True, kw_only=True)
2✔
332
class ShipwrightBuildStatusUpdate:
2✔
333
    """Model to represent an update about a build from Shipwright."""
334

335
    update: ShipwrightBuildStatusUpdateContent | None
2✔
336
    """The update about a build.
2✔
337

338
    None represents "no update"."""
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