Skip to content

Commit 071afa3

Browse files
MaorDavidzonclaude
andcommitted
Address review: atomic cache, dead code, deprecated filter, tests
- Atomic cache write (temp file + rename) prevents corrupt cache if process is killed mid-write - Skip endpoints marked deprecated:true in OpenAPI spec - Remove dead snake_case redundancy check - Add 25 unit tests for pure functions (path naming, prefix detection, spec parsing, deprecated flag handling) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d1951f8 commit 071afa3

File tree

5 files changed

+190
-5
lines changed

5 files changed

+190
-5
lines changed

cycode/cli/apps/api/api_command.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,8 @@ def build_api_command_groups(
136136

137137
group = click.Group(name=tag_name, help=f'[EXPERIMENT] Cycode API: {tag}')
138138

139-
# Compute common prefix from all GET endpoint paths in this tag
140-
get_endpoints = [ep for ep in endpoints if ep['method'] == 'get']
139+
# Compute common prefix from all GET (non-deprecated) endpoint paths in this tag
140+
get_endpoints = [ep for ep in endpoints if ep['method'] == 'get' and not ep.get('deprecated')]
141141
if not get_endpoints:
142142
continue
143143

@@ -152,7 +152,7 @@ def build_api_command_groups(
152152

153153
# Fix redundancy: if command name matches the tag name, use list/view
154154
# e.g. "cycode groups groups" -> "cycode groups list"
155-
if cmd_name == tag_name or cmd_name == tag_name.replace('-', '_'):
155+
if cmd_name == tag_name:
156156
cmd_name = 'view' if has_path_params else 'list'
157157

158158
# Handle duplicate names (e.g. deprecated + new endpoint for same resource)

cycode/cli/apps/api/openapi_spec.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,12 @@ def _fetch_and_cache_spec(client_id: Optional[str] = None, client_secret: Option
145145

146146

147147
def _cache_spec(spec: dict) -> None:
148-
"""Write spec to local cache file."""
148+
"""Write spec to local cache file atomically (write to temp file, then rename)."""
149149
try:
150150
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
151-
_CACHE_FILE.write_text(json.dumps(spec), encoding='utf-8')
151+
tmp_file = _CACHE_FILE.with_suffix('.json.tmp')
152+
tmp_file.write_text(json.dumps(spec), encoding='utf-8')
153+
tmp_file.replace(_CACHE_FILE) # atomic on POSIX and Windows
152154
logger.debug('Cached OpenAPI spec to %s', _CACHE_FILE)
153155
except Exception as e:
154156
logger.warning('Failed to cache OpenAPI spec: %s', e)
@@ -189,6 +191,7 @@ def parse_spec_commands(spec: dict) -> dict[str, list[dict]]:
189191
'operation_id': details.get('operationId', ''),
190192
'path_params': path_params,
191193
'query_params': query_params,
194+
'deprecated': details.get('deprecated', False),
192195
}
193196

194197
if tag not in groups:

tests/cli/apps/api/__init__.py

Whitespace-only changes.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Tests for the OpenAPI-to-Click translator."""
2+
3+
from cycode.cli.apps.api.api_command import (
4+
_find_common_prefix,
5+
_normalize_tag,
6+
_param_to_option_name,
7+
_path_to_command_name,
8+
)
9+
10+
# --- _normalize_tag ---
11+
12+
13+
def test_normalize_tag_simple() -> None:
14+
assert _normalize_tag('Projects') == 'projects'
15+
16+
17+
def test_normalize_tag_multi_word() -> None:
18+
assert _normalize_tag('Scan Statistics') == 'scan-statistics'
19+
20+
21+
def test_normalize_tag_with_special_chars() -> None:
22+
assert _normalize_tag('CLI scan statistics') == 'cli-scan-statistics'
23+
24+
25+
def test_normalize_tag_strips_leading_trailing_separators() -> None:
26+
assert _normalize_tag(' Projects ') == 'projects'
27+
28+
29+
# --- _param_to_option_name ---
30+
31+
32+
def test_param_to_option_name_snake_case() -> None:
33+
assert _param_to_option_name('page_size') == '--page-size'
34+
35+
36+
def test_param_to_option_name_camel_case() -> None:
37+
assert _param_to_option_name('pageSize') == '--page-size'
38+
39+
40+
def test_param_to_option_name_with_dot() -> None:
41+
assert _param_to_option_name('filter.status') == '--filter-status'
42+
43+
44+
def test_param_to_option_name_already_kebab() -> None:
45+
assert _param_to_option_name('page-size') == '--page-size'
46+
47+
48+
# --- _find_common_prefix ---
49+
50+
51+
def test_find_common_prefix_empty() -> None:
52+
assert _find_common_prefix([]) == ''
53+
54+
55+
def test_find_common_prefix_single_path() -> None:
56+
# Single path: use parent directory as prefix
57+
assert _find_common_prefix(['/v4/projects']) == '/v4'
58+
59+
60+
def test_find_common_prefix_two_paths_with_common_parent() -> None:
61+
assert _find_common_prefix(['/v4/projects', '/v4/projects/assets']) == '/v4/projects'
62+
63+
64+
def test_find_common_prefix_two_paths_with_grandparent() -> None:
65+
assert _find_common_prefix(['/v4/projects', '/v4/members']) == '/v4'
66+
67+
68+
def test_find_common_prefix_identical_paths() -> None:
69+
assert _find_common_prefix(['/v4/projects', '/v4/projects']) == '/v4/projects'
70+
71+
72+
# --- _path_to_command_name ---
73+
74+
75+
def test_path_to_command_name_collection() -> None:
76+
# /v4/projects with prefix /v4/projects -> nothing left -> 'list'
77+
assert _path_to_command_name('/v4/projects', '/v4/projects', has_path_params=False) == 'list'
78+
79+
80+
def test_path_to_command_name_single_resource() -> None:
81+
# /v4/projects/{id} with prefix /v4/projects -> only path param left -> 'view'
82+
assert _path_to_command_name('/v4/projects/{projectId}', '/v4/projects', has_path_params=True) == 'view'
83+
84+
85+
def test_path_to_command_name_sub_resource() -> None:
86+
# /v4/projects/assets with prefix /v4/projects -> 'assets'
87+
assert _path_to_command_name('/v4/projects/assets', '/v4/projects', has_path_params=False) == 'assets'
88+
89+
90+
def test_path_to_command_name_sub_resource_count() -> None:
91+
# /v4/violations/count with prefix /v4/violations -> 'count'
92+
assert _path_to_command_name('/v4/violations/count', '/v4/violations', has_path_params=False) == 'count'
93+
94+
95+
def test_path_to_command_name_multi_segment() -> None:
96+
# /v4/projects/collisions/count with prefix /v4/projects -> 'collisions-count'
97+
assert (
98+
_path_to_command_name('/v4/projects/collisions/count', '/v4/projects', has_path_params=False)
99+
== 'collisions-count'
100+
)
101+
102+
103+
def test_path_to_command_name_with_path_param_in_middle() -> None:
104+
# /v4/workflows/{id}/jobs with prefix /v4/workflows -> 'jobs' (path param stripped)
105+
assert _path_to_command_name('/v4/workflows/{workflowId}/jobs', '/v4/workflows', has_path_params=True) == 'jobs'
106+
107+
108+
def test_path_to_command_name_kebab_case_normalization() -> None:
109+
# Path with underscores or special chars -> kebab-case
110+
assert _path_to_command_name('/v4/brokers/broker_metrics', '/v4/brokers', has_path_params=False) == 'broker-metrics'
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Tests for the OpenAPI spec parser."""
2+
3+
from cycode.cli.apps.api.openapi_spec import parse_spec_commands
4+
5+
6+
def test_parse_spec_commands_groups_by_tag() -> None:
7+
spec = {
8+
'paths': {
9+
'/v4/projects': {
10+
'get': {'tags': ['Projects'], 'summary': 'Get projects'},
11+
},
12+
'/v4/violations': {
13+
'get': {'tags': ['Violations'], 'summary': 'Get violations'},
14+
},
15+
}
16+
}
17+
groups = parse_spec_commands(spec)
18+
assert set(groups.keys()) == {'Projects', 'Violations'}
19+
20+
21+
def test_parse_spec_commands_extracts_path_params() -> None:
22+
spec = {
23+
'paths': {
24+
'/v4/projects/{projectId}': {
25+
'get': {
26+
'tags': ['Projects'],
27+
'parameters': [
28+
{'name': 'projectId', 'in': 'path', 'required': True},
29+
{'name': 'page_size', 'in': 'query', 'required': False},
30+
],
31+
},
32+
},
33+
}
34+
}
35+
groups = parse_spec_commands(spec)
36+
ep = groups['Projects'][0]
37+
assert len(ep['path_params']) == 1
38+
assert ep['path_params'][0]['name'] == 'projectId'
39+
assert len(ep['query_params']) == 1
40+
assert ep['query_params'][0]['name'] == 'page_size'
41+
42+
43+
def test_parse_spec_commands_captures_deprecated_flag() -> None:
44+
spec = {
45+
'paths': {
46+
'/v4/old': {
47+
'get': {'tags': ['T'], 'summary': 'old', 'deprecated': True},
48+
},
49+
'/v4/new': {
50+
'get': {'tags': ['T'], 'summary': 'new'},
51+
},
52+
}
53+
}
54+
groups = parse_spec_commands(spec)
55+
by_path = {ep['path']: ep for ep in groups['T']}
56+
assert by_path['/v4/old']['deprecated'] is True
57+
assert by_path['/v4/new']['deprecated'] is False
58+
59+
60+
def test_parse_spec_commands_no_tags_uses_other() -> None:
61+
spec = {
62+
'paths': {
63+
'/v4/foo': {'get': {}},
64+
}
65+
}
66+
groups = parse_spec_commands(spec)
67+
assert 'other' in groups
68+
69+
70+
def test_parse_spec_commands_empty_spec() -> None:
71+
assert parse_spec_commands({}) == {}
72+
assert parse_spec_commands({'paths': {}}) == {}

0 commit comments

Comments
 (0)