Coverage for __init__.py: 99%

294 statements  

« prev     ^ index     » next       coverage.py v7.3.4, created at 2024-01-20 18:02 +0000

1r""" 

2 ___ _ _____  

3 / (_) __ _ _ __ __ _ ___ /__ \_ _ _ __ ___ _ __  

4 / /\ / |/ _` | '_ \ / _` |/ _ \ / /\/ | | | '_ \ / _ \ '__| 

5 / /_//| | (_| | | | | (_| | (_) | / / | |_| | |_) | __/ |  

6/___,'_/ |\__,_|_| |_|\__, |\___/ \/ \__, | .__/ \___|_|  

7 |__/ |___/ |___/|_|  

8 

9""" 

10 

11import contextlib 

12import inspect 

13import sys 

14import typing as t 

15from copy import deepcopy 

16from dataclasses import dataclass 

17from importlib import import_module 

18from types import MethodType, SimpleNamespace 

19 

20import click 

21from django.conf import settings 

22from django.core.management import get_commands 

23from django.core.management.base import BaseCommand 

24from django.core.management.color import no_style 

25from django.utils.translation import gettext_lazy as _ 

26from django.utils.functional import lazy 

27from typer import Typer 

28from typer.core import TyperCommand as CoreTyperCommand 

29from typer.core import TyperGroup as CoreTyperGroup 

30from typer.main import MarkupMode 

31from typer.main import get_command as get_typer_command 

32from typer.main import get_params_convertors_ctx_param_name_from_function 

33from typer.models import CommandFunctionType 

34from typer.models import Context as TyperContext 

35from typer.models import Default, DefaultPlaceholder 

36 

37from .types import ( 

38 ForceColor, 

39 NoColor, 

40 PythonPath, 

41 Settings, 

42 SkipChecks, 

43 Traceback, 

44 Verbosity, 

45 Version, 

46) 

47 

48VERSION = (0, 4, "0b") 

49 

50__title__ = "Django Typer" 

51__version__ = ".".join(str(i) for i in VERSION) 

52__author__ = "Brian Kohan" 

53__license__ = "MIT" 

54__copyright__ = "Copyright 2023 Brian Kohan" 

55 

56 

57__all__ = [ 

58 "TyperCommand", 

59 "Context", 

60 "initialize", 

61 "command", 

62 "group", 

63 "get_command", 

64] 

65 

66""" 

67TODO 

68- useful django types (app label, etc) 

69- documentation 

70- linting 

71- type hints 

72 

73design decision: no monkey-patching for call_command. call_command converts arguments to 

74strings. This is unavoidable and will just be a noted caveat that is also consistent with 

75how native django commands work. For calls with previously resolved types - the direct 

76callbacks should be invoked - either Command() or Command.group(). call_command however 

77*requires* options to be passed by value instead of by string. This should be fixed. 

78 

79behavior should align with native django commands 

80""" 

81 

82# def get_color_system(default): 

83# ctx = click.get_current_context(silent=True) 

84# if ctx: 

85# return None if ctx.django_command.style == no_style() else default 

86# return default 

87 

88# COLOR_SYSTEM = lazy(get_color_system, str) 

89# rich_utils.COLOR_SYSTEM = COLOR_SYSTEM(rich_utils.COLOR_SYSTEM) 

90 

91 

92def traceback_config(): 

93 cfg = getattr(settings, "DT_RICH_TRACEBACK_CONFIG", {"show_locals": True}) 

94 if cfg: 

95 return {"show_locals": True, **cfg} 

96 return cfg 

97 

98 

99def get_command( 

100 command_name: str, 

101 *subcommand: str, 

102 stdout: t.Optional[t.IO[str]] = None, 

103 stderr: t.Optional[t.IO[str]] = None, 

104 no_color: bool = False, 

105 force_color: bool = False, 

106): 

107 module = import_module( 

108 f"{get_commands()[command_name]}.management.commands.{command_name}" 

109 ) 

110 cmd = module.Command( 

111 stdout=stdout, stderr=stderr, no_color=no_color, force_color=force_color 

112 ) 

113 if subcommand: 

114 method = cmd.get_subcommand(*subcommand).command._callback.__wrapped__ 

115 return MethodType(method, cmd) # return the bound method 

116 return cmd 

117 

118 

119def _common_options( 

120 version: Version = False, 

121 verbosity: Verbosity = 1, 

122 settings: Settings = "", 

123 pythonpath: PythonPath = None, 

124 traceback: Traceback = False, 

125 no_color: NoColor = False, 

126 force_color: ForceColor = False, 

127 skip_checks: SkipChecks = False, 

128): 

129 pass 

130 

131 

132# cache common params to avoid this extra work on every command 

133# we cant resolve these at module scope because translations break it 

134_common_params = [] 

135 

136 

137def _get_common_params(): 

138 global _common_params 

139 if not _common_params: 

140 _common_params = get_params_convertors_ctx_param_name_from_function( 

141 _common_options 

142 )[0] 

143 return _common_params 

144 

145 

146COMMON_DEFAULTS = { 

147 key: value.default 

148 for key, value in inspect.signature(_common_options).parameters.items() 

149} 

150 

151 

152class _ParsedArgs(SimpleNamespace): # pylint: disable=too-few-public-methods 

153 def __init__(self, args, **kwargs): 

154 super().__init__(**kwargs) 

155 self.args = args 

156 

157 def _get_kwargs(self): 

158 return {"args": self.args, **COMMON_DEFAULTS} 

159 

160 

161# class _Augment: 

162# pass 

163 

164 

165# def augment(cls): 

166# return type('', (_Augment, cls), {}) 

167 

168 

169class Context(TyperContext): 

170 """ 

171 An extension of the click.Context class that adds a reference to 

172 the TyperCommand instance so that the Django command can be accessed 

173 from within click/typer callbacks that take a context. 

174 

175 e.g. This is necessary so that get_version() behavior can be implemented 

176 within the Version type itself. 

177 """ 

178 

179 django_command: "TyperCommand" 

180 children: t.List["Context"] 

181 _supplied_params: t.Dict[str, t.Any] 

182 

183 class ParamDict(dict): 

184 """ 

185 An extension of dict we use to block updates to parameters that were supplied 

186 when the command was invoked via call_command. This complexity is introduced 

187 by the hybrid parsing and option passing inherent to call_command. 

188 """ 

189 

190 def __init__(self, *args, supplied): 

191 super().__init__(*args) 

192 self.supplied = supplied 

193 

194 def __setitem__(self, key, value): 

195 if key not in self.supplied: 

196 super().__setitem__(key, value) 

197 

198 @property 

199 def supplied_params(self): 

200 """ 

201 Get the parameters that were supplied when the command was invoked via 

202 call_command, only the root context has these. 

203 """ 

204 if self.parent: 

205 return self.parent.supplied_params 

206 return getattr(self, "_supplied_params", {}) 

207 

208 def __init__( 

209 self, 

210 command: click.Command, # pylint: disable=redefined-outer-name 

211 parent: t.Optional["Context"] = None, 

212 django_command: t.Optional["TyperCommand"] = None, 

213 supplied_params: t.Optional[t.Dict[str, t.Any]] = None, 

214 **kwargs, 

215 ): 

216 super().__init__(command, parent=parent, **kwargs) 

217 if supplied_params: 

218 self._supplied_params = supplied_params 

219 self.django_command = django_command 

220 if not django_command and parent: 

221 self.django_command = parent.django_command 

222 

223 self.params = self.ParamDict( 

224 {**self.params, **self.supplied_params}, 

225 supplied=list(self.supplied_params.keys()), 

226 ) 

227 self.children = [] 

228 if parent: 

229 parent.children.append(self) 

230 

231 

232class DjangoAdapterMixin: # pylint: disable=too-few-public-methods 

233 context_class: t.Type[click.Context] = Context 

234 django_command: "TyperCommand" 

235 callback_is_method: bool = True 

236 param_converters: t.Dict[str, t.Callable[..., t.Any]] = {} 

237 

238 def common_params(self): 

239 return [] 

240 

241 def __init__( 

242 self, 

243 *args, 

244 callback: t.Optional[ # pylint: disable=redefined-outer-name 

245 t.Callable[..., t.Any] 

246 ], 

247 params: t.Optional[t.List[click.Parameter]] = None, 

248 **kwargs, 

249 ): 

250 params = params or [] 

251 self._callback = callback 

252 expected = [param.name for param in params[1:]] 

253 self_arg = params[0].name if params else "self" 

254 

255 def call_with_self(*args, **kwargs): 

256 ctx = click.get_current_context() 

257 return callback( 257 ↛ exitline 257 didn't jump to the function exit

258 *args, 

259 **{ 

260 # process supplied parameters incase they need type conversion 

261 param: self.param_converters.get(param, lambda _, value: value)( 

262 ctx, val 

263 ) 

264 if param in ctx.supplied_params 

265 else val 

266 for param, val in kwargs.items() 

267 if param in expected 

268 }, 

269 **( 

270 {self_arg: getattr(ctx, "django_command", None)} 

271 if self.callback_is_method 

272 else {} 

273 ), 

274 ) 

275 

276 super().__init__( # type: ignore 

277 *args, 

278 params=[ 

279 *(params[1:] if self.callback_is_method else params), 

280 *[ 

281 param 

282 for param in self.common_params() 

283 if param.name not in expected 

284 ], 

285 ], 

286 callback=call_with_self, 

287 **kwargs, 

288 ) 

289 self.param_converters = { 

290 param.name: param.process_value for param in self.params 

291 } 

292 

293 

294class TyperCommandWrapper(DjangoAdapterMixin, CoreTyperCommand): 

295 def common_params(self): 

296 if ( 

297 hasattr(self, "django_command") 

298 and self.django_command._num_commands < 2 

299 and not self.django_command._has_callback 

300 and not self.django_command._root_groups 

301 ): 

302 return [ 

303 param 

304 for param in _get_common_params() 

305 if param.name in (self.django_command.django_params or []) 

306 ] 

307 return super().common_params() 

308 

309 

310class TyperGroupWrapper(DjangoAdapterMixin, CoreTyperGroup): 

311 def common_params(self): 

312 if hasattr(self, "django_command") and self.django_command._has_callback: 

313 return [ 

314 param 

315 for param in _get_common_params() 

316 if param.name in (self.django_command.django_params or []) 

317 ] 

318 return super().common_params() 

319 

320 

321class GroupFunction(Typer): 

322 bound: bool = False 

323 django_command_cls: t.Type["TyperCommand"] 

324 _callback: t.Callable[..., t.Any] 

325 

326 def __get__(self, obj, obj_type=None): 

327 """ 

328 Our Typer app wrapper also doubles as a descriptor, so when 

329 it is accessed on the instance, we return the wrapped function 

330 so it may be called directly - but when accessed on the class 

331 the app itself is returned so it can modified by other decorators 

332 on the class and subclasses. 

333 """ 

334 if obj is None: 

335 return self 

336 return MethodType(self._callback, obj) 

337 

338 def __init__(self, *args, **kwargs): 

339 self._callback = kwargs["callback"] 

340 super().__init__(*args, **kwargs) 

341 

342 def bind(self, django_command_cls: t.Type["TyperCommand"]): 

343 self.django_command_cls = django_command_cls 

344 # the deepcopy is necessary for instances where classes derive 

345 # from Command classes and replace/extend commands on groups 

346 # defined in the base class - this avoids the extending class 

347 # polluting the base class's command tree 

348 self.django_command_cls.typer_app.add_typer(deepcopy(self)) 

349 

350 def callback(self, *args, **kwargs): 

351 raise NotImplementedError( 

352 _( 

353 "callback is not supported - the function decorated by group() is the callback." 

354 ) 

355 ) 

356 

357 def command( 

358 self, 

359 name: t.Optional[str] = None, 

360 *, 

361 cls: t.Type[TyperCommandWrapper] = TyperCommandWrapper, 

362 context_settings: t.Optional[t.Dict[t.Any, t.Any]] = None, 

363 help: t.Optional[str] = None, 

364 epilog: t.Optional[str] = None, 

365 short_help: t.Optional[str] = None, 

366 options_metavar: str = "[OPTIONS]", 

367 add_help_option: bool = True, 

368 no_args_is_help: bool = False, 

369 hidden: bool = False, 

370 deprecated: bool = False, 

371 # Rich settings 

372 rich_help_panel: t.Union[str, None] = Default(None), 

373 **kwargs, 

374 ): 

375 return super().command( 

376 name=name, 

377 cls=cls, 

378 context_settings=context_settings, 

379 help=help, 

380 epilog=epilog, 

381 short_help=short_help, 

382 options_metavar=options_metavar, 

383 add_help_option=add_help_option, 

384 no_args_is_help=no_args_is_help, 

385 hidden=hidden, 

386 deprecated=deprecated, 

387 rich_help_panel=rich_help_panel, 

388 **kwargs, 

389 ) 

390 

391 def group( 

392 self, 

393 name: t.Optional[str] = Default(None), 

394 cls: t.Type[TyperGroupWrapper] = TyperGroupWrapper, 

395 invoke_without_command: bool = Default(False), 

396 no_args_is_help: bool = Default(False), 

397 subcommand_metavar: t.Optional[str] = Default(None), 

398 chain: bool = Default(False), 

399 result_callback: t.Optional[t.Callable[..., t.Any]] = Default(None), 

400 # Command 

401 context_settings: t.Optional[t.Dict[t.Any, t.Any]] = Default(None), 

402 help: t.Optional[str] = Default(None), 

403 epilog: t.Optional[str] = Default(None), 

404 short_help: t.Optional[str] = Default(None), 

405 options_metavar: str = Default("[OPTIONS]"), 

406 add_help_option: bool = Default(True), 

407 hidden: bool = Default(False), 

408 deprecated: bool = Default(False), 

409 # Rich settings 

410 rich_help_panel: t.Union[str, None] = Default(None), 

411 **kwargs, 

412 ): 

413 def create_app(func: CommandFunctionType): 

414 grp = GroupFunction( 

415 name=name, 

416 cls=cls, 

417 invoke_without_command=invoke_without_command, 

418 no_args_is_help=no_args_is_help, 

419 subcommand_metavar=subcommand_metavar, 

420 chain=chain, 

421 result_callback=result_callback, 

422 callback=func, 

423 context_settings=context_settings, 

424 help=help, 

425 epilog=epilog, 

426 short_help=short_help, 

427 options_metavar=options_metavar, 

428 add_help_option=add_help_option, 

429 hidden=hidden, 

430 deprecated=deprecated, 

431 rich_help_panel=rich_help_panel, 

432 **kwargs, 

433 ) 

434 self.add_typer(grp) 

435 grp.bound = True 

436 return grp 

437 

438 return create_app 

439 

440 

441def initialize( # pylint: disable=too-mt.Any-local-variables 

442 name: t.Optional[str] = Default(None), 

443 *, 

444 cls: t.Type[TyperGroupWrapper] = TyperGroupWrapper, 

445 invoke_without_command: bool = Default(False), 

446 no_args_is_help: bool = Default(False), 

447 subcommand_metavar: t.Optional[str] = Default(None), 

448 chain: bool = Default(False), 

449 result_callback: t.Optional[t.Callable[..., t.Any]] = Default(None), 

450 # Command 

451 context_settings: t.Optional[t.Dict[t.Any, t.Any]] = Default(None), 

452 help: t.Optional[str] = Default(None), 

453 epilog: t.Optional[str] = Default(None), 

454 short_help: t.Optional[str] = Default(None), 

455 options_metavar: str = Default("[OPTIONS]"), 

456 add_help_option: bool = Default(True), 

457 hidden: bool = Default(False), 

458 deprecated: bool = Default(False), 

459 # Rich settings 

460 rich_help_panel: t.Union[str, None] = Default(None), 

461 **kwargs, 

462): 

463 def decorator(func: CommandFunctionType): 

464 func._typer_callback_ = lambda cmd, **extra: cmd.typer_app.callback( 

465 name=name or extra.pop("name", None), 

466 cls=type("_AdaptedCallback", (cls,), {"django_command": cmd}), 

467 invoke_without_command=invoke_without_command, 

468 subcommand_metavar=subcommand_metavar, 

469 chain=chain, 

470 result_callback=result_callback, 

471 context_settings=context_settings, 

472 help=cmd.typer_app.info.help or help, 

473 epilog=epilog, 

474 short_help=short_help, 

475 options_metavar=options_metavar, 

476 add_help_option=add_help_option, 

477 no_args_is_help=no_args_is_help, 

478 hidden=hidden, 

479 deprecated=deprecated, 

480 rich_help_panel=rich_help_panel, 

481 **kwargs, 

482 **extra, 

483 )(func) 

484 return func 

485 

486 return decorator 

487 

488 

489def command( 

490 name: t.Optional[str] = None, 

491 *args, 

492 cls: t.Type[TyperCommandWrapper] = TyperCommandWrapper, 

493 context_settings: t.Optional[t.Dict[t.Any, t.Any]] = None, 

494 help: t.Optional[str] = None, 

495 epilog: t.Optional[str] = None, 

496 short_help: t.Optional[str] = None, 

497 options_metavar: str = "[OPTIONS]", 

498 add_help_option: bool = True, 

499 no_args_is_help: bool = False, 

500 hidden: bool = False, 

501 deprecated: bool = False, 

502 # Rich settings 

503 rich_help_panel: t.Union[str, None] = Default(None), 

504 **kwargs, 

505): 

506 def decorator(func: CommandFunctionType): 

507 func._typer_command_ = lambda cmd, **extra: cmd.typer_app.command( 

508 name=name or extra.pop("name", None), 

509 *args, 

510 cls=type("_AdaptedCommand", (cls,), {"django_command": cmd}), 

511 context_settings=context_settings, 

512 help=help, 

513 epilog=epilog, 

514 short_help=short_help, 

515 options_metavar=options_metavar, 

516 add_help_option=add_help_option, 

517 no_args_is_help=no_args_is_help, 

518 hidden=hidden, 

519 deprecated=deprecated, 

520 # Rich settings 

521 rich_help_panel=rich_help_panel, 

522 **kwargs, 

523 **extra, 

524 )(func) 

525 return func 

526 

527 return decorator 

528 

529 

530def group( 

531 name: t.Optional[str] = Default(None), 

532 cls: t.Type[TyperGroupWrapper] = TyperGroupWrapper, 

533 invoke_without_command: bool = Default(False), 

534 no_args_is_help: bool = Default(False), 

535 subcommand_metavar: t.Optional[str] = Default(None), 

536 chain: bool = Default(False), 

537 result_callback: t.Optional[t.Callable[..., t.Any]] = Default(None), 

538 # Command 

539 context_settings: t.Optional[t.Dict[t.Any, t.Any]] = Default(None), 

540 help: t.Optional[str] = Default(None), 

541 epilog: t.Optional[str] = Default(None), 

542 short_help: t.Optional[str] = Default(None), 

543 options_metavar: str = Default("[OPTIONS]"), 

544 add_help_option: bool = Default(True), 

545 hidden: bool = Default(False), 

546 deprecated: bool = Default(False), 

547 # Rich settings 

548 rich_help_panel: t.Union[str, None] = Default(None), 

549 **kwargs, 

550): 

551 def create_app(func: CommandFunctionType): 

552 grp = GroupFunction( 

553 name=name, 

554 cls=cls, 

555 invoke_without_command=invoke_without_command, 

556 no_args_is_help=no_args_is_help, 

557 subcommand_metavar=subcommand_metavar, 

558 chain=chain, 

559 result_callback=result_callback, 

560 callback=func, 

561 context_settings=context_settings, 

562 help=help, 

563 epilog=epilog, 

564 short_help=short_help, 

565 options_metavar=options_metavar, 

566 add_help_option=add_help_option, 

567 hidden=hidden, 

568 deprecated=deprecated, 

569 rich_help_panel=rich_help_panel, 

570 **kwargs, 

571 ) 

572 return grp 

573 

574 return create_app 

575 

576 

577class _TyperCommandMeta(type): 

578 def __new__( 

579 mcs, 

580 name, 

581 bases, 

582 attrs, 

583 cls: t.Optional[t.Type[CoreTyperGroup]] = TyperGroupWrapper, 

584 invoke_without_command: bool = Default(False), 

585 no_args_is_help: bool = Default(False), 

586 subcommand_metavar: t.Optional[str] = Default(None), 

587 chain: bool = Default(False), 

588 result_callback: t.Optional[t.Callable[..., t.Any]] = Default(None), 

589 context_settings: t.Optional[t.Dict[t.Any, t.Any]] = Default(None), 

590 callback: t.Optional[t.Callable[..., t.Any]] = Default(None), 

591 help: t.Optional[str] = Default(None), 

592 epilog: t.Optional[str] = Default(None), 

593 short_help: t.Optional[str] = Default(None), 

594 options_metavar: str = Default("[OPTIONS]"), 

595 add_help_option: bool = Default(True), 

596 hidden: bool = Default(False), 

597 deprecated: bool = Default(False), 

598 rich_markup_mode: MarkupMode = None, 

599 rich_help_panel: t.Union[str, None] = Default(None), 

600 pretty_exceptions_enable: bool = Default(True), 

601 pretty_exceptions_show_locals: bool = Default(True), 

602 pretty_exceptions_short: bool = Default(True), 

603 ): 

604 """ 

605 This method is called when a new class is created. 

606 """ 

607 try: 

608 TyperCommand 

609 is_base_init = False 

610 except NameError: 

611 is_base_init = True 

612 typer_app = None 

613 

614 if not is_base_init: 

615 # conform the pretty exception defaults to the settings traceback config 

616 tb_config = traceback_config() 

617 if isinstance(pretty_exceptions_enable, DefaultPlaceholder): 

618 pretty_exceptions_enable = isinstance(tb_config, dict) 

619 

620 tb_config = tb_config or {} 

621 if isinstance(pretty_exceptions_show_locals, DefaultPlaceholder): 

622 pretty_exceptions_show_locals = tb_config.get( 

623 "show_locals", pretty_exceptions_show_locals 

624 ) 

625 if isinstance(pretty_exceptions_short, DefaultPlaceholder): 

626 pretty_exceptions_short = tb_config.get( 

627 "short", pretty_exceptions_short 

628 ) 

629 

630 typer_app = Typer( 

631 name=mcs.__module__.rsplit(".", maxsplit=1)[-1], 

632 cls=cls, 

633 help=help or attrs.get("help", Default(None)), 

634 invoke_without_command=invoke_without_command, 

635 no_args_is_help=no_args_is_help, 

636 subcommand_metavar=subcommand_metavar, 

637 chain=chain, 

638 result_callback=result_callback, 

639 context_settings=context_settings, 

640 callback=callback, 

641 epilog=epilog, 

642 short_help=short_help, 

643 options_metavar=options_metavar, 

644 add_help_option=add_help_option, 

645 hidden=hidden, 

646 deprecated=deprecated, 

647 add_completion=False, # see autocomplete command instead! 

648 rich_markup_mode=rich_markup_mode, 

649 rich_help_panel=rich_help_panel, 

650 pretty_exceptions_enable=pretty_exceptions_enable, 

651 pretty_exceptions_show_locals=pretty_exceptions_show_locals, 

652 pretty_exceptions_short=pretty_exceptions_short, 

653 ) 

654 

655 def handle(self, *args, **options): 

656 return self.typer_app( 

657 args=args, 

658 standalone_mode=False, 

659 supplied_params=options, 

660 django_command=self, 

661 prog_name=f"{sys.argv[0]} {self.typer_app.info.name}", 

662 ) 

663 

664 attrs = { 

665 "_handle": attrs.pop("handle", None), 

666 **attrs, 

667 "handle": handle, 

668 "typer_app": typer_app, 

669 } 

670 

671 return super().__new__(mcs, name, bases, attrs) 

672 

673 def __init__(cls, name, bases, attrs, **kwargs): 

674 """ 

675 This method is called after a new class is created. 

676 """ 

677 if cls.typer_app is not None: 

678 cls.typer_app.info.name = cls.__module__.rsplit(".", maxsplit=1)[-1] 

679 

680 def get_ctor(attr): 

681 return getattr( 

682 attr, "_typer_command_", getattr(attr, "_typer_callback_", None) 

683 ) 

684 

685 # because we're mapping a non-class based interface onto a class based 

686 # interface, we have to handle this class mro stuff manually here 

687 for cmd_cls, cls_attrs in [ 

688 *[(base, vars(base)) for base in reversed(bases)], 

689 (cls, attrs), 

690 ]: 

691 if not issubclass(cmd_cls, TyperCommand) or cmd_cls is TyperCommand: 

692 continue 

693 for attr in [*cls_attrs.values(), cls._handle]: 

694 cls._num_commands += hasattr(attr, "_typer_command_") 

695 cls._has_callback |= hasattr(attr, "_typer_callback_") 

696 if isinstance(attr, GroupFunction) and not attr.bound: 

697 attr.bind(cls) 

698 cls._root_groups += 1 

699 

700 if cmd_cls._handle: 

701 ctor = get_ctor(cmd_cls._handle) 

702 if ctor: 

703 ctor(cls, name=cls.typer_app.info.name) 

704 else: 

705 cls._num_commands += 1 

706 cls.typer_app.command( 

707 cls.typer_app.info.name, 

708 cls=type( 

709 "_AdaptedCommand", 

710 (TyperCommandWrapper,), 

711 {"django_command": cls}, 

712 ), 

713 help=cls.typer_app.info.help or None, 

714 )(cmd_cls._handle) 

715 

716 for attr in cls_attrs.values(): 

717 (get_ctor(attr) or (lambda _: None))(cls) 

718 

719 if ( 

720 cls._num_commands > 1 or cls._root_groups > 0 

721 ) and not cls.typer_app.registered_callback: 

722 cls.typer_app.callback( 

723 cls=type( 

724 "_AdaptedCallback", 

725 (TyperGroupWrapper,), 

726 {"django_command": cls, "callback_is_method": False}, 

727 ) 

728 )(_common_options) 

729 

730 super().__init__(name, bases, attrs, **kwargs) 

731 

732 

733class TyperParser: 

734 @dataclass(frozen=True) 

735 class Action: 

736 dest: str 

737 nargs: int 

738 required: bool = False 

739 

740 @property 

741 def option_strings(self): 

742 return [self.dest] 

743 

744 _actions: t.List[t.Any] 

745 _mutually_exclusive_groups: t.List[t.Any] = [] 

746 

747 django_command: "TyperCommand" 

748 prog_name: str 

749 subcommand: str 

750 

751 def __init__(self, django_command: "TyperCommand", prog_name, subcommand): 

752 self._actions = [] 

753 self.django_command = django_command 

754 self.prog_name = prog_name 

755 self.subcommand = subcommand 

756 

757 def populate_params(node): 

758 for param in node.command.params: 

759 self._actions.append(self.Action(param.name, param.nargs)) 

760 for child in node.children.values(): 

761 populate_params(child) 

762 

763 populate_params(self.django_command.command_tree) 

764 

765 def print_help(self, *command_path: str): 

766 self.django_command.command_tree.context.info_name = ( 

767 f"{self.prog_name} {self.subcommand}" 

768 ) 

769 command_node = self.django_command.get_subcommand(*command_path) 

770 with contextlib.redirect_stdout(self.django_command.stdout): 

771 command_node.print_help() 

772 

773 def parse_args(self, args=None, namespace=None): 

774 try: 

775 cmd = get_typer_command(self.django_command.typer_app) 

776 with cmd.make_context( 

777 info_name=f"{self.prog_name} {self.subcommand}", 

778 django_command=self.django_command, 

779 args=list(args or []), 

780 ) as ctx: 

781 params = ctx.params 

782 

783 # def discover_parsed_args(ctx): 

784 # # todo is this necessary? 

785 # for child in ctx.children: 

786 # discover_parsed_args(child) 

787 # params.update(child.params) 

788 

789 # discover_parsed_args(ctx) 

790 

791 return _ParsedArgs(args=args or [], **{**COMMON_DEFAULTS, **params}) 

792 except click.exceptions.Exit: 

793 sys.exit() 

794 

795 def add_argument(self, *args, **kwargs): 

796 raise NotImplementedError(_("add_argument() is not supported")) 

797 

798 

799class TyperCommand(BaseCommand, metaclass=_TyperCommandMeta): 

800 """ 

801 A BaseCommand extension class that uses the Typer library to parse 

802 arguments and options. This class adapts BaseCommand using a light touch 

803 that relies on most of the original BaseCommand implementation to handle 

804 default arguments and behaviors. 

805 

806 The goal of django_typer is to provide full typer style functionality 

807 while maintaining compatibility with the Django management command system. 

808 This means that the BaseCommand interface is preserved and the Typer 

809 interface is added on top of it. This means that this code base is more 

810 robust to changes in the Django management command system - because most 

811 of the base class functionality is preserved but mt.Any typer and click 

812 internals are used directly to achieve this. We rely on robust CI to 

813 catch breaking changes in the click/typer dependencies. 

814 

815 

816 TODO - there is a problem with subcommand resolution and make_context() 

817 that needs to be addressed. Need to understand exactly how click/typer 

818 does this so it can be broken apart and be interface compatible with 

819 Django. Also when are callbacks invoked, etc - during make_context? or 

820 invoke? There is a complexity here with execute(). 

821 

822 TODO - lazy loaded command overrides. 

823 Should be able to attach to another TyperCommand like this and conflicts would resolve 

824 based on INSTALLED_APP precedence. 

825 

826 class Command(TyperCommand, attach='app_label.command_name.subcommand1.subcommand2'): 

827 ... 

828 """ 

829 

830 # we do not use verbosity because the base command does not do anything with it 

831 # if users want to use a verbosity flag like the base django command adds 

832 # they can use the type from django_typer.types.Verbosity 

833 django_params: t.Optional[ 

834 t.List[ 

835 t.Literal[ 

836 "version", 

837 "settings", 

838 "pythonpath", 

839 "traceback", 

840 "no_color", 

841 "force_color", 

842 "skip_checks", 

843 ] 

844 ] 

845 ] = [name for name in COMMON_DEFAULTS.keys() if name != "verbosity"] 

846 

847 class CommandNode: 

848 name: str 

849 command: t.Union[TyperCommandWrapper, TyperGroupWrapper] 

850 context: TyperContext 

851 parent: t.Optional["CommandNode"] = None 

852 children: t.Dict[str, "CommandNode"] 

853 

854 def __init__( 

855 self, 

856 name: str, 

857 command: t.Union[TyperCommandWrapper, TyperGroupWrapper], 

858 context: TyperContext, 

859 parent: t.Optional["CommandNode"] = None, 

860 ): 

861 self.name = name 

862 self.command = command 

863 self.context = context 

864 self.parent = parent 

865 self.children = {} 

866 

867 def print_help(self): 

868 self.command.get_help(self.context) 

869 

870 def get_command(self, *command_path: str): 

871 if not command_path: 

872 return self 

873 try: 

874 return self.children[command_path[0]].get_command(*command_path[1:]) 

875 except KeyError: 

876 raise ValueError(f'No such command "{command_path[0]}"') 

877 

878 typer_app: t.Optional[Typer] = None 

879 _num_commands: int = 0 

880 _has_callback: bool = False 

881 _root_groups: int = 0 

882 

883 command_tree: CommandNode 

884 

885 def __init__( 

886 self, 

887 stdout: t.Optional[t.IO[str]] = None, 

888 stderr: t.Optional[t.IO[str]] = None, 

889 no_color: bool = False, 

890 force_color: bool = False, 

891 **kwargs, 

892 ): 

893 super().__init__( 

894 stdout=stdout, 

895 stderr=stderr, 

896 no_color=no_color, 

897 force_color=force_color, 

898 **kwargs, 

899 ) 

900 self.command_tree = self._build_cmd_tree(get_typer_command(self.typer_app)) 

901 

902 def get_subcommand(self, *command_path: str): 

903 return self.command_tree.get_command(*command_path) 

904 

905 def _filter_commands( 

906 self, ctx: TyperContext, cmd_filter: t.Optional[t.List[str]] = None 

907 ): 

908 return sorted( 

909 [ 

910 cmd 

911 for name, cmd in getattr( 

912 ctx.command, 

913 "commands", 

914 { 

915 name: ctx.command.get_command(ctx, name) 

916 for name in getattr(ctx.command, "list_commands", lambda _: [])( 

917 ctx 

918 ) 

919 or cmd_filter 

920 or [] 

921 }, 

922 ).items() 

923 if not cmd_filter or name in cmd_filter 

924 ], 

925 key=lambda item: item.name, 

926 ) 

927 

928 def _build_cmd_tree( 

929 self, 

930 cmd: CoreTyperCommand, 

931 parent: t.Optional[Context] = None, 

932 info_name: t.Optional[str] = None, 

933 node: t.Optional[CommandNode] = None, 

934 ): 

935 ctx = Context(cmd, info_name=info_name, parent=parent, django_command=self) 

936 current = self.CommandNode(cmd.name, cmd, ctx, parent=node) 

937 if node: 

938 node.children[cmd.name] = current 

939 for cmd in self._filter_commands(ctx): 

940 self._build_cmd_tree(cmd, parent=ctx, info_name=cmd.name, node=current) 

941 return current 

942 

943 def __init_subclass__(cls, **_): 

944 """Avoid passing typer arguments up the subclass init chain""" 

945 return super().__init_subclass__() 

946 

947 def create_parser(self, prog_name: str, subcommand: str, **_): 

948 return TyperParser(self, prog_name, subcommand) 

949 

950 def print_help(self, prog_name: str, subcommand: str, *cmd_path: str): 

951 """ 

952 Print the help message for this command, derived from 

953 ``self.usage()``. 

954 """ 

955 parser = self.create_parser(prog_name, subcommand) 

956 parser.print_help(*cmd_path) 

957 

958 def __call__(self, *args, **kwargs): 

959 """ 

960 Call this command's handle() directly. 

961 """ 

962 if getattr(self, "_handle", None): 

963 return self._handle(*args, **kwargs) 

964 raise NotImplementedError( 

965 _( 

966 "{cls} does not implement handle(), you must call the other command " 

967 "functions directly." 

968 ).format(cls=self.__class__) 

969 )