Skip to content

Commit 80b8f93

Browse files
authored
Merge pull request #2 from finecode-dev/feature/cli
CLI for running actions
2 parents 439891b + 20b7297 commit 80b8f93

54 files changed

Lines changed: 3016 additions & 556 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,31 @@ presets = [
4343

4444
1.5 For integration with VSCode, install [FineCode extension](https://github.com/finecode-dev/finecode-vscode)
4545

46+
## CLI
47+
48+
In virtualenv of your project you can use the following command:
49+
50+
`python -m finecode run [run_options] <list_of_actions> [actions_payload]`
51+
52+
Available run options:
53+
54+
- `--workdir="<path>"` ... use provided directory as work directory
55+
- `--project="<name>"` ... run actions only in this project. Multiple projects can be selected by providing multiple `--project="<name>"` options
56+
- `--concurrently` ... run actions concurrently. Single projects are always handled concurrently, this option determines whether actions inside of single project are run concurrently or not
57+
- `--trace` ... activate trace(more detailed) logging
58+
59+
If no projects are provided via options, FineCode will interpret working directory as workspace root, find all projects in it and run provided actions in all projects, in which they exist.
60+
61+
If projects are provided, actions are expected to exist in all of them.
62+
63+
Actions payload: if actions require payload or you want to run them with payload other than configured, you can provide it after names of actions.
64+
65+
Examples:
66+
67+
- `python -m finecode run --concurrently lint check_formatting` ... run `lint` and `check_formatting` actions concurrently in all projects in the workspace, root of which is in current working directory
68+
- `python -m finecode --workdir="./finecode_extension_api" run lint check_formatting` ... run `lint` and `check_formatting` sequentially in `finecode_extension_api` directory (project is there)
69+
- `python -m finecode --project="fine_python_mypy" --project="fine_python_ruff" run lint` ... run `lint` action in projects `fine_python_mypy` and `fine_python_ruff`. They should be discoverable from the working directory.
70+
4671
## Extensions from FineCode authors
4772

4873
### Presets
@@ -66,3 +91,20 @@ presets = [
6691
IDE
6792

6893
TODO: list all from LSP
94+
95+
## Workspace with multiple subprojects
96+
97+
### Reusing config
98+
99+
To reuse configuration in multiple subprojects, put it in a separate package in your workspace and add it as dev dependency in all subprojects in which you want to use it.
100+
101+
Design decision: there are multiple ways to achieve the same result:
102+
103+
- separate package
104+
- configuration of subprojects doesn't depend on file structure of the workspace. Subproject can be moved in another place or even outside of workspace and this will not affect its configuration, only if path to package with common configuration was file path, it should be changed.
105+
- fully transparent: the full configuration is known in a subproject without searching workspace root and analyzing the workspace
106+
- hierarchical configuration
107+
- makes subprojects more dependent on workspace, in case of moving subproject, additional actions with configurations are needed to keep it the same
108+
- letting to define reusable part on workspace level and provide it automatically to all subprojects
109+
- not transparent, part of configuration is implicit
110+
- FineCode needs to check whether it was started in workspace or in subproject, go deeper in file tree and find workspace root to resolve all configurations

finecode/__main__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from finecode.workspace_manager import cli as wm_cli
2+
3+
if __name__ == "__main__":
4+
wm_cli.cli()

finecode/extension_runner/bootstrap.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
icache,
3232
icommandrunner,
3333
ifilemanager,
34-
ilogger
34+
ilogger,
3535
)
3636

3737
container: dict[str, Any] = {}

finecode/extension_runner/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
import click
55
from loguru import logger
66

7-
from finecode.extension_runner import global_state
87
import finecode.extension_runner.start as runner_start
8+
from finecode.extension_runner import global_state
99

1010

1111
@click.command()

finecode/extension_runner/impls/process_executor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
import contextlib
55
import functools
66
import multiprocessing as mp
7-
import typing
87
import sys
8+
import typing
99

1010
from loguru import logger
1111

@@ -37,7 +37,7 @@ async def submit[T, **P](
3737
raise Exception("Process Executor is not activated")
3838

3939
if self._py_process_executor is None:
40-
if sys.version_info < (3, 14, 0) and sys.platform == 'linux':
40+
if sys.version_info < (3, 14, 0) and sys.platform == "linux":
4141
# forkserver is default on linux in Python 3.14+, use the same also
4242
# with older versions
4343
mp_context = mp.get_context("forkserver")

finecode/extension_runner/lsp_server.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,11 @@ async def run_action(ls: lsp_server.LanguageServer, params):
194194
# dict key can be path, but pygls fails to handle slashes in dict keys, use strings
195195
# representation of result instead until the problem is properly solved
196196
result_str = json.dumps(response.to_dict()["result"])
197-
return {"result": result_str}
197+
return {
198+
"result": result_str,
199+
"format": response.format,
200+
"return_code": response.return_code,
201+
}
198202

199203

200204
async def reload_action(ls: lsp_server.LanguageServer, params):

finecode/extension_runner/schemas.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from dataclasses import asdict, dataclass
22
from pathlib import Path
3-
from typing import Any
3+
from typing import Any, Literal
44

55

66
@dataclass
@@ -44,9 +44,12 @@ class RunActionRequest(BaseSchema):
4444
@dataclass
4545
class RunActionOptions(BaseSchema):
4646
partial_result_token: int | str | None = None
47+
result_format: Literal["json"] | Literal["string"] = "json"
4748

4849

4950
@dataclass
5051
class RunActionResponse(BaseSchema):
52+
return_code: int
5153
# result can be empty(=None) e.g. if it was sent as a list of partial results
5254
result: dict[str, Any] | None
55+
format: Literal["json"] | Literal["string"] | Literal["styled_text_json"] = "json"

finecode/extension_runner/services.py

Lines changed: 57 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
partial_result_sender as partial_result_sender_module,
1717
)
1818
from finecode.extension_runner import project_dirs, run_utils, schemas
19-
from finecode_extension_api import code_action
19+
from finecode_extension_api import code_action, textstyler
2020

2121

2222
class ActionFailedException(Exception): ...
@@ -169,7 +169,7 @@ async def run_subresult_coros_concurrently(
169169
partial_result_token: int | str,
170170
partial_result_sender: partial_result_sender_module.PartialResultSender,
171171
action_name: str,
172-
run_id: int
172+
run_id: int,
173173
) -> code_action.RunActionResult | None:
174174
coros_tasks: list[asyncio.Task] = []
175175
try:
@@ -178,10 +178,12 @@ async def run_subresult_coros_concurrently(
178178
coro_task = tg.create_task(coro)
179179
coros_tasks.append(coro_task)
180180
except ExceptionGroup as eg:
181-
logger.error(f'R{run_id} | {eg}')
181+
logger.error(f"R{run_id} | {eg}")
182182
for exc in eg.exceptions:
183183
logger.exception(exc)
184-
raise ActionFailedException(f"Concurrent running action handlers of '{action_name}' failed(Run {run_id}). See logs for more details")
184+
raise ActionFailedException(
185+
f"Concurrent running action handlers of '{action_name}' failed(Run {run_id}). See logs for more details"
186+
)
185187

186188
action_subresult: code_action.RunActionResult | None = None
187189
for coro_task in coros_tasks:
@@ -207,15 +209,17 @@ async def run_subresult_coros_sequentially(
207209
partial_result_token: int | str,
208210
partial_result_sender: partial_result_sender_module.PartialResultSender,
209211
action_name: str,
210-
run_id: int
212+
run_id: int,
211213
) -> code_action.RunActionResult | None:
212214
action_subresult: code_action.RunActionResult | None = None
213215
for coro in coros:
214216
try:
215217
coro_result = await coro
216218
except Exception as e:
217219
logger.exception(e)
218-
raise ActionFailedException(f"Running action handlers of '{action_name}' failed(Run {run_id}): {e}")
220+
raise ActionFailedException(
221+
f"Running action handlers of '{action_name}' failed(Run {run_id}): {e}"
222+
)
219223

220224
if coro_result is not None:
221225
if action_subresult is None:
@@ -245,7 +249,9 @@ async def run_action(
245249
# TODO: check whether config is set: this will be solved by passing initial
246250
# configuration as payload of initialize
247251
if global_state.runner_context is None:
248-
raise ActionFailedException(f"Run of action failed because extension runner is not initialized yet")
252+
raise ActionFailedException(
253+
"Run of action failed because extension runner is not initialized yet"
254+
)
249255

250256
start_time = time.time_ns()
251257
project_def = global_state.runner_context.project
@@ -254,7 +260,9 @@ async def run_action(
254260
action = project_def.actions[request.action_name]
255261
except KeyError:
256262
logger.error(f"R{run_id} | Action {request.action_name} not found")
257-
raise ActionFailedException(f"R{run_id} | Action {request.action_name} not found")
263+
raise ActionFailedException(
264+
f"R{run_id} | Action {request.action_name} not found"
265+
)
258266

259267
# design decisions:
260268
# - keep payload unchanged between all subaction runs.
@@ -341,20 +349,18 @@ async def run_action(
341349
try:
342350
async with asyncio.TaskGroup() as tg:
343351
for part in parts:
344-
part_coros = run_context.partial_result_scheduler.coroutines_by_key[
345-
part
346-
]
347-
del run_context.partial_result_scheduler.coroutines_by_key[
348-
part
349-
]
352+
part_coros = (
353+
run_context.partial_result_scheduler.coroutines_by_key[part]
354+
)
355+
del run_context.partial_result_scheduler.coroutines_by_key[part]
350356
if execute_handlers_concurrently:
351357
coro = run_subresult_coros_concurrently(
352358
part_coros,
353359
send_partial_results,
354360
partial_result_token,
355361
partial_result_sender,
356362
action.name,
357-
run_id
363+
run_id,
358364
)
359365
else:
360366
coro = run_subresult_coros_sequentially(
@@ -363,15 +369,18 @@ async def run_action(
363369
partial_result_token,
364370
partial_result_sender,
365371
action.name,
366-
run_id
372+
run_id,
367373
)
368374
subresult_task = tg.create_task(coro)
369375
subresults_tasks.append(subresult_task)
370376
except ExceptionGroup as eg:
371377
logger.error(eg)
372378
for exc in eg.exceptions:
373379
logger.exception(exc)
374-
raise ActionFailedException(f"Running action handlers of '{action.name}' failed(Run {run_id}). See ER logs for more details")
380+
raise ActionFailedException(
381+
f"Running action handlers of '{action.name}' failed(Run {run_id})."
382+
" See ER logs for more details"
383+
)
375384

376385
if send_partial_results:
377386
# all subresults are ready
@@ -408,7 +417,10 @@ async def run_action(
408417
logger.error(eg)
409418
for exc in eg.exceptions:
410419
logger.exception(exc)
411-
raise ActionFailedException(f"Running action handlers of '{action.name}' failed(Run {run_id}). See ER logs for more details")
420+
raise ActionFailedException(
421+
f"Running action handlers of '{action.name}' failed"
422+
f"(Run {run_id}). See ER logs for more details"
423+
)
412424

413425
for handler_task in handlers_tasks:
414426
coro_result = handler_task.result()
@@ -435,9 +447,26 @@ async def run_action(
435447
else:
436448
action_result.update(handler_result)
437449

438-
result_dict = None
450+
serialized_result: dict[str, Any] | str | None = None
451+
result_format = "string"
452+
run_return_code = code_action.RunReturnCode.SUCCESS
439453
if isinstance(action_result, code_action.RunActionResult):
440-
result_dict = action_result.model_dump(mode="json")
454+
run_return_code = action_result.return_code
455+
if options.result_format == "json":
456+
serialized_result = action_result.model_dump(mode="json")
457+
result_format = "json"
458+
elif options.result_format == "string":
459+
result_text = action_result.to_text()
460+
if isinstance(result_text, textstyler.StyledText):
461+
serialized_result = result_text.to_json()
462+
result_format = "styled_text_json"
463+
else:
464+
serialized_result = result_text
465+
result_format = "string"
466+
else:
467+
raise ActionFailedException(
468+
f"Unsupported result format: {options.result_format}"
469+
)
441470
elif action_result is not None:
442471
logger.error(
443472
f"R{run_id} | Unexpected result type: {type(action_result).__name__}"
@@ -451,7 +480,11 @@ async def run_action(
451480
logger.trace(
452481
f"R{run_id} | Run action end '{request.action_name}', duration: {duration}ms"
453482
)
454-
return schemas.RunActionResponse(result=result_dict)
483+
return schemas.RunActionResponse(
484+
result=serialized_result,
485+
format=result_format,
486+
return_code=run_return_code.value,
487+
)
455488

456489

457490
async def execute_action_handler(
@@ -570,7 +603,9 @@ def get_run_context(param_type):
570603
execution_result = call_result
571604
except Exception as e:
572605
logger.exception(e)
573-
raise ActionFailedException(f"Running action handler '{handler.name}' failed(Run {run_id}): {e}")
606+
raise ActionFailedException(
607+
f"Running action handler '{handler.name}' failed(Run {run_id}): {e}"
608+
)
574609

575610
end_time = time.time_ns()
576611
duration = (end_time - start_time) / 1_000_000

finecode/pygls_server_utils.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,7 @@ def deserialize_pygls_object(pygls_object) -> dict[str, Any] | list[Any]:
8282
deserialized = []
8383
for index in range(len(pygls_object)):
8484
item = getattr(pygls_object, f"_{index}")
85-
if (
86-
hasattr(item, "__module__")
87-
and item.__module__ == "pygls.protocol"
88-
):
85+
if hasattr(item, "__module__") and item.__module__ == "pygls.protocol":
8986
deserialized_value = deserialize_pygls_object(item)
9087
else:
9188
deserialized_value = item

0 commit comments

Comments
 (0)