indent 0.1.26__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 (55) hide show
  1. exponent/__init__.py +34 -0
  2. exponent/cli.py +110 -0
  3. exponent/commands/cloud_commands.py +585 -0
  4. exponent/commands/common.py +411 -0
  5. exponent/commands/config_commands.py +334 -0
  6. exponent/commands/run_commands.py +222 -0
  7. exponent/commands/settings.py +56 -0
  8. exponent/commands/types.py +111 -0
  9. exponent/commands/upgrade.py +29 -0
  10. exponent/commands/utils.py +146 -0
  11. exponent/core/config.py +180 -0
  12. exponent/core/graphql/__init__.py +0 -0
  13. exponent/core/graphql/client.py +61 -0
  14. exponent/core/graphql/get_chats_query.py +47 -0
  15. exponent/core/graphql/mutations.py +160 -0
  16. exponent/core/graphql/queries.py +146 -0
  17. exponent/core/graphql/subscriptions.py +16 -0
  18. exponent/core/remote_execution/checkpoints.py +212 -0
  19. exponent/core/remote_execution/cli_rpc_types.py +499 -0
  20. exponent/core/remote_execution/client.py +999 -0
  21. exponent/core/remote_execution/code_execution.py +77 -0
  22. exponent/core/remote_execution/default_env.py +31 -0
  23. exponent/core/remote_execution/error_info.py +45 -0
  24. exponent/core/remote_execution/exceptions.py +10 -0
  25. exponent/core/remote_execution/file_write.py +35 -0
  26. exponent/core/remote_execution/files.py +330 -0
  27. exponent/core/remote_execution/git.py +268 -0
  28. exponent/core/remote_execution/http_fetch.py +94 -0
  29. exponent/core/remote_execution/languages/python_execution.py +239 -0
  30. exponent/core/remote_execution/languages/shell_streaming.py +226 -0
  31. exponent/core/remote_execution/languages/types.py +20 -0
  32. exponent/core/remote_execution/port_utils.py +73 -0
  33. exponent/core/remote_execution/session.py +128 -0
  34. exponent/core/remote_execution/system_context.py +26 -0
  35. exponent/core/remote_execution/terminal_session.py +375 -0
  36. exponent/core/remote_execution/terminal_types.py +29 -0
  37. exponent/core/remote_execution/tool_execution.py +595 -0
  38. exponent/core/remote_execution/tool_type_utils.py +39 -0
  39. exponent/core/remote_execution/truncation.py +296 -0
  40. exponent/core/remote_execution/types.py +635 -0
  41. exponent/core/remote_execution/utils.py +477 -0
  42. exponent/core/types/__init__.py +0 -0
  43. exponent/core/types/command_data.py +206 -0
  44. exponent/core/types/event_types.py +89 -0
  45. exponent/core/types/generated/__init__.py +0 -0
  46. exponent/core/types/generated/strategy_info.py +213 -0
  47. exponent/migration-docs/login.md +112 -0
  48. exponent/py.typed +4 -0
  49. exponent/utils/__init__.py +0 -0
  50. exponent/utils/colors.py +92 -0
  51. exponent/utils/version.py +289 -0
  52. indent-0.1.26.dist-info/METADATA +38 -0
  53. indent-0.1.26.dist-info/RECORD +55 -0
  54. indent-0.1.26.dist-info/WHEEL +4 -0
  55. indent-0.1.26.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,111 @@
1
+ from collections.abc import Callable, Sequence
2
+ from gettext import gettext
3
+ from typing import Any
4
+
5
+ import click
6
+ import questionary
7
+
8
+ from exponent.core.types.generated.strategy_info import StrategyInfo
9
+
10
+
11
+ class AutoCompleteOption(click.Option):
12
+ prompt: str
13
+
14
+ def __init__(
15
+ self,
16
+ param_decls: Sequence[str] | None = None,
17
+ prompt: bool | str = True,
18
+ choices: list[str] | None = None,
19
+ **kwargs: Any,
20
+ ):
21
+ super().__init__(param_decls, prompt=prompt, **kwargs)
22
+ if isinstance(self.type, click.Choice):
23
+ self.choices = self.type.choices
24
+ else:
25
+ self.choices = choices or []
26
+
27
+ def prompt_for_value(self, ctx: click.core.Context) -> Any:
28
+ return questionary.autocomplete(
29
+ self.prompt,
30
+ list(self.choices),
31
+ style=questionary.Style(
32
+ [
33
+ ("question", "bold"), # question text
34
+ (
35
+ "answer",
36
+ "fg:#33ccff bold",
37
+ ), # submitted answer text behind the question
38
+ (
39
+ "answer",
40
+ "bg:#000066",
41
+ ), # submitted answer text behind the question
42
+ ]
43
+ ),
44
+ ).unsafe_ask()
45
+
46
+
47
+ class StrategyChoice(click.Choice[str]):
48
+ def __init__(self, choices: Sequence[StrategyInfo]) -> None:
49
+ self.strategy_choices = choices
50
+ self.choices = [strategy.strategy_name.value for strategy in choices]
51
+ self.case_sensitive = True
52
+
53
+
54
+ class StrategyOption(AutoCompleteOption):
55
+ def __init__(self, *args: Any, type: StrategyChoice, **kwargs: Any):
56
+ super().__init__(*args, type=type, **kwargs)
57
+ self.default = self.default_choice(type.strategy_choices)
58
+ self.strategy_choices = type.strategy_choices
59
+
60
+ def _format_strategy_info(
61
+ self, strategy_info: StrategyInfo, formatter: click.HelpFormatter
62
+ ) -> None:
63
+ row = (strategy_info.strategy_name.value, strategy_info.display_name)
64
+ formatter.write_dl([row])
65
+ with formatter.indentation():
66
+ formatter.write_text(strategy_info.description)
67
+
68
+ def help_extra_hook(self, formatter: click.HelpFormatter) -> None:
69
+ with formatter.section("Strategies"):
70
+ for strategy_info in self.strategy_choices:
71
+ formatter.write_paragraph()
72
+ self._format_strategy_info(strategy_info, formatter)
73
+
74
+ @staticmethod
75
+ def default_choice(choices: Sequence[StrategyInfo]) -> str:
76
+ return min(choices, key=lambda x: x.display_order).strategy_name.value
77
+
78
+
79
+ class ExponentCommand(click.Command):
80
+ def format_options(
81
+ self, ctx: click.Context, formatter: click.HelpFormatter
82
+ ) -> None:
83
+ """Writes all the options into the formatter if they exist."""
84
+ opts = []
85
+ for param in self.get_params(ctx):
86
+ rv = param.get_help_record(ctx)
87
+ hook = getattr(param, "help_extra_hook", None)
88
+ if rv is not None:
89
+ opts.append((rv, hook))
90
+
91
+ if not opts:
92
+ return
93
+
94
+ with formatter.section(gettext("Options")):
95
+ for opt, hook in opts:
96
+ formatter.write_dl([opt])
97
+ if hook is not None:
98
+ hook(formatter)
99
+ formatter.write_paragraph()
100
+
101
+
102
+ class ExponentGroup(click.Group):
103
+ command_class = ExponentCommand
104
+ group_class = type
105
+
106
+
107
+ def exponent_cli_group(
108
+ name: str | None = None,
109
+ **attrs: Any,
110
+ ) -> Callable[[Callable[..., Any]], ExponentGroup]:
111
+ return click.command(name, ExponentGroup, **attrs)
@@ -0,0 +1,29 @@
1
+ import click
2
+
3
+ from exponent.commands.types import exponent_cli_group
4
+ from exponent.utils.version import check_exponent_version, upgrade_exponent
5
+
6
+
7
+ @exponent_cli_group()
8
+ def upgrade_cli() -> None:
9
+ """Manage Indent version upgrades."""
10
+ pass
11
+
12
+
13
+ @upgrade_cli.command()
14
+ @click.option(
15
+ "--force",
16
+ is_flag=True,
17
+ help="Upgrade without prompting for confirmation, if a new version is available.",
18
+ )
19
+ def upgrade(force: bool = False) -> None:
20
+ """Upgrade Indent to the latest version."""
21
+ if result := check_exponent_version():
22
+ installed_version, latest_version = result
23
+ upgrade_exponent(
24
+ current_version=installed_version,
25
+ new_version=latest_version,
26
+ force=force,
27
+ )
28
+ else:
29
+ click.echo("Indent is already up to date.")
@@ -0,0 +1,146 @@
1
+ import asyncio
2
+ import os
3
+ import sys
4
+ import time
5
+ import webbrowser
6
+
7
+ import click
8
+
9
+ from exponent.core.config import Environment, Settings
10
+ from exponent.utils.version import get_installed_version
11
+
12
+
13
+ def print_editable_install_forced_prod_warning(settings: Settings) -> None:
14
+ click.secho(
15
+ "Detected local editable install, but this command only works against prod.",
16
+ fg="red",
17
+ bold=True,
18
+ )
19
+ click.secho("Using prod settings:", fg="red", bold=True)
20
+ click.secho("- base_url=", fg="yellow", bold=True, nl=False)
21
+ click.secho(f"{settings.base_url}", fg=(100, 200, 255), bold=False)
22
+ click.secho("- base_api_url=", fg="yellow", bold=True, nl=False)
23
+ click.secho(f"{settings.get_base_api_url()}", fg=(100, 200, 255), bold=False)
24
+ click.secho()
25
+
26
+
27
+ def print_editable_install_warning(settings: Settings) -> None:
28
+ click.secho(
29
+ "Detected local editable install, using local URLs", fg="yellow", bold=True
30
+ )
31
+ click.secho("- base_url=", fg="yellow", bold=True, nl=False)
32
+ click.secho(f"{settings.base_url}", fg=(100, 200, 255), bold=False)
33
+ click.secho("- base_api_url=", fg="yellow", bold=True, nl=False)
34
+ click.secho(f"{settings.get_base_api_url()}", fg=(100, 200, 255), bold=False)
35
+ click.secho()
36
+
37
+
38
+ def print_exponent_message(base_url: str, chat_uuid: str) -> None:
39
+ version = get_installed_version()
40
+ shell = os.environ.get("SHELL")
41
+
42
+ click.echo()
43
+ click.secho(f"△ Indent v{version}", fg=(180, 150, 255), bold=True)
44
+ click.echo()
45
+ click.echo(
46
+ " - Link: " + click.style(f"{base_url}/chats/{chat_uuid}", fg=(100, 200, 255))
47
+ )
48
+
49
+ if shell is not None:
50
+ click.echo(f" - Shell: {shell}")
51
+
52
+
53
+ def is_indent_app_installed() -> bool:
54
+ if sys.platform == "darwin": # macOS
55
+ return os.path.exists("/Applications/Indent.app")
56
+
57
+ # TODO: Add support for Windows and Linux
58
+ return False
59
+
60
+
61
+ def launch_exponent_browser(
62
+ environment: Environment, base_url: str, chat_uuid: str
63
+ ) -> None:
64
+ if is_indent_app_installed() and environment == Environment.production:
65
+ url = f"exponent://chats/{chat_uuid}"
66
+ else:
67
+ url = f"{base_url}/chats/{chat_uuid}"
68
+ webbrowser.open(url)
69
+
70
+
71
+ class Spinner:
72
+ def __init__(self, text: str) -> None:
73
+ self.text = text
74
+ self.task: asyncio.Task[None] | None = None
75
+ self.base_time = time.time()
76
+ self.fg_color: tuple[int, int, int] | None = None
77
+ self.bold = False
78
+ self.animation_chars = "⣷⣯⣟⡿⢿⣻⣽⣾"
79
+ self.animation_speed = 10
80
+
81
+ def show(self) -> None:
82
+ if self.task is not None:
83
+ return
84
+
85
+ async def spinner(base_time: float) -> None:
86
+ color_start = ""
87
+ if self.fg_color:
88
+ if isinstance(self.fg_color, tuple) and len(self.fg_color) == 3:
89
+ r, g, b = self.fg_color
90
+ color_start = f"\x1b[38;2;{r};{g};{b}m"
91
+ elif isinstance(self.fg_color, int):
92
+ color_start = f"\x1b[{30 + self.fg_color}m"
93
+
94
+ bold_start = "\x1b[1m" if self.bold else ""
95
+ style_start = f"{color_start}{bold_start}"
96
+ style_end = "\x1b[0m"
97
+
98
+ while True:
99
+ t = time.time() - base_time
100
+ i = round(t * self.animation_speed) % len(self.animation_chars)
101
+ click.echo(
102
+ f"\r{style_start}{self.animation_chars[i]} {self.text}{style_end}",
103
+ nl=False,
104
+ )
105
+ await asyncio.sleep(0.1)
106
+
107
+ self.task = asyncio.get_event_loop().create_task(spinner(self.base_time))
108
+
109
+ def hide(self) -> None:
110
+ if self.task is None:
111
+ return
112
+
113
+ self.task.cancel()
114
+ self.task = None
115
+ click.echo("\r\x1b[0m\x1b[2K", nl=False)
116
+ sys.stdout.flush()
117
+
118
+
119
+ class ConnectionTracker:
120
+ def __init__(self) -> None:
121
+ self.connected = True
122
+ self.queue: asyncio.Queue[bool] = asyncio.Queue()
123
+
124
+ def is_connected(self) -> bool:
125
+ while True:
126
+ try:
127
+ self.connected = self.queue.get_nowait()
128
+ except asyncio.QueueEmpty:
129
+ break
130
+
131
+ return self.connected
132
+
133
+ async def wait_for_reconnection(self) -> None:
134
+ if not self.is_connected():
135
+ assert await self.queue.get()
136
+ self.connected = True
137
+
138
+ async def set_connected(self, connected: bool) -> None:
139
+ await self.queue.put(connected)
140
+
141
+ async def next_change(self) -> bool:
142
+ return await self.queue.get()
143
+
144
+
145
+ def get_short_git_commit_hash(commit_hash: str) -> str:
146
+ return commit_hash[:8]
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
4
+ import json
5
+ import logging
6
+ import os
7
+ from functools import lru_cache
8
+ from importlib.metadata import Distribution, PackageNotFoundError
9
+ from typing import Any, Dict # noqa: UP035
10
+
11
+ from pydantic import BaseModel
12
+ from pydantic_settings import (
13
+ BaseSettings,
14
+ JsonConfigSettingsSource,
15
+ PydanticBaseSettingsSource,
16
+ SettingsConfigDict,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # If the package is editable, we want to use:
22
+ # base_url = localhost:3000
23
+ # base_api_url = localhost:8000
24
+
25
+
26
+ def is_editable_install() -> bool:
27
+ if os.getenv("ENVIRONMENT") == "test" or os.getenv("EXPONENT_TEST_AUTO_UPGRADE"):
28
+ # We should explicitly set these variables
29
+ # in test when needed
30
+ return False
31
+
32
+ try:
33
+ dist = Distribution.from_name("indent")
34
+ except PackageNotFoundError:
35
+ logger.info("No distribution info found for indent")
36
+ return False
37
+
38
+ direct_url = dist.read_text("direct_url.json")
39
+ if not direct_url:
40
+ return False
41
+
42
+ try:
43
+ direct_url_json = json.loads(direct_url)
44
+ except json.JSONDecodeError:
45
+ logger.warning("Failed to decode distribution info for exponent-run")
46
+ return False
47
+
48
+ pkg_is_editable = direct_url_json.get("dir_info", {}).get("editable", False)
49
+ return bool(pkg_is_editable)
50
+
51
+
52
+ class Environment(str, enum.Enum):
53
+ test = "test"
54
+ development = "development"
55
+ staging = "staging"
56
+ production = "production"
57
+
58
+ @property
59
+ def is_local(self) -> bool:
60
+ return self in [Environment.development, Environment.test]
61
+
62
+
63
+ class APIKeys(BaseModel):
64
+ development: str | None = None
65
+ staging: str | None = None
66
+ production: str | None = None
67
+
68
+
69
+ SETTINGS_FOLDER = os.path.expanduser("~/.config/indent")
70
+ SETTINGS_FILE_PATH = os.path.join(SETTINGS_FOLDER, "config.json")
71
+
72
+
73
+ class GlobalExponentOptions(BaseModel):
74
+ git_warning_disabled: bool = False
75
+ auto_upgrade: bool = True
76
+ base_api_url_override: str | None = None
77
+ base_ws_url_override: str | None = None
78
+ use_default_colors: bool = False
79
+
80
+
81
+ class Settings(BaseSettings):
82
+ environment: Environment
83
+ base_url: str
84
+ base_api_url: str
85
+ base_ws_url: str
86
+
87
+ exponent_api_key: str | None = None
88
+ extra_exponent_api_keys: Dict[str, str] = {} # noqa: UP006
89
+
90
+ options: GlobalExponentOptions = GlobalExponentOptions()
91
+
92
+ log_level: str = "WARNING"
93
+
94
+ model_config = SettingsConfigDict(
95
+ json_file=SETTINGS_FILE_PATH,
96
+ json_file_encoding="utf-8",
97
+ env_prefix="EXPONENT_",
98
+ )
99
+
100
+ def get_base_api_url(self) -> str:
101
+ return self.options.base_api_url_override or self.base_api_url
102
+
103
+ def get_base_ws_url(self) -> str:
104
+ return self.options.base_ws_url_override or self.base_ws_url
105
+
106
+ @classmethod
107
+ def settings_customise_sources(
108
+ cls,
109
+ settings_cls: type[BaseSettings],
110
+ init_settings: PydanticBaseSettingsSource,
111
+ env_settings: PydanticBaseSettingsSource,
112
+ dotenv_settings: PydanticBaseSettingsSource,
113
+ file_secret_settings: PydanticBaseSettingsSource,
114
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
115
+ return env_settings, JsonConfigSettingsSource(settings_cls), init_settings
116
+
117
+ def write_settings_to_config_file(self) -> None:
118
+ os.makedirs(SETTINGS_FOLDER, exist_ok=True)
119
+ with open(SETTINGS_FILE_PATH, "w") as f:
120
+ f.write(
121
+ self.model_dump_json(
122
+ include=self.config_file_keys, exclude_defaults=True
123
+ )
124
+ )
125
+
126
+ def get_config_file_settings(self) -> dict[str, Any]:
127
+ return self.model_dump(include=self.config_file_keys, exclude_defaults=True)
128
+
129
+ @property
130
+ def config_file_path(self) -> str:
131
+ return SETTINGS_FILE_PATH
132
+
133
+ @property
134
+ def config_file_keys(self) -> set[str]:
135
+ return {"exponent_api_key", "extra_exponent_api_keys", "options"}
136
+
137
+ @property
138
+ def api_key(self) -> str | None:
139
+ if self.environment.is_local:
140
+ return self.extra_exponent_api_keys.get(Environment.development)
141
+ elif self.environment == Environment.staging:
142
+ return self.extra_exponent_api_keys.get(Environment.staging)
143
+ elif self.environment == Environment.production:
144
+ return self.exponent_api_key
145
+ else:
146
+ raise ValueError(f"Unknown environment: {self.environment}")
147
+
148
+ def update_api_key(self, api_key: str) -> None:
149
+ if self.environment == Environment.development:
150
+ self.extra_exponent_api_keys[Environment.development] = api_key
151
+ elif self.environment == Environment.staging:
152
+ self.extra_exponent_api_keys[Environment.staging] = api_key
153
+ elif self.environment == Environment.production:
154
+ self.exponent_api_key = api_key
155
+
156
+
157
+ @lru_cache(maxsize=1)
158
+ def get_settings(use_prod: bool = False, use_staging: bool = False) -> Settings:
159
+ if is_editable_install() and not (use_prod or use_staging):
160
+ base_url = "http://localhost:3000"
161
+ base_api_url = "http://localhost:8000"
162
+ base_ws_url = "ws://localhost:8000"
163
+ environment = Environment.development
164
+ elif use_staging:
165
+ base_url = "https://staging.indent.com"
166
+ base_api_url = "https://staging-api.indent.com"
167
+ base_ws_url = "wss://ws-staging-api.indent.com"
168
+ environment = Environment.staging
169
+ else:
170
+ base_url = "https://app.indent.com"
171
+ base_api_url = "https://api.indent.com"
172
+ base_ws_url = "wss://ws-api.indent.com"
173
+ environment = Environment.production
174
+
175
+ return Settings(
176
+ base_url=base_url,
177
+ base_api_url=base_api_url,
178
+ base_ws_url=base_ws_url,
179
+ environment=environment,
180
+ )
File without changes
@@ -0,0 +1,61 @@
1
+ from collections.abc import AsyncGenerator
2
+ from typing import Any
3
+
4
+ from gql import Client, GraphQLRequest, gql
5
+ from gql.transport.httpx import HTTPXAsyncTransport
6
+ from gql.transport.websockets import WebsocketsTransport
7
+
8
+
9
+ class GraphQLClient:
10
+ def __init__(self, api_key: str, base_api_url: str, base_ws_url: str):
11
+ self.graphql_url = f"{base_api_url}/graphql"
12
+ self.websocket_url = f"{base_ws_url}/graphql_ws".replace(
13
+ "https", "wss"
14
+ ).replace("http", "ws")
15
+
16
+ self.api_key = api_key
17
+
18
+ def get_transport(self, timeout: float | None = None) -> HTTPXAsyncTransport:
19
+ return HTTPXAsyncTransport(
20
+ url=self.graphql_url,
21
+ headers={"API-KEY": self.api_key},
22
+ timeout=timeout,
23
+ )
24
+
25
+ def get_ws_transport(self) -> WebsocketsTransport:
26
+ return WebsocketsTransport(
27
+ url=self.websocket_url,
28
+ init_payload={"apiKey": self.api_key},
29
+ )
30
+
31
+ async def execute(
32
+ self,
33
+ query_str: str,
34
+ vars: dict[str, Any] | None = None,
35
+ op_name: str | None = None,
36
+ timeout: float | None = None,
37
+ ) -> Any:
38
+ async with Client(
39
+ transport=self.get_transport(timeout),
40
+ fetch_schema_from_transport=False,
41
+ execute_timeout=timeout,
42
+ ) as session:
43
+ # Execute single query
44
+ query = GraphQLRequest(
45
+ query_str, variable_values=vars, operation_name=op_name
46
+ )
47
+ result = await session.execute(query)
48
+ return result
49
+
50
+ async def subscribe(
51
+ self,
52
+ subscription_str: str,
53
+ vars: dict[str, Any] | None = None,
54
+ ) -> AsyncGenerator[dict[str, Any], None]:
55
+ async with Client(
56
+ transport=self.get_ws_transport(),
57
+ ) as session:
58
+ # Execute subscription
59
+ subscription = gql(subscription_str)
60
+ async for result in session.subscribe(subscription, vars):
61
+ yield result
@@ -0,0 +1,47 @@
1
+ GET_CHATS_QUERY: str = """
2
+ query Chats {
3
+ chats {
4
+ ... on UnauthenticatedError {
5
+ message
6
+ }
7
+ ... on Chats {
8
+ chats {
9
+ id
10
+ chatUuid
11
+ name
12
+ subtitle
13
+ isShared
14
+ isStarted
15
+ updatedAt # ISO datetime string
16
+ }
17
+ }
18
+ }
19
+ }
20
+ """
21
+ CREATE_CLOUD_CONFIG_MUTATION: str = """
22
+ mutation CreateCloudConfig(
23
+ $githubOrgName: String!,
24
+ $githubRepoName: String!,
25
+ $setupCommands: [String!],
26
+ ) {
27
+ createCloudConfig(
28
+ input: {
29
+ githubOrgName: $githubOrgName,
30
+ githubRepoName: $githubRepoName,
31
+ setupCommands: $setupCommands,
32
+ }
33
+ ) {
34
+ __typename
35
+ ... on UnauthenticatedError {
36
+ message
37
+ }
38
+ ... on CloudConfig {
39
+ cloudConfigUuid
40
+ githubOrgName
41
+ githubRepoName
42
+ setupCommands
43
+ repoUrl
44
+ }
45
+ }
46
+ }
47
+ """