codespeak-cli 0.2.0__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.
@@ -0,0 +1,220 @@
1
+ import functools
2
+ import logging
3
+ import typing
4
+ from collections.abc import Callable
5
+ from contextlib import contextmanager
6
+ from datetime import UTC, datetime
7
+ from logging.handlers import RotatingFileHandler
8
+ from pathlib import Path
9
+ from typing import Any, override
10
+
11
+ from codespeak_shared import SpanProvider as SharedSpanProvider
12
+ from codespeak_shared.logging import get_span_provider, set_span_provider
13
+ from codespeak_shared.logging.span_provider import SpanProvider
14
+
15
+ from console_client.version import get_current_package_version
16
+
17
+
18
+ class IndentingFormatter(logging.Formatter):
19
+ def __init__(self, fmt: str | None = None):
20
+ super().__init__(fmt)
21
+ self.indent_level = 0
22
+ self.indent_str = SpanProvider.INDENT_STR
23
+
24
+ def indent_increase(self) -> int:
25
+ self.indent_level += 1
26
+ return self.indent_level
27
+
28
+ def indent_decrease(self) -> int:
29
+ if self.indent_level > 0:
30
+ self.indent_level -= 1
31
+ else:
32
+ raise ValueError("Cannot decrease indent")
33
+ return self.indent_level
34
+
35
+ @override
36
+ def format(self, record: logging.LogRecord) -> str:
37
+ original_msg = super().format(record)
38
+ indent = self.indent_str * self.indent_level
39
+ result_msg = indent + original_msg.replace("\n", "\\n")
40
+ return result_msg
41
+
42
+ @override
43
+ def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str:
44
+ dt = datetime.fromtimestamp(record.created, tz=UTC)
45
+ return dt.isoformat()
46
+
47
+
48
+ class NoOpFormatter(IndentingFormatter):
49
+ @override
50
+ def format(self, record: logging.LogRecord) -> str:
51
+ return record.getMessage()
52
+
53
+ @override
54
+ def indent_increase(self) -> int:
55
+ return 0
56
+
57
+ @override
58
+ def indent_decrease(self) -> int:
59
+ return 0
60
+
61
+ @override
62
+ def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str:
63
+ dt = datetime.fromtimestamp(record.created, tz=UTC)
64
+ return dt.isoformat()
65
+
66
+
67
+ class ConsoleClientLoggingUtil:
68
+ _FILE_PATTERN = "%(asctime)s - [%(threadName)s] %(levelname)s - %(name)s - %(message)s"
69
+ _CONSOLE_PATTERN = "%(asctime)s - %(message)s"
70
+
71
+ initialized = False
72
+ tracer = None
73
+ no_file_logging: bool = True
74
+ enable_spans = True
75
+
76
+ @classmethod
77
+ def initialize_logger(
78
+ cls,
79
+ log_file_path: Path | None,
80
+ enable_console_logging: bool,
81
+ enable_spans: bool = True,
82
+ log_rotation: bool = False,
83
+ ):
84
+ """
85
+ If log_file_path is None, logging to file will be disabled.
86
+ """
87
+ if log_file_path:
88
+ log_file_path.parent.mkdir(parents=True, exist_ok=True)
89
+
90
+ logger = logging.getLogger() # root logger
91
+ logger.setLevel(logging.INFO)
92
+ logger.handlers.clear()
93
+
94
+ if log_file_path:
95
+ if log_rotation:
96
+ file_handler = RotatingFileHandler(log_file_path, maxBytes=10 * 1024 * 1024, backupCount=100)
97
+ else:
98
+ file_handler = logging.FileHandler(log_file_path)
99
+
100
+ cls.enable_spans = enable_spans
101
+
102
+ if enable_spans:
103
+ formatter = IndentingFormatter(ConsoleClientLoggingUtil._FILE_PATTERN)
104
+ else:
105
+ formatter = logging.Formatter(ConsoleClientLoggingUtil._FILE_PATTERN)
106
+ file_handler.setFormatter(formatter)
107
+ logger.addHandler(file_handler)
108
+ cls.no_file_logging = False
109
+ else:
110
+ cls.no_file_logging = True
111
+
112
+ if enable_console_logging:
113
+ console_handler = logging.StreamHandler()
114
+ formatter = logging.Formatter(ConsoleClientLoggingUtil._CONSOLE_PATTERN)
115
+ console_handler.setFormatter(formatter)
116
+ logger.addHandler(console_handler)
117
+
118
+ cls.initialized = True
119
+
120
+ current_package_name = __name__.split(".")[0]
121
+ logging.getLogger(__class__.__qualname__).info(
122
+ f"Current package ({current_package_name}) version: {get_current_package_version()}"
123
+ )
124
+
125
+ class IndentingLogSpanProvider(SharedSpanProvider):
126
+ @contextmanager
127
+ def create_span(self, name: str) -> typing.Iterator[None]:
128
+ ConsoleClientLoggingUtil.enter_span(name)
129
+ try:
130
+ yield None
131
+ finally:
132
+ ConsoleClientLoggingUtil.exit_span(name)
133
+
134
+ set_span_provider(IndentingLogSpanProvider())
135
+
136
+ @classmethod
137
+ def ensure_initialized(cls):
138
+ if not cls.initialized:
139
+ raise ValueError("Please call ConsoleClientLoggingUtil.initialize()")
140
+
141
+ @staticmethod
142
+ def span(name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
143
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
144
+ @functools.wraps(func)
145
+ def wrapper(*args: Any, **kwargs: Any):
146
+ def delegate() -> Any:
147
+ return func(*args, **kwargs)
148
+
149
+ ConsoleClientLoggingUtil.run_in_span(delegate, name, func)
150
+
151
+ return wrapper
152
+
153
+ return decorator
154
+
155
+ @classmethod
156
+ def enter_span(cls, name: str, annotated_function: Callable[..., Any] | None = None):
157
+ cls.ensure_initialized()
158
+ cause = f"annotated function: {annotated_function.__qualname__}" if annotated_function else "explicitly"
159
+
160
+ # for unnamed spans, just increase indent
161
+ if name:
162
+ logging.getLogger(ConsoleClientLoggingUtil.__class__.__qualname__).info(f"{name}")
163
+ new_depth = ConsoleClientLoggingUtil._find_indenter().indent_increase()
164
+
165
+ logging.getLogger(ConsoleClientLoggingUtil.__class__.__qualname__).debug(
166
+ f"Started span: {name if name else 'empty'}, {cause}, new depth: {new_depth}"
167
+ )
168
+
169
+ @classmethod
170
+ def exit_span(cls, name: str, annotated_function: Callable[..., Any] | None = None):
171
+ ConsoleClientLoggingUtil.ensure_initialized()
172
+ new_depth = ConsoleClientLoggingUtil._find_indenter().indent_decrease()
173
+ cause = f"annotated function: {annotated_function.__qualname__}" if annotated_function else "explicitly"
174
+ logging.getLogger(ConsoleClientLoggingUtil.__class__.__qualname__).debug(
175
+ f"Exited span: {name}, {cause}, new depth: {new_depth}"
176
+ )
177
+
178
+ @classmethod
179
+ def _find_indenter(cls) -> IndentingFormatter:
180
+ if not cls.enable_spans or cls.no_file_logging:
181
+ return NoOpFormatter()
182
+
183
+ file_handler = next(
184
+ (handler for handler in logging.getLogger().handlers if isinstance(handler, logging.FileHandler)), None
185
+ )
186
+ if not file_handler:
187
+ raise ValueError("No file handler found")
188
+ assert isinstance(file_handler.formatter, IndentingFormatter)
189
+ return file_handler.formatter or NoOpFormatter()
190
+
191
+ @staticmethod
192
+ def run_in_span(
193
+ func: Callable[..., Any], span_name: str, annotated_function: Callable[..., Any] | None = None
194
+ ) -> Any:
195
+ ConsoleClientLoggingUtil.enter_span(span_name, annotated_function)
196
+ try:
197
+ return func()
198
+ finally:
199
+ ConsoleClientLoggingUtil.exit_span(span_name, annotated_function)
200
+
201
+ class Span:
202
+ def __init__(self, name: str) -> None:
203
+ self.exited = False
204
+ self.name = name
205
+ self._span = get_span_provider().create_span(name)
206
+
207
+ def __enter__(self) -> "ConsoleClientLoggingUtil.Span":
208
+ self._span.__enter__()
209
+ return self
210
+
211
+ def enter(self):
212
+ self.__enter__()
213
+
214
+ def exit(self):
215
+ self.__exit__(None, None, None)
216
+
217
+ def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any):
218
+ if not self.exited:
219
+ self._span.__exit__(exc_type, exc_val, exc_tb)
220
+ self.exited = True
@@ -0,0 +1,348 @@
1
+ """Console client CLI for CodeSpeak - remote builds and authentication."""
2
+
3
+ import logging
4
+ import os
5
+ import sys
6
+ from argparse import SUPPRESS, ArgumentParser
7
+ from importlib.metadata import version
8
+ from pathlib import Path
9
+ from typing import NoReturn
10
+
11
+ import dotenv
12
+ from api_stubs.setup_grpc import setup_grpc
13
+ from codespeak_shared import BuildResult, LoggingUtil
14
+ from codespeak_shared.change_request_utils import implement_code_change_request
15
+ from codespeak_shared.codespeak_project import FileBasedCodeSpeakProject
16
+ from codespeak_shared.environment_enhancer import enhance_environment_with_api_key
17
+ from codespeak_shared.exceptions import (
18
+ BuildWithExistingChangeRequest,
19
+ CodespeakInternalError,
20
+ CodespeakUserError,
21
+ ProjectNotFoundUserError,
22
+ )
23
+ from codespeak_shared.project_initializer import initialize_project
24
+ from codespeak_shared.shared_feature_flags import SharedFeatureFlags
25
+ from codespeak_shared.utils.colors import Colors, ThemeType
26
+ from codespeak_shared.utils.lenient_namespace import LenientNamespace
27
+ from codespeak_shared.utils.process_util import register_signal_handlers
28
+ from rich.console import Console
29
+
30
+ from console_client.auth.auth_manager import login
31
+ from console_client.auth.token_storage import is_token_expired, load_user_token
32
+ from console_client.client_feature_flags import ConsoleClientFeatureFlags
33
+ from console_client.console_client_logging import ConsoleClientLoggingUtil
34
+
35
+
36
+ def _ensure_authenticated(console: Console, client_feature_flags: ConsoleClientFeatureFlags) -> BuildResult:
37
+ """
38
+ Ensure user is authenticated. If no token is found, trigger login flow.
39
+
40
+ Args:
41
+ console: Rich console for user output.
42
+ client_feature_flags: Feature flags instance to check if authentication is enabled.
43
+
44
+ Returns:
45
+ Result indicating authentication success or failure.
46
+ """
47
+ # Skip authentication check if feature flag is disabled
48
+ if not client_feature_flags.get_flag_value(ConsoleClientFeatureFlags.CONSOLE_CLIENT_SEND_USER_TOKEN):
49
+ return BuildResult.succeeded("authentication-check")
50
+
51
+ try:
52
+ token = load_user_token()
53
+ if token:
54
+ if not is_token_expired():
55
+ # Token exists and is valid
56
+ return BuildResult.succeeded("authentication-check")
57
+
58
+ # Token is expired - trigger login
59
+ console.print("[yellow]Your authentication token has expired.[/yellow]")
60
+ console.print("[yellow]Please log in again to continue...[/yellow]\n")
61
+ return _login_user(console, client_feature_flags)
62
+
63
+ # Token is missing or empty - trigger login
64
+ console.print("[yellow]Authentication required.[/yellow]")
65
+ console.print("[yellow]Please log in to continue...[/yellow]\n")
66
+ return _login_user(console, client_feature_flags)
67
+
68
+ except BaseException as e: # noqa: BLE001
69
+ return BuildResult.failed("authentication-check", e)
70
+
71
+
72
+ def _login_user(console: Console, client_feature_flags: ConsoleClientFeatureFlags) -> BuildResult:
73
+ """Execute login flow."""
74
+ if not client_feature_flags.get_flag_value(ConsoleClientFeatureFlags.CONSOLE_CLIENT_SEND_USER_TOKEN):
75
+ console.print("Login disabled")
76
+ return BuildResult.succeeded("login")
77
+
78
+ try:
79
+ login(console, client_feature_flags)
80
+ return BuildResult.succeeded("login")
81
+
82
+ except KeyboardInterrupt as e:
83
+ console.print("\n[yellow]Login cancelled by user.[/yellow]")
84
+ return BuildResult.failed("login", e)
85
+
86
+ except CodespeakUserError as e:
87
+ return BuildResult.failed("login", e)
88
+
89
+ except BaseException as e: # noqa: BLE001 (login catch-all block)
90
+ return BuildResult.failed("login", e)
91
+
92
+
93
+ class ArgumentParserWithHelp(ArgumentParser):
94
+ def error(self, message: str) -> NoReturn:
95
+ sys.stderr.write(f"{message}\n")
96
+ sys.stderr.write("\n")
97
+ self.print_help()
98
+ sys.exit(CodespeakUserError.EXIT_CODE)
99
+
100
+
101
+ def main() -> None:
102
+ register_signal_handlers()
103
+
104
+ dotenv.load_dotenv(dotenv.find_dotenv(usecwd=True))
105
+ dotenv.load_dotenv(".env.local", override=True)
106
+
107
+ logger = logging.getLogger("console-client")
108
+ logger.info(f"Invocation: {' '.join([os.path.basename(sys.argv[0])] + sys.argv[1:])}")
109
+
110
+ def add_common_params(parser: ArgumentParser) -> None:
111
+ parser.add_argument("--enable-flag", action="append", help=SUPPRESS)
112
+ parser.add_argument("--disable-flag", action="append", help=SUPPRESS)
113
+
114
+ def add_build_params(parser: ArgumentParser) -> None:
115
+ add_common_params(parser)
116
+ parser.add_argument("--start", help=SUPPRESS)
117
+ parser.add_argument("--until", help=SUPPRESS)
118
+ parser.add_argument("--dry-run", action="store_true", help=SUPPRESS)
119
+ parser.add_argument(
120
+ "--no-diff-check",
121
+ action="store_true",
122
+ help=SUPPRESS,
123
+ )
124
+ parser.add_argument(
125
+ "--no-interactive",
126
+ action="store_true",
127
+ default=None,
128
+ help="run in non-interactive mode without real-time progress updates",
129
+ )
130
+ parser.add_argument(
131
+ "--disable-parallelism",
132
+ action="store_true",
133
+ help=SUPPRESS,
134
+ )
135
+ parser.add_argument(
136
+ "--spec",
137
+ help="path to the specification file to build; if omitted, all registered specification files will be built",
138
+ default=None,
139
+ )
140
+
141
+ resume_control_group = parser.add_mutually_exclusive_group()
142
+ resume_control_group.add_argument(
143
+ "--clean",
144
+ action="store_true",
145
+ help=SUPPRESS,
146
+ )
147
+ resume_control_group.add_argument(
148
+ "--retry",
149
+ action="store_true",
150
+ help=SUPPRESS,
151
+ )
152
+ parser.add_argument(
153
+ "--skip-tests",
154
+ action="store_true",
155
+ help="Skip improving test coverage",
156
+ )
157
+
158
+ def add_init_params(parser: ArgumentParser) -> None:
159
+ add_common_params(parser)
160
+ parser.add_argument("--profile", help="configuration profile to use for initialization")
161
+ parser.add_argument(
162
+ "--project-name", default=None, help="custom name for the project (defaults to directory name)"
163
+ )
164
+ parser.add_argument(
165
+ "--mixed",
166
+ action="store_true",
167
+ help="enable mixed mode, allowing both CodeSpeak spec-driven code and manually written code to coexist",
168
+ default=False,
169
+ )
170
+ parser.add_argument(
171
+ "--ignore-enclosing-project-check",
172
+ default=False,
173
+ action="store_true",
174
+ help=SUPPRESS,
175
+ )
176
+
177
+ # Create main parser
178
+ parser = ArgumentParserWithHelp(description="CodeSpeak build system")
179
+ parser.prog = "codespeak"
180
+ parser.add_argument("--version", action="version", version=f"CodeSpeak CLI {version('codespeak-cli')}")
181
+ subparsers = parser.add_subparsers(dest="command", required=True)
182
+
183
+ # Init command (local project initialization)
184
+ init_parser = subparsers.add_parser(
185
+ "init",
186
+ help="initialize a new CodeSpeak project",
187
+ description="Initialize a new CodeSpeak project in the current directory. This creates the necessary configuration files and project structure.",
188
+ )
189
+ add_init_params(init_parser)
190
+
191
+ # Login command
192
+ login_parser = subparsers.add_parser(
193
+ "login",
194
+ help="authenticate with CodeSpeak",
195
+ description="Authenticate with CodeSpeak to enable builds. Opens a browser for authentication.",
196
+ )
197
+ add_common_params(login_parser)
198
+
199
+ # Build command (remote build)
200
+ build_parser = subparsers.add_parser(
201
+ "build",
202
+ help="run build",
203
+ description="Build your project according to the specification file(s). CodeSpeak will generate or update code to match the spec.",
204
+ )
205
+ add_build_params(build_parser)
206
+
207
+ # Change command (remote change request)
208
+ change_parser = subparsers.add_parser(
209
+ "change",
210
+ help="code change request",
211
+ description="Request a code change to an existing project. Use --new to create a change request file, or -m to specify the change directly.",
212
+ )
213
+ add_build_params(change_parser)
214
+ change_parser.add_argument(
215
+ "--new",
216
+ action="store_true",
217
+ default=False,
218
+ help="create an empty change request file for further manual editing; after editing, implement the change request with 'codespeak change'",
219
+ )
220
+ change_parser.add_argument("-m", "--message", help="implement a given change request")
221
+
222
+ # Run command (remote build + run target app)
223
+ run_parser = subparsers.add_parser(
224
+ "run",
225
+ help="build and run the target application",
226
+ description="Build and run target application.",
227
+ )
228
+ add_build_params(run_parser)
229
+
230
+ # Parse arguments
231
+ args = parser.parse_args(namespace=LenientNamespace())
232
+ env_vars = dict(os.environ)
233
+ client_feature_flags = ConsoleClientFeatureFlags(env_vars, args.enable_flag, args.disable_flag)
234
+
235
+ # Setup console with theme
236
+ theme = Colors.get_theme(
237
+ ThemeType.DARK
238
+ if client_feature_flags.get_flag_value(SharedFeatureFlags.CONSOLE_USES_DARK_THEME)
239
+ else ThemeType.LIGHT
240
+ )
241
+ console = Console(theme=theme)
242
+ stderr_console = Console(stderr=True, theme=theme)
243
+
244
+ # Print any feature flag warnings
245
+ for warning in client_feature_flags.warnings:
246
+ stderr_console.print(warning)
247
+
248
+ # Handle commands
249
+ try:
250
+ if args.command == "init":
251
+
252
+ def init_logging(local_project: FileBasedCodeSpeakProject, feature_flags: SharedFeatureFlags) -> None:
253
+ ConsoleClientLoggingUtil.initialize_logger(
254
+ local_project.ignored_data_path("codespeak.log").get_underlying_path(), enable_console_logging=False
255
+ )
256
+
257
+ result = initialize_project(
258
+ current_path=Path(os.curdir),
259
+ console=console,
260
+ feature_flags=client_feature_flags,
261
+ args=args,
262
+ initialize_logging=init_logging,
263
+ )
264
+
265
+ elif args.command == "login":
266
+ result = _login_user(console, client_feature_flags)
267
+
268
+ elif args.command in ("build", "change", "run"):
269
+ # Find project
270
+ local_project = FileBasedCodeSpeakProject.find_upwards(Path(os.curdir))
271
+ if not local_project:
272
+ result = BuildResult.failed(args.command, ProjectNotFoundUserError(Path(os.curdir).resolve()))
273
+ else:
274
+ ConsoleClientLoggingUtil.initialize_logger(
275
+ local_project.ignored_data_path("codespeak.log").get_underlying_path(), enable_console_logging=False
276
+ )
277
+
278
+ # Enhance environment with API key on client side (before sending to server)
279
+ env_vars = enhance_environment_with_api_key(
280
+ console=console,
281
+ env_vars=env_vars,
282
+ project_root=local_project.project_root.get_underlying_path(),
283
+ no_interactive=args.no_interactive,
284
+ )
285
+
286
+ def call_server() -> BuildResult:
287
+ # Ensure user is authenticated before running build
288
+ auth_result = _ensure_authenticated(console, client_feature_flags)
289
+ if auth_result.enum_type != BuildResult.SUCCEEDED:
290
+ return auth_result
291
+ else:
292
+ with LoggingUtil.Span(f"Running command: {args.command}"):
293
+ # setup GRPC logging before importing build_client (which imports grpc)
294
+ setup_grpc(client_feature_flags.get_flag_value(SharedFeatureFlags.DEBUG_GRPC))
295
+
296
+ from console_client.build_client import run_build_client # noqa: PLC0415
297
+
298
+ return run_build_client(args, local_project, client_feature_flags, console, env_vars)
299
+
300
+ if args.command in ("build", "run"):
301
+ ccr_path = local_project.code_change_request_path()
302
+ if local_project.os_env().exists(ccr_path):
303
+ result = BuildResult.failed(
304
+ "build", BuildWithExistingChangeRequest(ccr_path.get_underlying_path())
305
+ )
306
+ else:
307
+ result = call_server()
308
+ else:
309
+ result = implement_code_change_request(local_project, args, console, call_server)
310
+ else:
311
+ # Shouldn't happen as argparse should fail before we get here
312
+ console.print(f"[red]Unknown command: {args.command}[/red]")
313
+ sys.exit(1)
314
+
315
+ except BaseException as e: # noqa: BLE001 (global catch-all block)
316
+ result = BuildResult.failed(args.command, e)
317
+
318
+ # Handle result and exit
319
+ if result.enum_type == BuildResult.SUCCEEDED:
320
+ sys.exit(0)
321
+ elif result.enum_type == BuildResult.SKIPPED:
322
+ sys.exit(0)
323
+ elif result.enum_type == BuildResult.FAILED:
324
+ assert result.exception
325
+ if isinstance(result.exception, KeyboardInterrupt):
326
+ stderr_console.print(
327
+ f"[error.emphasized]{result.command.capitalize()} interrupted by user.[/error.emphasized]"
328
+ )
329
+ sys.exit(130)
330
+ elif isinstance(result.exception, (CodespeakUserError, CodespeakUserError)):
331
+ stderr_console.print(
332
+ f"[error.emphasized]{result.command.capitalize()} failed:[/error.emphasized]\n{result.exception}"
333
+ )
334
+ sys.exit(CodespeakUserError.EXIT_CODE)
335
+ else:
336
+ if client_feature_flags.get_flag_value(ConsoleClientFeatureFlags.PRINT_MESSAGE_FOR_INTERNAL_ERROR):
337
+ stderr_console.print(
338
+ f"[error.emphasized]{result.command.capitalize()} failed:[/error.emphasized]\n{result.exception}"
339
+ )
340
+ else:
341
+ stderr_console.print(
342
+ f"[error.emphasized]{result.command.capitalize()} failed: internal error.[/error.emphasized]"
343
+ )
344
+ sys.exit(CodespeakInternalError.EXIT_CODE)
345
+
346
+
347
+ if __name__ == "__main__":
348
+ main()