Skip to content

Commit ee076aa

Browse files
randy-sengclaude
authored andcommitted
OP#10322: Read the prefix from the project, not the environment
The providers now list under the prefix the loaded project declares, and S3_KEY_PREFIX is gone from the environment. An instance is no longer pinned to one prefix for its lifetime: opening another project moves where it reads, within the one bucket it may authenticate to. With no project loaded there is no prefix and nothing to list, so the time-range dialog is refused and says to load a project first. That is a behaviour change to slice 6, which offered the dialog at startup, and matches how the worker runs: a lease starts when an otconfig is delivered. The acceptance test preloads a project otconfig instead of setting S3_KEY_PREFIX, which is the same change seen from the outside. ADR 0003's variable list is marked as the list at the time of that ADR rather than rewritten: the decision to configure S3 through the environment stands, only the prefix left it.
1 parent ba06523 commit ee076aa

11 files changed

Lines changed: 127 additions & 41 deletions

File tree

‎OTAnalytics/plugin_s3/config/env_vars.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
ENV_S3_SECRET_KEY = "OTA_S3_SECRET_KEY" # nosec B105 - variable name, not a secret
1212
ENV_S3_BUCKET = "OTA_S3_BUCKET"
1313
ENV_S3_REGION = "OTA_S3_REGION"
14-
ENV_S3_KEY_PREFIX = "OTA_S3_KEY_PREFIX" # gitleaks:allow - variable name, not a secret
1514
ENV_S3_USER_SOURCE = "OTA_S3_USER_SOURCE"
1615
ENV_S3_MAX_LOAD_DURATION = "OTA_S3_MAX_LOAD_DURATION"
1716
ENV_S3_DOWNLOAD_CONCURRENCY = "OTA_S3_DOWNLOAD_CONCURRENCY"
@@ -41,7 +40,6 @@ class S3Env:
4140
secret_key: str | None = field(default_factory=_make_env_reader(ENV_S3_SECRET_KEY))
4241
bucket: str | None = field(default_factory=_make_env_reader(ENV_S3_BUCKET))
4342
region: str | None = field(default_factory=_make_env_reader(ENV_S3_REGION))
44-
key_prefix: str | None = field(default_factory=_make_env_reader(ENV_S3_KEY_PREFIX))
4543
user_source: str | None = field(
4644
default_factory=_make_env_reader(ENV_S3_USER_SOURCE)
4745
)

‎OTAnalytics/plugin_s3/config/parsing.py‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
ENV_S3_ACCESS_KEY,
99
ENV_S3_BUCKET,
1010
ENV_S3_ENDPOINT_URL,
11-
ENV_S3_KEY_PREFIX,
1211
ENV_S3_SECRET_KEY,
1312
ENV_S3_USER_SOURCE,
1413
S3Env,
@@ -91,7 +90,6 @@ def parse_s3_config(env: S3Env) -> S3Config:
9190
(ENV_S3_ACCESS_KEY, env.access_key),
9291
(ENV_S3_SECRET_KEY, env.secret_key),
9392
(ENV_S3_BUCKET, env.bucket),
94-
(ENV_S3_KEY_PREFIX, env.key_prefix),
9593
(ENV_S3_USER_SOURCE, env.user_source),
9694
)
9795
if missing := [name for name, value in required if value is None]:
@@ -103,7 +101,6 @@ def parse_s3_config(env: S3Env) -> S3Config:
103101
secret_key=_required(env.secret_key),
104102
bucket=_required(env.bucket),
105103
region=env.region,
106-
key_prefix=_required(env.key_prefix),
107104
user_source=_required(env.user_source),
108105
max_load_duration=_parse_max_load_duration(env),
109106
download_concurrency=_parse_download_concurrency(env),

‎OTAnalytics/plugin_s3/config/s3.py‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ class S3Config:
1515
secret_key (str): S3 secret key for authentication.
1616
bucket (str): S3 bucket name.
1717
region (str | None): AWS region (None if not applicable).
18-
key_prefix (str): prefix under which this instance's tracks and videos
19-
live. Fixed at startup; the user selects a time range, not a prefix.
2018
user_source (str): base path on the local filesystem that downloaded
2119
objects are written to, mirroring their S3 keys. Named after
2220
OTCloud's `S3_USER_SOURCE` for cross-repo consistency.
@@ -30,7 +28,6 @@ class S3Config:
3028
secret_key: str
3129
bucket: str
3230
region: str | None
33-
key_prefix: str
3431
user_source: str
3532
max_load_duration: timedelta = DEFAULT_MAX_LOAD_DURATION
3633
download_concurrency: int = DEFAULT_DOWNLOAD_CONCURRENCY

‎OTAnalytics/plugin_s3/s3_file_providers.py‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010

1111
import ijson
1212

13+
from OTAnalytics.application.key_prefix import S3KeyPrefix
1314
from OTAnalytics.application.logger import logger
15+
from OTAnalytics.application.state import CurrentKeyPrefix
1416
from OTAnalytics.application.use_cases.ask_for_load_window import AskForLoadWindow
1517
from OTAnalytics.application.use_cases.provide_input_files import (
1618
ProvideTrackFiles,
@@ -35,6 +37,10 @@
3537
DOWNLOADING_VIDEOS = "Downloading videos"
3638

3739
CANCELLED = "Loading from S3 cancelled. Nothing was loaded."
40+
NO_PROJECT = (
41+
"This installation reads from S3, where a project says which data is its"
42+
" own. Load a project first, then select a time range."
43+
)
3844

3945

4046
class MissingVideoForTrackFile(Exception):
@@ -104,6 +110,9 @@ class _S3Provider:
104110
list_objects (S3ListObjects): lists the bucket under the key prefix.
105111
download_objects (DownloadObjects): downloads the selected objects.
106112
config (S3Config): the S3 settings fixed at startup.
113+
current_key_prefix (CurrentKeyPrefix): where the loaded project's data
114+
lives. Read per load rather than at startup, because opening another
115+
project moves it.
107116
"""
108117

109118
def __init__(
@@ -112,11 +121,13 @@ def __init__(
112121
list_objects: S3ListObjects,
113122
download_objects: DownloadObjects,
114123
config: S3Config,
124+
current_key_prefix: CurrentKeyPrefix,
115125
) -> None:
116126
self._dialog = dialog
117127
self._list_objects = list_objects
118128
self._download_objects = download_objects
119129
self._config = config
130+
self._current_key_prefix = current_key_prefix
120131

121132
async def _provide(
122133
self, title: str, suffixes: set[str], description: str
@@ -131,13 +142,19 @@ async def _provide(
131142
Returns:
132143
list[Path]: the local paths, empty if nothing was loaded.
133144
"""
134-
selected = await self._dialog.ask(title, self._source())
145+
key_prefix = self._current_key_prefix.get()
146+
if key_prefix is None:
147+
# The prefix belongs to the project, so before one is loaded there
148+
# is nothing to list and no window worth asking for.
149+
self._dialog.report_error(NO_PROJECT)
150+
return []
151+
selected = await self._dialog.ask(title, self._source(key_prefix))
135152
if selected is None:
136153
return []
137154
window = selected.clamp(self._config.max_load_duration)
138155
if window.was_clamped:
139156
self._dialog.report_clamped(window)
140-
keys = await self._list_objects.list_keys(self._config.key_prefix)
157+
keys = await self._list_objects.list_keys(key_prefix.value)
141158
wanted = select_in_window(keys, window, suffixes)
142159
if not wanted:
143160
logger().info(f"Nothing to load between {window.start} and {window.end}")
@@ -157,7 +174,7 @@ async def _provide(
157174
# browser. The traceback still goes to the log.
158175
logger().exception(cause, exc_info=True)
159176
self._dialog.report_error(
160-
f"Could not load from '{self._source()}': {cause}."
177+
f"Could not load from '{self._source(key_prefix)}': {cause}."
161178
" Nothing was loaded."
162179
)
163180
return []
@@ -168,9 +185,9 @@ async def _load(
168185
"""Download what was selected. Overridden to fetch companions too."""
169186
return await self._download_objects.download_all(wanted, description)
170187

171-
def _source(self) -> str:
188+
def _source(self, key_prefix: S3KeyPrefix) -> str:
172189
"""Where files come from, for the user to see but not to change."""
173-
return f"{self._config.bucket}/{self._config.key_prefix}"
190+
return f"{self._config.bucket}/{key_prefix}"
174191

175192

176193
class S3TrackFileProvider(_S3Provider, ProvideTrackFiles):
@@ -181,6 +198,7 @@ class S3TrackFileProvider(_S3Provider, ProvideTrackFiles):
181198
list_objects (S3ListObjects): lists the bucket under the key prefix.
182199
download_objects (DownloadObjects): downloads the selected objects.
183200
config (S3Config): the S3 settings fixed at startup.
201+
current_key_prefix (CurrentKeyPrefix): where the loaded project's data lives.
184202
read_video_name (Callable[[Path], str]): reads a track file's video name.
185203
"""
186204

@@ -190,9 +208,12 @@ def __init__(
190208
list_objects: S3ListObjects,
191209
download_objects: DownloadObjects,
192210
config: S3Config,
211+
current_key_prefix: CurrentKeyPrefix,
193212
read_video_name: Callable[[Path], str] = read_video_name,
194213
) -> None:
195-
super().__init__(dialog, list_objects, download_objects, config)
214+
super().__init__(
215+
dialog, list_objects, download_objects, config, current_key_prefix
216+
)
196217
self._read_video_name = read_video_name
197218

198219
async def provide(self) -> list[Path]:

‎OTAnalytics/plugin_ui/nicegui_application.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ def provide_track_files(self) -> ProvideTrackFiles:
306306
list_objects=self.s3_list_objects,
307307
download_objects=self.download_objects,
308308
config=config,
309+
current_key_prefix=self.current_key_prefix,
309310
)
310311
return LocalTrackFileProvider(self.ui_factory)
311312

@@ -317,6 +318,7 @@ def provide_video_files(self) -> ProvideVideoFiles:
317318
list_objects=self.s3_list_objects,
318319
download_objects=self.download_objects,
319320
config=config,
321+
current_key_prefix=self.current_key_prefix,
320322
)
321323
return LocalVideoFileProvider(self.ui_factory)
322324

‎tests/acceptance/test_load_from_s3.py‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Acceptance: in s3 mode, adding tracks asks for a time range, not a file."""
22

33
import io
4+
import json
45
import os
56
import subprocess
67
import sys
@@ -22,7 +23,6 @@
2223
ENV_S3_ACCESS_KEY,
2324
ENV_S3_BUCKET,
2425
ENV_S3_ENDPOINT_URL,
25-
ENV_S3_KEY_PREFIX,
2626
ENV_S3_MAX_LOAD_DURATION,
2727
ENV_S3_SECRET_KEY,
2828
ENV_S3_USER_SOURCE,
@@ -76,12 +76,49 @@ def rustfs() -> Iterator[dict]:
7676
}
7777

7878

79+
def _project_otconfig(directory: Path) -> Path:
80+
"""An otconfig declaring where this project's data lives.
81+
82+
In s3 mode the prefix comes from the project, not the environment (ADR
83+
0004), so the application has to be given a project before it can list
84+
anything at all.
85+
"""
86+
directory.mkdir(parents=True, exist_ok=True)
87+
file = directory / "project.otconfig"
88+
file.write_text(
89+
json.dumps(
90+
{
91+
"project": {"name": "Acceptance", "start_date": 1684886400},
92+
"s3_key_prefix": PREFIX,
93+
"videos": [],
94+
"analysis": {
95+
"do_events": True,
96+
"do_counting": True,
97+
"tracks": [],
98+
"export": {
99+
"save_name": "acceptance",
100+
"save_suffix": "",
101+
"event_formats": ["csv"],
102+
"count_intervals": [15],
103+
},
104+
"num_processes": 1,
105+
"logfile": "logs",
106+
},
107+
"sections": [],
108+
"flows": [],
109+
}
110+
)
111+
)
112+
return file
113+
114+
79115
@pytest.fixture
80116
def s3_app(rustfs: dict, tmp_path: Path) -> Iterator[Any]:
81117
"""The real application, started in s3 mode against RustFS.
82118
83119
Configuration is environment only, so pointing the documented variables at a
84-
throwaway RustFS exercises the production path with no test-only hooks.
120+
throwaway RustFS exercises the production path with no test-only hooks. The
121+
project is preloaded from a file, which is where the key prefix comes from.
85122
"""
86123
environment = dict(os.environ)
87124
environment.update(
@@ -91,7 +128,6 @@ def s3_app(rustfs: dict, tmp_path: Path) -> Iterator[Any]:
91128
ENV_S3_ACCESS_KEY: rustfs["access_key"],
92129
ENV_S3_SECRET_KEY: rustfs["secret_key"],
93130
ENV_S3_BUCKET: BUCKET,
94-
ENV_S3_KEY_PREFIX: PREFIX,
95131
ENV_S3_USER_SOURCE: str(tmp_path / "user-source"),
96132
ENV_S3_MAX_LOAD_DURATION: "20m",
97133
}
@@ -102,6 +138,8 @@ def s3_app(rustfs: dict, tmp_path: Path) -> Iterator[Any]:
102138
"-m",
103139
"OTAnalytics",
104140
"--webui",
141+
"--config",
142+
str(_project_otconfig(tmp_path / "project")),
105143
"--file-picker-directory",
106144
file_picker_directory(),
107145
],

‎tests/integration/OTAnalytics/plugin_s3/test_load_from_rustfs.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
import pytest
1919

20+
from OTAnalytics.application.key_prefix import S3KeyPrefix
21+
from OTAnalytics.application.state import CurrentKeyPrefix
2022
from OTAnalytics.domain.load_window import LoadWindow
2123
from OTAnalytics.plugin_s3.config.s3 import S3Config
2224
from OTAnalytics.plugin_s3.download import S3Download
@@ -101,6 +103,7 @@ class Given:
101103
config: S3Config
102104
dialog: Mock
103105
user_source: Path
106+
current_key_prefix: CurrentKeyPrefix
104107

105108

106109
def create_given(rustfs: dict, tmp_path: Path, hours: float = 1) -> Given:
@@ -111,7 +114,6 @@ def create_given(rustfs: dict, tmp_path: Path, hours: float = 1) -> Given:
111114
secret_key=rustfs["secret_key"],
112115
bucket=BUCKET,
113116
region=None,
114-
key_prefix=PREFIX,
115117
user_source=str(user_source),
116118
max_load_duration=MAX_LOAD_DURATION,
117119
download_concurrency=4,
@@ -120,7 +122,14 @@ def create_given(rustfs: dict, tmp_path: Path, hours: float = 1) -> Given:
120122
dialog.ask = AsyncMock(
121123
return_value=LoadWindow(start=START, end=START + timedelta(hours=hours))
122124
)
123-
return Given(config=config, dialog=dialog, user_source=user_source)
125+
current_key_prefix = CurrentKeyPrefix()
126+
current_key_prefix.set(S3KeyPrefix(PREFIX))
127+
return Given(
128+
config=config,
129+
dialog=dialog,
130+
user_source=user_source,
131+
current_key_prefix=current_key_prefix,
132+
)
124133

125134

126135
def _download_objects(given: Given) -> DownloadObjects:
@@ -138,6 +147,7 @@ def create_track_target(given: Given) -> S3TrackFileProvider:
138147
list_objects=S3ListObjects(S3Store(given.config)),
139148
download_objects=_download_objects(given),
140149
config=given.config,
150+
current_key_prefix=given.current_key_prefix,
141151
)
142152

143153

@@ -147,6 +157,7 @@ def create_video_target(given: Given) -> S3VideoFileProvider:
147157
list_objects=S3ListObjects(S3Store(given.config)),
148158
download_objects=_download_objects(given),
149159
config=given.config,
160+
current_key_prefix=given.current_key_prefix,
150161
)
151162

152163

‎tests/unit/OTAnalytics/plugin_s3/config/test_parsing.py‎

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
ENV_S3_BUCKET,
1010
ENV_S3_DOWNLOAD_CONCURRENCY,
1111
ENV_S3_ENDPOINT_URL,
12-
ENV_S3_KEY_PREFIX,
1312
ENV_S3_MAX_LOAD_DURATION,
1413
ENV_S3_REGION,
1514
ENV_S3_SECRET_KEY,
@@ -32,7 +31,6 @@ def env(**overrides: str | None) -> S3Env:
3231
"secret_key": None,
3332
"bucket": None,
3433
"region": None,
35-
"key_prefix": None,
3634
"user_source": None,
3735
"max_load_duration": None,
3836
"download_concurrency": None,
@@ -48,7 +46,6 @@ def complete_env(**overrides: str | None) -> S3Env:
4846
"access_key": "minioadmin",
4947
"secret_key": "minioadmin", # gitleaks:allow
5048
"bucket": "otcloud",
51-
"key_prefix": "6-1145/site/OTCamera19/",
5249
"user_source": "/data/otanalytics-source",
5350
}
5451
required.update(overrides)
@@ -93,7 +90,6 @@ def test_parse_every_variable(self) -> None:
9390
assert actual.access_key == "minioadmin"
9491
assert actual.bucket == "otcloud"
9592
assert actual.region == "us-east-1"
96-
assert actual.key_prefix == "6-1145/site/OTCamera19/"
9793
assert actual.user_source == "/data/otanalytics-source"
9894
assert actual.max_load_duration == timedelta(hours=2)
9995
assert actual.download_concurrency == 16
@@ -109,15 +105,6 @@ def test_optional_variables_default(self) -> None:
109105
assert actual.max_load_duration == timedelta(hours=10)
110106
assert actual.download_concurrency == 8
111107

112-
def test_empty_key_prefix_is_kept(self) -> None:
113-
"""`S3_KEY_PREFIX=""` means the bucket root, not "unset".
114-
115-
# Requirement OP#10256
116-
"""
117-
actual = parse_s3_config(complete_env(key_prefix=""))
118-
119-
assert actual.key_prefix == ""
120-
121108
def test_malformed_duration_is_rejected(self) -> None:
122109
with pytest.raises(InvalidDurationError):
123110
parse_s3_config(complete_env(max_load_duration="10"))
@@ -138,7 +125,6 @@ def test_report_every_missing_variable_at_once(self) -> None:
138125
ENV_S3_ENDPOINT_URL,
139126
ENV_S3_ACCESS_KEY,
140127
ENV_S3_SECRET_KEY,
141-
ENV_S3_KEY_PREFIX,
142128
ENV_S3_USER_SOURCE,
143129
]
144130

@@ -162,7 +148,6 @@ def test_only_absent_variables_are_reported(self) -> None:
162148
assert excinfo.value.missing == [
163149
ENV_S3_ENDPOINT_URL,
164150
ENV_S3_SECRET_KEY,
165-
ENV_S3_KEY_PREFIX,
166151
]
167152

168153
def test_message_names_the_environment_variables(self) -> None:
@@ -189,7 +174,6 @@ def test_unset_variables_are_none(self, monkeypatch: pytest.MonkeyPatch) -> None
189174
ENV_S3_SECRET_KEY,
190175
ENV_S3_BUCKET,
191176
ENV_S3_REGION,
192-
ENV_S3_KEY_PREFIX,
193177
ENV_S3_USER_SOURCE,
194178
ENV_S3_MAX_LOAD_DURATION,
195179
ENV_S3_DOWNLOAD_CONCURRENCY,

0 commit comments

Comments
 (0)