Skip to content

Commit 4dd5ab6

Browse files
committed
Migrate dump_config_cmd to external API server. Improve error handling in api client
1 parent 357059b commit 4dd5ab6

6 files changed

Lines changed: 168 additions & 82 deletions

File tree

docs/api-protocol.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,40 @@ from context.
193193

194194
---
195195

196+
#### `workspace/getProjectRawConfig`
197+
198+
Return the fully resolved raw configuration for a project, as stored in the
199+
workspace context after config reading and preset resolution.
200+
201+
- **Type:** request
202+
- **Clients:** CLI
203+
- **Status:** implemented
204+
205+
**Params:**
206+
207+
```json
208+
{"project": "my_project"}
209+
```
210+
211+
**Result:**
212+
213+
```json
214+
{
215+
"raw_config": {
216+
"tool": { "finecode": { ... } },
217+
...
218+
}
219+
}
220+
```
221+
222+
**Errors:**
223+
224+
- `project` is required — returns a JSON-RPC error if omitted.
225+
- Project not found — returns a JSON-RPC error if no project with the given name
226+
exists in the workspace context.
227+
228+
---
229+
196230
### `actions/` — Action Discovery & Execution
197231

198232
#### `actions/list`

src/finecode/api_client.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,26 @@
1919
CONTENT_LENGTH_HEADER = "Content-Length: "
2020

2121

22+
class ApiError(Exception):
23+
"""Base class for API client errors."""
24+
25+
26+
class ApiServerError(ApiError):
27+
"""Server returned a JSON-RPC error response."""
28+
29+
def __init__(self, code: int, message: str) -> None:
30+
self.code = code
31+
super().__init__(f"API error ({code}): {message}")
32+
33+
34+
class ApiResponseError(ApiError):
35+
"""Server returned an unexpected or malformed response."""
36+
37+
def __init__(self, method: str, detail: str) -> None:
38+
self.method = method
39+
super().__init__(f"Unexpected response for '{method}': {detail}")
40+
41+
2242
async def _read_message(reader: asyncio.StreamReader) -> dict | None:
2343
"""Read one Content-Length framed JSON-RPC message. Returns None on EOF."""
2444
header_line = await reader.readline()
@@ -43,6 +63,11 @@ class ApiClient:
4363
After connect(), a background reader loop dispatches incoming messages:
4464
- Responses (with ``id``) resolve the matching pending request future.
4565
- Notifications (without ``id``) are dispatched to registered callbacks.
66+
67+
Errors:
68+
- ``ApiServerError``: the server returned a JSON-RPC error.
69+
- ``ApiResponseError``: the server response was missing an expected field.
70+
- ``ConnectionError``: the connection was lost.
4671
"""
4772

4873
def __init__(self) -> None:
@@ -110,14 +135,34 @@ async def find_project_for_file(self, file_path: str) -> str | None:
110135
"workspace/findProjectForFile", {"file_path": file_path}
111136
)
112137
# server returns {"project": name | None}
138+
if not isinstance(result, dict):
139+
raise ApiResponseError(
140+
"workspace/findProjectForFile", f"expected dict, got {type(result).__name__}"
141+
)
113142
return result.get("project")
114143

144+
async def get_project_raw_config(self, project: str) -> dict:
145+
"""Return the resolved raw config for a project by name."""
146+
result = await self.request(
147+
"workspace/getProjectRawConfig", {"project": project}
148+
)
149+
if not isinstance(result, dict) or "raw_config" not in result:
150+
raise ApiResponseError(
151+
"workspace/getProjectRawConfig",
152+
f"missing 'raw_config' field, got {result!r}",
153+
)
154+
return result["raw_config"]
155+
115156
async def list_actions(self, project: str | None = None) -> list[dict]:
116157
"""List available actions, optionally filtered by project name."""
117158
params: dict = {}
118159
if project is not None:
119160
params["project"] = project
120161
result = await self.request("actions/list", params)
162+
if not isinstance(result, dict) or "actions" not in result:
163+
raise ApiResponseError(
164+
"actions/list", f"missing 'actions' field, got {result!r}"
165+
)
121166
return result["actions"]
122167

123168
async def get_tree(self, parent_node_id: str | None = None) -> dict:
@@ -248,7 +293,12 @@ def _send_notification(self, method: str, params: dict | None = None) -> None:
248293
# -- Low-level request --------------------------------------------------
249294

250295
async def request(self, method: str, params: dict | None = None) -> dict:
251-
"""Send a JSON-RPC request and wait for the response."""
296+
"""Send a JSON-RPC request and wait for the response.
297+
298+
Raises:
299+
ApiServerError: the server returned a JSON-RPC error.
300+
ConnectionError: the connection was closed before a response arrived.
301+
"""
252302
if self._writer is None:
253303
raise RuntimeError("Not connected to FineCode API server")
254304

@@ -273,7 +323,7 @@ async def request(self, method: str, params: dict | None = None) -> dict:
273323

274324
if "error" in response:
275325
error = response["error"]
276-
raise RuntimeError(f"API error ({error['code']}): {error['message']}")
326+
raise ApiServerError(error["code"], error["message"])
277327

278328
return response.get("result")
279329

src/finecode/api_server/api_server.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,27 @@ async def _handle_list_projects(
181181
return [_project_to_dict(p) for p in ws_context.ws_projects.values()]
182182

183183

184+
async def _handle_get_project_raw_config(
185+
params: dict | None, ws_context: context.WorkspaceContext
186+
) -> dict:
187+
"""Return the resolved raw config for a project by name.
188+
189+
Params: ``{"project": "project_name"}``
190+
Result: ``{"raw_config": {...}}``
191+
"""
192+
params = params or {}
193+
project_name = params.get("project")
194+
if not project_name:
195+
raise ValueError("project parameter is required")
196+
197+
for project_dir_path, project in ws_context.ws_projects.items():
198+
if project.name == project_name:
199+
raw_config = ws_context.ws_projects_raw_configs.get(project_dir_path, {})
200+
return _NoConvert({"raw_config": raw_config})
201+
202+
raise ValueError(f"Project '{project_name}' not found")
203+
204+
184205
async def _handle_find_project_for_file(
185206
params: dict, ws_context: context.WorkspaceContext
186207
) -> dict:
@@ -792,6 +813,7 @@ async def _handle_run_with_partial_results_task(
792813
"workspace/addDir": _handle_add_dir,
793814
"workspace/removeDir": _handle_remove_dir,
794815
"workspace/setConfigOverrides": _handle_set_config_overrides,
816+
"workspace/getProjectRawConfig": _handle_get_project_raw_config,
795817
# actions/
796818
"actions/list": _handle_list_actions,
797819
"actions/getTree": _handle_get_tree,

src/finecode/cli.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,8 @@ def prepare_envs(trace: bool, debug: bool, recreate: bool) -> None:
423423
@click.option("--trace", "trace", is_flag=True, default=False)
424424
@click.option("--debug", "debug", is_flag=True, default=False)
425425
@click.option("--project", "project", type=str)
426-
def dump_config(trace: bool, debug: bool, project: str | None):
426+
@click.option("--shared-server", "shared_server", is_flag=True, default=False)
427+
def dump_config(trace: bool, debug: bool, project: str | None, shared_server: bool):
427428
if debug is True:
428429
import debugpy
429430

@@ -443,7 +444,9 @@ def dump_config(trace: bool, debug: bool, project: str | None):
443444
try:
444445
asyncio.run(
445446
dump_config_cmd.dump_config(
446-
workdir_path=pathlib.Path(os.getcwd()), project_name=project
447+
workdir_path=pathlib.Path(os.getcwd()),
448+
project_name=project,
449+
own_server=not shared_server,
447450
)
448451
)
449452
except dump_config_cmd.DumpFailed as exception:
Lines changed: 51 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,91 +1,67 @@
11
import pathlib
22

3-
from loguru import logger
4-
5-
from finecode.api_server import context
6-
from finecode.api_server.services import run_service, shutdown_service
7-
from finecode.api_server.config import config_models, read_configs
8-
from finecode.api_server.runner import runner_manager
3+
from finecode.api_client import ApiClient, ApiError
4+
from finecode.api_server import api_server
95

106

117
class DumpFailed(Exception):
128
def __init__(self, message: str) -> None:
139
self.message = message
1410

1511

16-
async def dump_config(workdir_path: pathlib.Path, project_name: str):
17-
ws_context = context.WorkspaceContext([workdir_path])
18-
# it could be optimized by looking for concrete project instead of all
19-
await read_configs.read_projects_in_dir(
20-
dir_path=workdir_path, ws_context=ws_context
21-
)
22-
23-
# project is provided. Filter out other projects if there are more, they would
24-
# not be used (run can be started in a workspace with also other projects)
25-
ws_context.ws_projects = {
26-
project_dir_path: project
27-
for project_dir_path, project in ws_context.ws_projects.items()
28-
if project.name == project_name
29-
}
30-
31-
# read configs without presets, this is required to be able to start runners in
32-
# the next step
33-
for project in ws_context.ws_projects.values():
34-
try:
35-
await read_configs.read_project_config(
36-
project=project, ws_context=ws_context, resolve_presets=False
37-
)
38-
except config_models.ConfigurationError as exception:
39-
raise DumpFailed(
40-
f"Reading project configs(without presets) in {project.dir_path} failed: {exception.message}"
41-
) from exception
42-
43-
# Some tools like IDE extensions for syntax highlighting rely on
44-
# file name. Keep file name of config the same and save in subdirectory
45-
project_dir_path = list(ws_context.ws_projects.keys())[0]
46-
dump_dir_path = project_dir_path / "finecode_config_dump"
47-
dump_file_path = dump_dir_path / "pyproject.toml"
48-
project_def = ws_context.ws_projects[project_dir_path]
49-
actions_by_projects = {project_dir_path: ["dump_config"]}
50-
51-
# start runner to init project config
12+
async def dump_config(
13+
workdir_path: pathlib.Path, project_name: str, own_server: bool = True
14+
):
15+
port_file = None
5216
try:
53-
# reread projects configs, now with resolved presets
54-
# to be able to resolve presets, start runners with presets first
55-
try:
56-
await runner_manager.start_runners_with_presets(
57-
projects=[project_def], ws_context=ws_context
58-
)
59-
except runner_manager.RunnerFailedToStart as exception:
60-
raise DumpFailed(
61-
f"Starting runners with presets failed: {exception.message}"
62-
) from exception
17+
if own_server:
18+
port_file = api_server.start_own_server(workdir_path)
19+
try:
20+
port = await api_server.wait_until_ready_from_file(port_file)
21+
except TimeoutError as exc:
22+
raise DumpFailed(str(exc)) from exc
23+
else:
24+
api_server.ensure_running(workdir_path)
25+
try:
26+
port = await api_server.wait_until_ready()
27+
except TimeoutError as exc:
28+
raise DumpFailed(str(exc)) from exc
6329

30+
client = ApiClient()
31+
await client.connect("127.0.0.1", port)
6432
try:
65-
await run_service.start_required_environments(
66-
actions_by_projects, ws_context
33+
result = await client.add_dir(workdir_path)
34+
projects = result.get("projects", [])
35+
project = next(
36+
(p for p in projects if p["name"] == project_name), None
6737
)
68-
except run_service.StartingEnvironmentsFailed as exception:
69-
raise DumpFailed(
70-
f"Failed to start environments for running 'dump_config': {exception.message}"
71-
) from exception
38+
if project is None:
39+
raise DumpFailed(f"Project '{project_name}' not found")
7240

73-
project_raw_config = ws_context.ws_projects_raw_configs[project_dir_path]
41+
project_dir_path = pathlib.Path(project["path"])
42+
source_file_path = project_dir_path / "pyproject.toml"
43+
target_file_path = project_dir_path / "finecode_config_dump" / "pyproject.toml"
7444

75-
await run_service.run_action(
76-
action_name="dump_config",
77-
params={
78-
"source_file_path": project_def.def_path,
79-
"project_raw_config": project_raw_config,
80-
"target_file_path": dump_file_path,
81-
},
82-
project_def=project_def,
83-
ws_context=ws_context,
84-
result_formats=[run_service.RunResultFormat.STRING],
85-
preprocess_payload=False,
86-
run_trigger=run_service.RunActionTrigger.USER,
87-
dev_env=run_service.DevEnv.CLI,
88-
)
89-
logger.info(f"Dumped config into {dump_file_path}")
45+
try:
46+
project_raw_config = await client.get_project_raw_config(project_name)
47+
await client.run_action(
48+
action="dump_config",
49+
project=project_name,
50+
params={
51+
"source_file_path": str(source_file_path),
52+
"project_raw_config": project_raw_config,
53+
"target_file_path": str(target_file_path),
54+
},
55+
options={
56+
"result_formats": ["string"],
57+
"trigger": "user",
58+
"dev_env": "cli",
59+
},
60+
)
61+
except ApiError as exc:
62+
raise DumpFailed(str(exc)) from exc
63+
finally:
64+
await client.close()
9065
finally:
91-
shutdown_service.on_shutdown(ws_context)
66+
if port_file is not None and port_file.exists():
67+
port_file.unlink(missing_ok=True)

src/finecode/cli_app/commands/run_cmd.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import click
77

8-
from finecode.api_client import ApiClient
8+
from finecode.api_client import ApiClient, ApiError
99
from finecode.api_server import api_server
1010
from finecode.api_server.runner import runner_client
1111
from finecode.cli_app import utils
@@ -53,7 +53,8 @@ async def run_actions(
5353
"Warning: --config overrides are ignored in --shared-server mode. ",
5454
err=True,
5555
)
56-
56+
# TODO: could it be optimized: if projects are provided, parse only them?
57+
# the same also in other CLI commands
5758
await client.add_dir(workdir_path)
5859

5960
params_by_project: dict[str, dict[str, typing.Any]] = {}
@@ -78,7 +79,7 @@ async def run_actions(
7879
"dev_env": "cli",
7980
},
8081
)
81-
except RuntimeError as exc:
82+
except ApiError as exc:
8283
raise RunFailed(str(exc)) from exc
8384

8485
return _build_run_result(batch_result)

0 commit comments

Comments
 (0)