intuned-runtime 1.3.0rc0__py3-none-any.whl → 1.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. intuned_cli/__init__.py +15 -24
  2. intuned_cli/commands/__init__.py +6 -1
  3. intuned_cli/commands/attempt_api_command.py +8 -0
  4. intuned_cli/commands/attempt_authsession_check_command.py +8 -0
  5. intuned_cli/commands/attempt_authsession_command.py +4 -4
  6. intuned_cli/commands/attempt_authsession_create_command.py +9 -1
  7. intuned_cli/commands/attempt_command.py +4 -4
  8. intuned_cli/commands/authsession_command.py +12 -0
  9. intuned_cli/commands/authsession_record_command.py +54 -0
  10. intuned_cli/commands/command.py +6 -4
  11. intuned_cli/commands/deploy_command.py +2 -0
  12. intuned_cli/commands/init_command.py +2 -0
  13. intuned_cli/commands/run_api_command.py +9 -1
  14. intuned_cli/commands/run_authsession_command.py +4 -4
  15. intuned_cli/commands/run_authsession_create_command.py +34 -4
  16. intuned_cli/commands/run_authsession_update_command.py +33 -4
  17. intuned_cli/commands/run_authsession_validate_command.py +32 -3
  18. intuned_cli/commands/run_command.py +4 -4
  19. intuned_cli/commands/save_command.py +2 -0
  20. intuned_cli/controller/__test__/test_api.py +159 -18
  21. intuned_cli/controller/__test__/test_authsession.py +497 -6
  22. intuned_cli/controller/api.py +40 -39
  23. intuned_cli/controller/authsession.py +213 -110
  24. intuned_cli/controller/deploy.py +3 -3
  25. intuned_cli/controller/save.py +47 -48
  26. intuned_cli/types.py +14 -0
  27. intuned_cli/utils/__test__/test_browser.py +132 -0
  28. intuned_cli/utils/__test__/test_traces.py +27 -0
  29. intuned_cli/utils/api_helpers.py +54 -5
  30. intuned_cli/utils/auth_session_helpers.py +42 -7
  31. intuned_cli/utils/backend.py +4 -1
  32. intuned_cli/utils/browser.py +63 -0
  33. intuned_cli/utils/error.py +14 -0
  34. intuned_cli/utils/exclusions.py +1 -0
  35. intuned_cli/utils/help.py +9 -0
  36. intuned_cli/utils/traces.py +31 -0
  37. intuned_cli/utils/wrapper.py +58 -0
  38. intuned_internal_cli/__init__.py +7 -0
  39. {intuned_runtime-1.3.0rc0.dist-info → intuned_runtime-1.3.2.dist-info}/METADATA +4 -2
  40. {intuned_runtime-1.3.0rc0.dist-info → intuned_runtime-1.3.2.dist-info}/RECORD +45 -37
  41. runtime/browser/launch_chromium.py +19 -8
  42. runtime/types/settings_types.py +13 -4
  43. {intuned_runtime-1.3.0rc0.dist-info → intuned_runtime-1.3.2.dist-info}/WHEEL +0 -0
  44. {intuned_runtime-1.3.0rc0.dist-info → intuned_runtime-1.3.2.dist-info}/entry_points.txt +0 -0
  45. {intuned_runtime-1.3.0rc0.dist-info → intuned_runtime-1.3.2.dist-info}/licenses/LICENSE +0 -0
@@ -30,7 +30,7 @@ async def check_deploy_status(
30
30
  api_key: str,
31
31
  ):
32
32
  base_url = get_base_url()
33
- url = f"{base_url}/api/v1/workspace/{workspace_id}/projects/create/{project_name}/result"
33
+ url = f"{base_url}/api/v1/workspace/{workspace_id}/projects/{project_name}/deploy/result"
34
34
 
35
35
  headers = {
36
36
  "x-api-key": api_key,
@@ -124,7 +124,7 @@ async def deploy_project(
124
124
  if update_console_task:
125
125
  update_console_task.cancel()
126
126
  if console.is_terminal:
127
- print("\r", " " * 100)
127
+ print("\r", " " * 100, file=console.file)
128
128
  console.print("[green][bold]Project deployed successfully![/bold][/green]")
129
129
  console.print(
130
130
  f"[bold]You can check your project on the platform:[/bold] [cyan underline]{get_base_url()}/projects/{project_name}/details[/cyan underline]"
@@ -143,7 +143,7 @@ async def deploy_project(
143
143
  )
144
144
  except Exception:
145
145
  if console.is_terminal:
146
- print("\r", " " * 100)
146
+ print("\r", " " * 100, file=console.file)
147
147
  raise
148
148
  finally:
149
149
  if update_console_task:
@@ -16,6 +16,7 @@ from intuned_cli.types import DirectoryNode
16
16
  from intuned_cli.types import FileNode
17
17
  from intuned_cli.types import FileNodeContent
18
18
  from intuned_cli.types import FileSystemTree
19
+ from intuned_cli.utils.api_helpers import get_intuned_settings_file
19
20
  from intuned_cli.utils.api_helpers import load_intuned_json
20
21
  from intuned_cli.utils.backend import get_base_url
21
22
  from intuned_cli.utils.console import console
@@ -123,70 +124,69 @@ async def get_file_tree_from_project(path: Path, *, exclude: list[str] | None =
123
124
  return results
124
125
 
125
126
 
126
- def mapFileTreeToIdeFileTree(file_tree: FileSystemTree):
127
+ async def map_file_tree_to_ide_file_tree(file_tree: FileSystemTree):
127
128
  """
128
129
  Maps the file tree to IDE parameters format by processing parameters directory
129
130
  and converting it to ____testParameters structure.
130
131
  """
131
132
 
132
- if not file_tree:
133
- return
134
-
135
133
  parameters_node = file_tree.root.get("parameters")
136
- if parameters_node is None:
137
- return
134
+ if isinstance(parameters_node, DirectoryNode):
135
+ api_parameters_map: dict[str, list[dict[str, Any]]] = {}
136
+ cli_parameters = list(parameters_node.directory.root.keys())
137
+ test_parameters = DirectoryNode(directory=FileSystemTree(root={}))
138
138
 
139
- if not isinstance(parameters_node, DirectoryNode):
140
- return
141
-
142
- api_parameters_map: dict[str, list[dict[str, Any]]] = {}
143
- cli_parameters = list(parameters_node.directory.root.keys())
144
- test_parameters = DirectoryNode(directory=FileSystemTree(root={}))
139
+ for parameter_key in cli_parameters:
140
+ # If parameter of type directory, discard it and continue
141
+ parameter = parameters_node.directory.root[parameter_key]
145
142
 
146
- for parameter_key in cli_parameters:
147
- # If parameter of type directory, discard it and continue
148
- parameter = parameters_node.directory.root[parameter_key]
143
+ if isinstance(parameter, DirectoryNode):
144
+ continue
149
145
 
150
- if isinstance(parameter, DirectoryNode):
151
- continue
146
+ if not parameter.file.contents.strip():
147
+ continue
152
148
 
153
- if not parameter.file.contents.strip():
154
- continue
149
+ try:
150
+ parameter_payload = json.loads(parameter.file.contents)
151
+ except json.JSONDecodeError:
152
+ continue
155
153
 
156
- try:
157
- parameter_payload = json.loads(parameter.file.contents)
158
- except json.JSONDecodeError:
159
- continue
154
+ if "__api-name" not in parameter_payload:
155
+ continue
160
156
 
161
- if "__api-name" not in parameter_payload:
162
- continue
157
+ api = parameter_payload["__api-name"]
158
+ # Create parameter value by excluding __api-name
159
+ parameter_value = {k: v for k, v in parameter_payload.items() if k != "__api-name"}
163
160
 
164
- api = parameter_payload["__api-name"]
165
- # Create parameter value by excluding __api-name
166
- parameter_value = {k: v for k, v in parameter_payload.items() if k != "__api-name"}
161
+ test_parameter: dict[str, Any] = {
162
+ "name": parameter_key.replace(".json", ""),
163
+ "lastUsed": False,
164
+ "id": str(uuid.uuid4()),
165
+ "value": json.dumps(parameter_value),
166
+ }
167
167
 
168
- test_parameter: dict[str, Any] = {
169
- "name": parameter_key.replace(".json", ""),
170
- "lastUsed": False,
171
- "id": str(uuid.uuid4()),
172
- "value": json.dumps(parameter_value),
173
- }
168
+ if api not in api_parameters_map:
169
+ api_parameters_map[api] = []
170
+ api_parameters_map[api].append(test_parameter)
174
171
 
175
- if api not in api_parameters_map:
176
- api_parameters_map[api] = []
177
- api_parameters_map[api].append(test_parameter)
172
+ for api, parameters in api_parameters_map.items():
173
+ # By default, last one used is the last one in the map
174
+ if len(parameters) > 0:
175
+ parameters[-1]["lastUsed"] = True
178
176
 
179
- for api, parameters in api_parameters_map.items():
180
- # By default, last one used is the last one in the map
181
- if len(parameters) > 0:
182
- parameters[-1]["lastUsed"] = True
177
+ test_parameters.directory.root[f"{api}.json"] = FileNode(
178
+ file=FileNodeContent(contents=json.dumps(parameters, indent=2))
179
+ )
183
180
 
184
- test_parameters.directory.root[f"{api}.json"] = FileNode(
185
- file=FileNodeContent(contents=json.dumps(parameters, indent=2))
186
- )
181
+ del file_tree.root["parameters"]
182
+ file_tree.root["____testParameters"] = test_parameters
187
183
 
188
- del file_tree.root["parameters"]
189
- file_tree.root["____testParameters"] = test_parameters
184
+ if file_tree.root.get("Intuned.json") is None:
185
+ settings_file = await get_intuned_settings_file()
186
+ text_content = await Path(settings_file.file_path).read_text()
187
+ parsed_content = settings_file.parse(text_content)
188
+ json_content = json.dumps(parsed_content, indent=2)
189
+ file_tree.root["Intuned.json"] = FileNode(file=FileNodeContent(contents=json_content))
190
190
 
191
191
 
192
192
  class SaveProjectResponse(BaseModel):
@@ -201,14 +201,13 @@ async def save_project(
201
201
  ):
202
202
  base_url = get_base_url()
203
203
  url = f"{base_url}/api/v1/workspace/{workspace_id}/projects/{project_name}"
204
- print(f"calling {url}")
205
204
  headers = {
206
205
  api_key_header_name: api_key,
207
206
  "Content-Type": "application/json",
208
207
  }
209
208
  cwd = await Path().resolve()
210
209
  file_tree = await get_file_tree_from_project(cwd, exclude=exclusions)
211
- mapFileTreeToIdeFileTree(file_tree)
210
+ await map_file_tree_to_ide_file_tree(file_tree)
212
211
 
213
212
  payload: dict[str, Any] = {
214
213
  "codeTree": file_tree.model_dump(mode="json"),
intuned_cli/types.py CHANGED
@@ -1,3 +1,6 @@
1
+ from typing import NotRequired
2
+ from typing import TypedDict
3
+
1
4
  from pydantic import BaseModel
2
5
  from pydantic import RootModel
3
6
 
@@ -19,3 +22,14 @@ class FileNode(BaseModel):
19
22
 
20
23
 
21
24
  FileSystemTree.model_rebuild()
25
+
26
+
27
+ class BaseExecuteCommandOptionsWithoutTrace(TypedDict):
28
+ headless: bool
29
+ timeout: float
30
+ proxy: NotRequired[str | None]
31
+ keep_browser_open: bool
32
+
33
+
34
+ class BaseExecuteCommandOptions(BaseExecuteCommandOptionsWithoutTrace):
35
+ trace: bool
@@ -0,0 +1,132 @@
1
+ from dataclasses import dataclass
2
+ from unittest.mock import AsyncMock
3
+ from unittest.mock import MagicMock
4
+ from unittest.mock import Mock
5
+ from unittest.mock import patch
6
+
7
+ import pytest
8
+ import pytest_asyncio
9
+
10
+ from intuned_cli.utils.browser import close_cli_browser
11
+ from intuned_cli.utils.browser import get_cli_run_options
12
+ from runtime.types.run_types import CDPRunOptions
13
+ from runtime.types.run_types import StandaloneRunOptions
14
+
15
+
16
+ @dataclass
17
+ class BrowserMocks:
18
+ launch_chromium: AsyncMock
19
+
20
+
21
+ @pytest_asyncio.fixture
22
+ async def browser_mocks():
23
+ """Mock dependencies for execute_record_auth_session_cli tests."""
24
+
25
+ _mock_launch_chromium = patch("runtime.browser.launch_chromium.launch_chromium")
26
+ _mock_get_free_port = patch("runtime.run.playwright_context.get_random_free_port", new_callable=AsyncMock)
27
+
28
+ with (
29
+ _mock_launch_chromium as mock_launch_chromium,
30
+ _mock_get_free_port as _mock_get_free_port,
31
+ ):
32
+ mock_launch_chromium_return_value = Mock()
33
+
34
+ async def create_context_and_page():
35
+ return (MagicMock(), MagicMock())
36
+
37
+ mock_launch_chromium_return_value.__aenter__ = AsyncMock(side_effect=create_context_and_page)
38
+ mock_launch_chromium_return_value.__aexit__ = AsyncMock(return_value=None)
39
+ mock_launch_chromium.return_value = mock_launch_chromium_return_value
40
+
41
+ _mock_get_free_port.return_value = 1234
42
+
43
+ yield BrowserMocks(
44
+ launch_chromium=mock_launch_chromium,
45
+ )
46
+
47
+ await close_cli_browser()
48
+
49
+
50
+ class TestBrowser:
51
+ @pytest.mark.asyncio
52
+ async def test_returns_standalone_options_if_keep_browser_open_is_false(self, browser_mocks: BrowserMocks):
53
+ from intuned_cli.utils.browser import get_cli_run_options
54
+
55
+ options = await get_cli_run_options(
56
+ keep_browser_open=False,
57
+ headless=False,
58
+ )
59
+ assert options.environment == "standalone"
60
+ assert type(options) is StandaloneRunOptions
61
+ assert options.headless is False
62
+
63
+ @pytest.mark.asyncio
64
+ async def test_launches_browser_and_returns_cdp_options_if_keep_browser_open_is_true(
65
+ self, browser_mocks: BrowserMocks
66
+ ):
67
+ options = await get_cli_run_options(
68
+ keep_browser_open=True,
69
+ headless=False,
70
+ )
71
+ assert options.environment == "cdp"
72
+ assert type(options) is CDPRunOptions
73
+ assert options.cdp_address == "http://localhost:1234"
74
+
75
+ browser_mocks.launch_chromium.assert_called_with(
76
+ headless=False,
77
+ cdp_port=1234,
78
+ proxy=None,
79
+ )
80
+
81
+ @pytest.mark.asyncio
82
+ async def test_keeps_context_open_until_new_context_if_keep_browser_open_is_true(self, browser_mocks: BrowserMocks):
83
+ options = await get_cli_run_options(
84
+ keep_browser_open=True,
85
+ headless=False,
86
+ )
87
+ assert options.environment == "cdp"
88
+ assert type(options) is CDPRunOptions
89
+ assert options.cdp_address == "http://localhost:1234"
90
+
91
+ from intuned_cli.utils.browser import _current_browser_context # type: ignore
92
+
93
+ first_context = _current_browser_context
94
+ assert first_context is not None
95
+
96
+ assert browser_mocks.launch_chromium.call_count == 1
97
+
98
+ options = await get_cli_run_options(
99
+ keep_browser_open=True,
100
+ headless=False,
101
+ )
102
+
103
+ from intuned_cli.utils.browser import _current_browser_context # type: ignore
104
+
105
+ second_context = _current_browser_context
106
+ assert second_context is not None
107
+ assert first_context != second_context
108
+ assert browser_mocks.launch_chromium.call_count == 2
109
+
110
+ @pytest.mark.asyncio
111
+ async def test_keeps_context_open_until_explicitly_closed_if_keep_browser_open_is_true(
112
+ self, browser_mocks: BrowserMocks
113
+ ):
114
+ options = await get_cli_run_options(
115
+ keep_browser_open=True,
116
+ headless=False,
117
+ )
118
+ assert options.environment == "cdp"
119
+ assert type(options) is CDPRunOptions
120
+ assert options.cdp_address == "http://localhost:1234"
121
+
122
+ from intuned_cli.utils.browser import _current_browser_context # type: ignore
123
+
124
+ assert _current_browser_context is not None
125
+
126
+ assert browser_mocks.launch_chromium.call_count == 1
127
+
128
+ await close_cli_browser()
129
+
130
+ from intuned_cli.utils.browser import _current_browser_context # type: ignore
131
+
132
+ assert _current_browser_context is None
@@ -0,0 +1,27 @@
1
+ from unittest.mock import Mock
2
+ from unittest.mock import patch
3
+
4
+ from intuned_cli.utils.traces import cli_trace
5
+ from runtime.types.run_types import TracingDisabled
6
+ from runtime.types.run_types import TracingEnabled
7
+
8
+
9
+ def get_mock_console():
10
+ """Create a mock console that tracks calls."""
11
+ mock_console = Mock()
12
+ mock_console.print = Mock()
13
+ return mock_console
14
+
15
+
16
+ @patch("intuned_cli.controller.authsession.console", get_mock_console())
17
+ class TestTraces:
18
+ def test_gives_disabled_tracing_if_id_is_none(self):
19
+ tracing = cli_trace(None).__enter__()
20
+ assert tracing.enabled is False
21
+ assert type(tracing) is TracingDisabled
22
+
23
+ def test_gives_enabled_tracing_if_id_is_provided(self):
24
+ tracing = cli_trace("some_id").__enter__()
25
+ assert tracing.enabled is True
26
+ assert type(tracing) is TracingEnabled
27
+ assert "some_id" in tracing.file_path
@@ -1,7 +1,15 @@
1
+ import json
2
+ from typing import Any
3
+ from typing import Callable
4
+ from typing import get_args
1
5
  from typing import Literal
2
6
  from typing import overload
3
7
 
8
+ import toml
9
+ import yaml
4
10
  from anyio import Path
11
+ from jsonc_parser.parser import JsoncParser # type: ignore
12
+ from pydantic import BaseModel
5
13
 
6
14
  from intuned_cli.utils.error import CLIError
7
15
  from runtime.types import IntunedJson
@@ -23,10 +31,51 @@ async def assert_api_file_exists(dirname: Literal["api", "auth-sessions"], api_n
23
31
 
24
32
 
25
33
  async def load_intuned_json() -> IntunedJson:
26
- intuned_json_path = Path("Intuned.json")
27
- if not await intuned_json_path.exists():
28
- raise CLIError("Intuned.json file is missing in the current directory.")
34
+ intuned_settings_file = await get_intuned_settings_file()
35
+
36
+ intuned_settings_content = await Path(intuned_settings_file.file_path).read_text()
37
+
38
+ parsed_content = intuned_settings_file.parse(intuned_settings_content)
39
+
29
40
  try:
30
- return IntunedJson.model_validate_json(await intuned_json_path.read_text())
41
+ return IntunedJson.model_validate(parsed_content)
31
42
  except Exception as e:
32
- raise CLIError(f"Failed to parse Intuned.json: {e}") from e
43
+ raise CLIError(f"Failed to parse {intuned_settings_file.name}: {e}") from e
44
+
45
+
46
+ IntunedSettingsFileName = Literal["Intuned.json", "Intuned.jsonc", "Intuned.yaml", "Intuned.yml", "Intuned.toml"]
47
+
48
+ intuned_file_names: list[IntunedSettingsFileName] = list(get_args(IntunedSettingsFileName))
49
+
50
+
51
+ class IntunedSettingsFile(BaseModel):
52
+ name: IntunedSettingsFileName
53
+ file_path: str
54
+ parse: Callable[[str], Any]
55
+
56
+
57
+ intuned_settings_parsers: dict[IntunedSettingsFileName, Callable[[str], Any]] = {
58
+ "Intuned.json": json.loads,
59
+ "Intuned.jsonc": lambda content: JsoncParser.parse_str(content), # type: ignore
60
+ "Intuned.yaml": yaml.safe_load,
61
+ "Intuned.yml": yaml.safe_load,
62
+ "Intuned.toml": toml.loads,
63
+ }
64
+
65
+
66
+ async def get_intuned_settings_file() -> IntunedSettingsFile:
67
+ for file_name in intuned_file_names:
68
+ path = Path(file_name)
69
+ if await path.exists():
70
+ return IntunedSettingsFile(
71
+ name=file_name,
72
+ file_path=str(path),
73
+ parse=intuned_settings_parsers[file_name],
74
+ )
75
+ raise CLIError(
76
+ "No Intuned settings file found in the current directory. Expected one of: " + ", ".join(intuned_file_names)
77
+ )
78
+
79
+
80
+ async def get_intuned_settings_file_name() -> str:
81
+ return (await get_intuned_settings_file()).name
@@ -1,10 +1,13 @@
1
1
  import json
2
2
  from typing import Any
3
+ from typing import Literal
3
4
 
4
5
  from anyio import Path
5
6
 
7
+ from intuned_cli.utils.api_helpers import get_intuned_settings_file_name
6
8
  from intuned_cli.utils.api_helpers import load_intuned_json
7
9
  from intuned_cli.utils.error import CLIError
10
+ from runtime.types.settings_types import IntunedJsonEnabledAuthSessions
8
11
 
9
12
 
10
13
  class CLIAssertionError(CLIError):
@@ -12,17 +15,30 @@ class CLIAssertionError(CLIError):
12
15
 
13
16
 
14
17
  async def is_auth_enabled() -> bool:
15
- """
16
- Check if the auth session is enabled in Intuned.json.
17
- Returns True if enabled, False otherwise.
18
- """
19
18
  intuned_json = await load_intuned_json()
20
19
  return intuned_json.auth_sessions.enabled
21
20
 
22
21
 
23
- async def assert_auth_enabled():
22
+ async def assert_auth_enabled(*, auth_type: Literal["API", "MANUAL"] | None = None):
24
23
  if not await is_auth_enabled():
25
- raise CLIAssertionError("Auth session is not enabled, enable it in Intuned.json to use it")
24
+ raise CLIAssertionError(
25
+ f"Auth session is not enabled, enable it in {await get_intuned_settings_file_name()} to use it"
26
+ )
27
+ if auth_type is None:
28
+ return
29
+ intuned_json = await load_intuned_json()
30
+ if (
31
+ type(intuned_json.auth_sessions) is IntunedJsonEnabledAuthSessions
32
+ and intuned_json.auth_sessions.type != auth_type
33
+ ):
34
+ if auth_type == "API":
35
+ raise CLIAssertionError(
36
+ f"Auth session type is not credentials-based in {await get_intuned_settings_file_name()}. Set it to 'API' to run this command."
37
+ )
38
+
39
+ raise CLIAssertionError(
40
+ f"Auth session type is not recorder-based in {await get_intuned_settings_file_name()}. Set it to 'MANUAL' to run this command."
41
+ )
26
42
 
27
43
 
28
44
  async def assert_auth_consistent(auth_session_id: str | None = None):
@@ -32,7 +48,9 @@ async def assert_auth_consistent(auth_session_id: str | None = None):
32
48
  "Auth session is enabled, but no auth session is provided. Please provide an auth session ID."
33
49
  )
34
50
  if not _is_auth_enabled and auth_session_id is not None:
35
- raise CLIAssertionError("Auth session is not enabled, enable it in Intuned.json to use it")
51
+ raise CLIAssertionError(
52
+ f"Auth session is not enabled, enable it in {await get_intuned_settings_file_name()} to use it"
53
+ )
36
54
 
37
55
 
38
56
  async def load_parameters(parameters: str) -> dict[str, Any]:
@@ -55,3 +73,20 @@ async def load_parameters(parameters: str) -> dict[str, Any]:
55
73
  raise CLIError(f"Invalid JSON format: {e}") from e
56
74
  except Exception as e:
57
75
  raise CLIError(f"Failed to load parameters: {e}") from e
76
+
77
+
78
+ async def get_auth_session_recorder_parameters() -> tuple[str, str]:
79
+ intuned_json = await load_intuned_json()
80
+ auth_session_settings = intuned_json.auth_sessions
81
+ if type(auth_session_settings) is not IntunedJsonEnabledAuthSessions:
82
+ raise CLIError(f"Auth sessions are not enabled in {await get_intuned_settings_file_name()}")
83
+ if auth_session_settings.type != "MANUAL":
84
+ raise CLIError(f"Auth session type is not recorder-based in {await get_intuned_settings_file_name()}")
85
+ start_url = auth_session_settings.start_url
86
+ finish_url = auth_session_settings.finish_url
87
+ if not start_url or not finish_url:
88
+ raise CLIError(
89
+ f"Auth session type is recorder-based but start_url or finish_url is not set in {await get_intuned_settings_file_name()}"
90
+ )
91
+
92
+ return start_url, finish_url
@@ -1,5 +1,6 @@
1
1
  import os
2
2
 
3
+ from intuned_cli.utils.api_helpers import get_intuned_settings_file_name
3
4
  from intuned_cli.utils.error import CLIError
4
5
  from runtime.types import IntunedJson
5
6
 
@@ -21,7 +22,9 @@ async def get_intuned_api_auth_credentials(
21
22
  api_key = api_key or os.environ.get("INTUNED_API_KEY")
22
23
 
23
24
  if not workspace_id:
24
- raise CLIError("Workspace ID is required. Please provide it via command line options or Intuned.json")
25
+ raise CLIError(
26
+ f"Workspace ID is required. Please provide it via command line options or {await get_intuned_settings_file_name()}."
27
+ )
25
28
 
26
29
  if not api_key:
27
30
  raise CLIError(
@@ -0,0 +1,63 @@
1
+ from typing import Any
2
+ from typing import AsyncContextManager
3
+ from typing import TYPE_CHECKING
4
+
5
+ from runtime.types.run_types import CDPRunOptions
6
+ from runtime.types.run_types import ProxyConfig
7
+ from runtime.types.run_types import StandaloneRunOptions
8
+
9
+ if TYPE_CHECKING:
10
+ from playwright.async_api import BrowserContext
11
+
12
+ _current_browser_context_manager: AsyncContextManager[Any] | None = None
13
+ _current_browser_context: "BrowserContext | None" = None
14
+
15
+
16
+ async def get_cli_run_options(
17
+ headless: bool = False,
18
+ proxy: ProxyConfig | None = None,
19
+ keep_browser_open: bool = False,
20
+ ):
21
+ global _current_browser_context_manager, _current_browser_context
22
+ if not keep_browser_open:
23
+ return StandaloneRunOptions(
24
+ headless=headless,
25
+ proxy=proxy,
26
+ )
27
+ from playwright.async_api import ProxySettings
28
+
29
+ from runtime.browser.launch_chromium import launch_chromium
30
+ from runtime.run.playwright_context import get_random_free_port
31
+
32
+ await close_cli_browser()
33
+ port = await get_random_free_port()
34
+ acm = launch_chromium(
35
+ headless=headless,
36
+ cdp_port=port,
37
+ proxy=ProxySettings(
38
+ **proxy.model_dump(by_alias=True),
39
+ )
40
+ if proxy
41
+ else None,
42
+ )
43
+
44
+ _current_browser_context_manager = acm
45
+ _current_browser_context, _ = await _current_browser_context_manager.__aenter__()
46
+
47
+ return CDPRunOptions(
48
+ cdp_address=f"http://localhost:{port}",
49
+ )
50
+
51
+
52
+ def is_cli_browser_launched():
53
+ global _current_browser_context_manager
54
+ return _current_browser_context_manager is not None and _current_browser_context is not None
55
+
56
+
57
+ async def close_cli_browser():
58
+ global _current_browser_context_manager, _current_browser_context
59
+ if _current_browser_context_manager is not None:
60
+ await _current_browser_context_manager.__aexit__(None, None, None)
61
+ _current_browser_context_manager = None
62
+ if _current_browser_context is not None:
63
+ _current_browser_context = None
@@ -29,3 +29,17 @@ def log_automation_error(e: RunApiError):
29
29
  console.print(f"[red]{''.join(stack_trace)}[/red]")
30
30
  else:
31
31
  console.print(f"[red]{e}[/red]")
32
+
33
+
34
+ class CLIExit(BaseException):
35
+ """Exception to signal CLI exit with a specific code."""
36
+
37
+ def __init__(self, code: int):
38
+ """
39
+ Initialize the CLIExit.
40
+
41
+ Args:
42
+ code (int): The exit code.
43
+ """
44
+ super().__init__()
45
+ self.code = code
@@ -34,6 +34,7 @@ exclusions = [
34
34
  "README.md",
35
35
  "output/**",
36
36
  "tmp/**",
37
+ "traces/**",
37
38
  ]
38
39
 
39
40
  # For compatibility with import style similar to 'export default exclusions'
@@ -0,0 +1,9 @@
1
+ import arguably
2
+
3
+ from intuned_cli.utils.error import CLIExit
4
+
5
+
6
+ def print_help_and_exit():
7
+ if arguably.is_target():
8
+ arguably._context.context._current_parser.print_help() # type: ignore
9
+ raise CLIExit(0)
@@ -0,0 +1,31 @@
1
+ from contextlib import contextmanager
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+
5
+ from intuned_cli.utils.console import console
6
+ from runtime.types.run_types import TracingDisabled
7
+ from runtime.types.run_types import TracingEnabled
8
+
9
+ _trace_dir_name = datetime.now().isoformat()
10
+
11
+ _count = 0
12
+
13
+
14
+ def get_trace_path(id: str):
15
+ global _count
16
+ _count += 1
17
+ return Path() / "traces" / _trace_dir_name / f"{_count}-{id}.zip"
18
+
19
+
20
+ @contextmanager
21
+ def cli_trace(id: str | None):
22
+ if not id:
23
+ yield TracingDisabled()
24
+ return
25
+
26
+ trace_path = get_trace_path(id)
27
+ try:
28
+ yield TracingEnabled(file_path=str(trace_path))
29
+ finally:
30
+ if trace_path.exists():
31
+ console.print(f"[bold]Trace saved to [/bold][underline]{str(trace_path)}[/underline]")