Skip to content

Commit 04161d4

Browse files
SK-3118: Close flowvault coverage gaps and code-review cleanups
Address the code-review P0/P1 findings for flowvault: - Cover the remaining error-handling/edge branches to reach 100% on flowvault product code: batching .env fallback + zero/negative concurrency, response parsing None/[None] cases, get_vault_url validation + get_metrics fallbacks (new test__utils.py), full validate_bulk_insert/detokenize_request coverage, update ApiError body variants, async bulk error paths, and the bulk response __str__/empty-retry helpers. - Exclude setup.py and the `if __name__` boilerplate from coverage via .coveragerc. - requirements.txt: bump pydantic floor 1.9.2 -> 2.0.0 (setup.py and the Fern client require pydantic v2). - Remove dead GetRecordRequest import and MAX_BULK_DATA_SIZE constant; extract the repeated 'additional_headers'/'Unknown error' literals into named constants; drop explanatory comments from _validations.py. - get_vault_url: reject an invalid env with `not isinstance(env, Env)` instead of `env not in Env`, which raised TypeError (and left the guard unreachable) for a non-member on Python < 3.12. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d63741a commit 04161d4

12 files changed

Lines changed: 277 additions & 28 deletions

File tree

flowvault/.coveragerc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,9 @@
22
omit =
33
*/generated/*
44
generated/*
5+
setup.py
6+
7+
[report]
8+
exclude_lines =
9+
pragma: no cover
10+
if __name__ == .__main__.:

flowvault/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
httpx>=0.21.2
2-
pydantic>= 1.9.2
2+
pydantic>= 2.0.0
33
pydantic-core>=2.18.2
44
typing_extensions>= 4.0.0
55
coverage >= 7.8.0

flowvault/skyflow_flowvault/utils/_batching.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
from common.utils.logger import log_warn
77
from skyflow_flowvault.utils._skyflow_messages import SkyflowMessages
88

9-
MAX_BULK_DATA_SIZE = 10000
10-
119
DEFAULT_BATCH_SIZE = 50
1210
MAX_BATCH_SIZE = 1000
1311
DEFAULT_CONCURRENCY = 1

flowvault/skyflow_flowvault/utils/_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def get_vault_url(cluster_id, env, vault_id, logger=None):
1818
if not cluster_id or not isinstance(cluster_id, str) or not cluster_id.strip():
1919
raise SkyflowError(CommonMessages.Error.INVALID_CLUSTER_ID.value.format(vault_id), invalid_input_error_code)
2020

21-
if env not in Env:
21+
if not isinstance(env, Env):
2222
raise SkyflowError(CommonMessages.Error.INVALID_ENV.value.format(vault_id), invalid_input_error_code)
2323

2424
base_url = EnvUrls[env.name].value

flowvault/skyflow_flowvault/utils/validations/_validations.py

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,6 @@
1515

1616
invalid_input_error_code = CommonMessages.ErrorCodes.INVALID_INPUT.value
1717

18-
# validate_vault_config/validate_update_vault_config/validate_credentials are re-exported
19-
# directly from common.utils.validations -- flowvault's own logic here was field-for-field
20-
# identical to v2's, confirmed, so both variants now share one implementation.
21-
2218

2319
def _validate_upsert(logger, upsert):
2420
if upsert is None:
@@ -33,7 +29,7 @@ def _validate_upsert(logger, upsert):
3329
raise SkyflowError(SkyflowMessages.Error.INVALID_UPSERT_UPDATE_TYPE_IN_INSERT.value, invalid_input_error_code)
3430

3531

36-
MAX_INSERT_RECORDS = 10000 # matches Java's v3 Validations.validateInsertRequest (hardcoded, not configurable)
32+
MAX_INSERT_RECORDS = 10000
3733

3834

3935
def validate_insert_request(logger, request):
@@ -46,15 +42,10 @@ def validate_insert_request(logger, request):
4642
if len(request.records) > MAX_INSERT_RECORDS:
4743
raise SkyflowError(SkyflowMessages.Error.TOO_MANY_RECORDS_IN_INSERT.value, invalid_input_error_code)
4844

49-
# record.table format and record.data emptiness/key/value validity are checked by the controller
50-
# via BaseVaultController._validate_table_name_if_present() / _validate_field_values().
51-
5245
_validate_upsert(logger, request.upsert)
5346
for record in request.records:
5447
_validate_upsert(logger, record.upsert)
5548

56-
# table must be set in exactly one place -- request-level (every record) or per-record (no
57-
# partial mix) -- and upsert must live at that same place (mirrors Java's v3 Validations).
5849
table_at_request_level = request.table_name is not None
5950

6051
if table_at_request_level:
@@ -75,7 +66,6 @@ def validate_insert_request(logger, request):
7566

7667

7768
def validate_get_request(logger, request):
78-
# Two mutually exclusive modes: multi-table batch (request.records) vs single-table.
7969
if request.records is not None:
8070
single_table_fields_set = (
8171
request.table or request.ids or request.unique_values or request.columns

flowvault/skyflow_flowvault/vault/controller/_vault.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@
4141
InsertRequest,
4242
InsertResponse,
4343
GetRequest,
44-
GetRecordRequest,
4544
GetResponse,
4645
UpdateRequest,
4746
UpdateResponse,
@@ -60,6 +59,8 @@
6059
)
6160

6261
REQUEST_ID_HEADER = "x-request-id"
62+
ADDITIONAL_HEADERS_KEY = "additional_headers"
63+
UNKNOWN_ERROR_MESSAGE = "Unknown error"
6364

6465

6566
class VaultController(BaseVaultController):
@@ -98,7 +99,7 @@ def insert(self, request: InsertRequest) -> InsertResponse:
9899
vault_id=self._vault_client.get_vault_id(),
99100
table_name=request.table_name,
100101
records=wire_records,
101-
request_options={'additional_headers': headers},
102+
request_options={ADDITIONAL_HEADERS_KEY: headers},
102103
**upsert_kwargs,
103104
)
104105
records = [self.__record_row(record, include_data=False) for record in (raw_response.data.records or [])]
@@ -137,7 +138,7 @@ def get(self, request: GetRequest) -> GetResponse:
137138
log_info(SkyflowMessages.Info.GET_TRIGGERED.value, self._vault_client.get_logger())
138139
raw_response = records_api.with_raw_response.get_records(
139140
vault_id=self._vault_client.get_vault_id(),
140-
request_options={'additional_headers': self.__build_headers()},
141+
request_options={ADDITIONAL_HEADERS_KEY: self.__build_headers()},
141142
**call_kwargs,
142143
)
143144
records = [self.__record_row(record, include_data=True) for record in (raw_response.data.records or [])]
@@ -174,7 +175,7 @@ def update(self, request: UpdateRequest) -> UpdateResponse:
174175
vault_id=self._vault_client.get_vault_id(),
175176
table_name=request.table_name,
176177
records=wire_records,
177-
request_options={'additional_headers': self.__build_headers()},
178+
request_options={ADDITIONAL_HEADERS_KEY: self.__build_headers()},
178179
)
179180
request_id = self.__extract_request_id(raw_response.headers)
180181
records, errors = self.__split_success_and_errors(
@@ -204,7 +205,7 @@ def delete(self, request: DeleteRequest) -> DeleteResponse:
204205
table_name=request.table,
205206
skyflow_i_ds=request.ids,
206207
unique_values=self.__to_unique_values(request.unique_values),
207-
request_options={'additional_headers': self.__build_headers()},
208+
request_options={ADDITIONAL_HEADERS_KEY: self.__build_headers()},
208209
)
209210
records = [self.__delete_row(record) for record in (raw_response.data.records or [])]
210211
except Exception as e:
@@ -227,7 +228,7 @@ def query(self, request: QueryRequest) -> QueryResponse:
227228
raw_response = query_api.with_raw_response.execute_query(
228229
vault_id=self._vault_client.get_vault_id(),
229230
query=request.query,
230-
request_options={'additional_headers': self.__build_headers()},
231+
request_options={ADDITIONAL_HEADERS_KEY: self.__build_headers()},
231232
)
232233
records = [{'data': getattr(record, 'data', None)} for record in (raw_response.data.records or [])]
233234
metadata = self.__query_metadata(raw_response.data)
@@ -253,7 +254,7 @@ def detokenize(self, request: DetokenizeRequest) -> DetokenizeResponse:
253254
vault_id=self._vault_client.get_vault_id(),
254255
tokens=request.tokens,
255256
token_group_redactions=self.__to_token_group_redactions(request.token_group_redactions),
256-
request_options={'additional_headers': self.__build_headers()},
257+
request_options={ADDITIONAL_HEADERS_KEY: self.__build_headers()},
257258
)
258259
records = [self.__detokenize_row(resp) for resp in (raw_response.data.response or [])]
259260
except Exception as e:
@@ -275,7 +276,7 @@ def call_batch(batch, start_index):
275276
vault_id=self._vault_client.get_vault_id(),
276277
table_name=request.table,
277278
records=batch,
278-
request_options={'additional_headers': self.__build_headers()},
279+
request_options={ADDITIONAL_HEADERS_KEY: self.__build_headers()},
279280
**top_kwargs,
280281
)
281282
return self.__format_bulk_insert_batch(raw_response.data.records or [], start_index, raw_response.headers)
@@ -299,7 +300,7 @@ async def call_batch(batch, start_index):
299300
vault_id=self._vault_client.get_vault_id(),
300301
table_name=request.table,
301302
records=batch,
302-
request_options={'additional_headers': self.__build_headers()},
303+
request_options={ADDITIONAL_HEADERS_KEY: self.__build_headers()},
303304
**top_kwargs,
304305
)
305306
return self.__format_bulk_insert_batch(raw_response.data.records or [], start_index, raw_response.headers)
@@ -323,7 +324,7 @@ def call_batch(batch, start_index):
323324
vault_id=self._vault_client.get_vault_id(),
324325
tokens=batch,
325326
token_group_redactions=redactions,
326-
request_options={'additional_headers': self.__build_headers()},
327+
request_options={ADDITIONAL_HEADERS_KEY: self.__build_headers()},
327328
)
328329
return self.__format_bulk_detokenize_batch(raw_response.data.response or [], start_index, raw_response.headers)
329330
except Exception as e:
@@ -346,7 +347,7 @@ async def call_batch(batch, start_index):
346347
vault_id=self._vault_client.get_vault_id(),
347348
tokens=batch,
348349
token_group_redactions=redactions,
349-
request_options={'additional_headers': self.__build_headers()},
350+
request_options={ADDITIONAL_HEADERS_KEY: self.__build_headers()},
350351
)
351352
return self.__format_bulk_detokenize_batch(raw_response.data.response or [], start_index, raw_response.headers)
352353
except Exception as e:
@@ -476,7 +477,7 @@ def __bulk_batch_error_tuples(self, e, count, start_index):
476477
if body and isinstance(body.get('records'), list) and body['records']:
477478
tuples = [
478479
(start_index + offset, request_id,
479-
record.get('error', record.get('message', 'Unknown error')),
480+
record.get('error', record.get('message', UNKNOWN_ERROR_MESSAGE)),
480481
record.get('http_code', record.get('httpCode', record.get('statusCode', status))))
481482
for offset, record in enumerate(body['records']) if isinstance(record, dict)
482483
]
@@ -710,5 +711,5 @@ def __errors_from_exception(self, e, records, start_index):
710711

711712
def __error_dict_from_record_map(self, record_map, request_index, request_id):
712713
code = record_map.get('http_code', record_map.get('httpCode', record_map.get('statusCode')))
713-
message = record_map.get('error', record_map.get('message', 'Unknown error'))
714+
message = record_map.get('error', record_map.get('message', UNKNOWN_ERROR_MESSAGE))
714715
return {'request_index': request_index, 'error': message, 'code': code, 'request_id': request_id}

flowvault/tests/utils/test__batching.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,29 @@ def test_invalid_concurrency_falls_back(self):
6363
_, concurrency = resolve_batch_config(INSERT_BATCH_SIZE_KEY, INSERT_CONCURRENCY_LIMIT_KEY, 500)
6464
self.assertEqual(concurrency, 1)
6565

66+
def test_zero_or_negative_concurrency_falls_back(self):
67+
for raw in ("0", "-3"):
68+
with _with_settings({INSERT_CONCURRENCY_LIMIT_KEY: raw}):
69+
_, concurrency = resolve_batch_config(INSERT_BATCH_SIZE_KEY, INSERT_CONCURRENCY_LIMIT_KEY, 500)
70+
self.assertEqual(concurrency, 1)
71+
6672

6773
class TestResolveSetting(unittest.TestCase):
6874
def test_reads_process_env_first(self):
6975
with patch.dict(os.environ, {INSERT_BATCH_SIZE_KEY: "77"}):
7076
self.assertEqual(_batching._resolve_setting(INSERT_BATCH_SIZE_KEY), "77")
7177

78+
def test_falls_back_to_dotenv_when_env_absent(self):
79+
with patch.dict(os.environ, {}, clear=True), \
80+
patch.object(_batching, "find_dotenv", return_value="/tmp/.env"), \
81+
patch.object(_batching, "dotenv_values", return_value={INSERT_BATCH_SIZE_KEY: "88"}):
82+
self.assertEqual(_batching._resolve_setting(INSERT_BATCH_SIZE_KEY), "88")
83+
84+
def test_dotenv_lookup_error_returns_none(self):
85+
with patch.dict(os.environ, {}, clear=True), \
86+
patch.object(_batching, "find_dotenv", side_effect=Exception("boom")):
87+
self.assertIsNone(_batching._resolve_setting(INSERT_BATCH_SIZE_KEY))
88+
7289

7390
if __name__ == "__main__":
7491
unittest.main()

flowvault/tests/utils/test__response_parsing.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ def test_bare_value_becomes_a_token(self):
2929
def test_none_returns_none(self):
3030
self.assertIsNone(parse_tokens(None))
3131

32+
def test_none_column_value_is_skipped(self):
33+
self.assertEqual(parse_tokens({"ssn": None}), {})
34+
35+
def test_none_entry_in_list_is_dropped(self):
36+
self.assertEqual(parse_tokens({"ssn": [None]}), {"ssn": []})
37+
3238

3339
class TestParseHashedData(unittest.TestCase):
3440
def test_list_of_hash_entries(self):
@@ -44,6 +50,9 @@ def test_bare_value_wrapped(self):
4450
def test_none_returns_none(self):
4551
self.assertIsNone(parse_hashed_data(None))
4652

53+
def test_none_entry_in_list_is_dropped(self):
54+
self.assertEqual(parse_hashed_data({"ssn": [None]}), {"ssn": []})
55+
4756

4857
class TestParseMetadata(unittest.TestCase):
4958
def test_reads_both_casings(self):
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import unittest
2+
from unittest.mock import patch
3+
4+
from common.errors import SkyflowError
5+
from common.utils.enums import Env
6+
from common.utils.constants import SdkMetricsKey
7+
from skyflow_flowvault.utils import _utils
8+
from skyflow_flowvault.utils._utils import get_vault_url, get_metrics
9+
10+
11+
class TestGetVaultUrl(unittest.TestCase):
12+
def test_valid(self):
13+
url = get_vault_url("cluster123", Env.PROD, "v1")
14+
self.assertTrue(url.startswith("https://cluster123."))
15+
16+
def test_invalid_cluster_id_raises(self):
17+
for bad in ("", " ", None, 123):
18+
with self.assertRaises(SkyflowError):
19+
get_vault_url(bad, Env.PROD, "v1")
20+
21+
def test_invalid_env_raises(self):
22+
with self.assertRaises(SkyflowError):
23+
get_vault_url("cluster123", "not-an-env", "v1")
24+
25+
26+
class _SysPlatformRaises:
27+
version = "v"
28+
29+
@property
30+
def platform(self):
31+
raise RuntimeError("boom")
32+
33+
34+
class _SysVersionRaises:
35+
platform = "linux"
36+
37+
@property
38+
def version(self):
39+
raise RuntimeError("boom")
40+
41+
42+
class TestGetMetrics(unittest.TestCase):
43+
def setUp(self):
44+
_utils._CACHED_METRICS.clear()
45+
46+
def tearDown(self):
47+
_utils._CACHED_METRICS.clear()
48+
49+
def test_caches_after_first_call(self):
50+
first = get_metrics()
51+
self.assertIs(get_metrics(), first)
52+
53+
def test_device_model_error_falls_back(self):
54+
with patch.object(_utils.platform, "node", side_effect=RuntimeError("boom")):
55+
metrics = get_metrics()
56+
self.assertEqual(metrics[SdkMetricsKey.SDK_CLIENT_DEVICE_MODEL], "")
57+
58+
def test_os_details_error_falls_back(self):
59+
with patch.object(_utils, "sys", _SysPlatformRaises()):
60+
metrics = get_metrics()
61+
self.assertEqual(metrics[SdkMetricsKey.SDK_CLIENT_OS_DETAILS], "")
62+
63+
def test_runtime_error_falls_back(self):
64+
with patch.object(_utils, "sys", _SysVersionRaises()):
65+
metrics = get_metrics()
66+
self.assertEqual(metrics[SdkMetricsKey.SDK_RUNTIME_DETAILS], _utils.SdkPrefix.PYTHON_RUNTIME + "")
67+
68+
69+
if __name__ == "__main__":
70+
unittest.main()

0 commit comments

Comments
 (0)