55
66import dataclasses
77import itertools
8+ import json
89import os
910from collections import defaultdict
1011from pathlib import Path
1920 Command ,
2021 Configuration ,
2122 ExitStyle ,
23+ ExtraRequirements ,
2224 Factor ,
2325 FactorDescription ,
2426 Group ,
@@ -48,6 +50,24 @@ def _assert_dict_str_keys(obj: Any, *, path: str) -> dict[str, Any]:
4850 return cast ("dict[str, Any]" , obj )
4951
5052
53+ def _parse_when (data : dict [str , Any ], table_path : str ) -> Marker | None :
54+ raw_when = data .pop ("when" , None )
55+ if raw_when and not isinstance (raw_when , str ):
56+ raise InvalidModelError (
57+ f"The { table_path } `when` value must be a string, "
58+ f"given: { raw_when } of type { type (raw_when )} ."
59+ )
60+ try :
61+ return Marker (raw_when ) if raw_when else None
62+ except InvalidMarker as e :
63+ raise InvalidModelError (
64+ f"The { table_path } `when` value is not a valid marker "
65+ f"expression: { e } { os .linesep } "
66+ f"See: https://packaging.python.org/en/latest/specifications/"
67+ f"dependency-specifiers/#environment-markers"
68+ )
69+
70+
5171def _parse_commands (
5272 commands : dict [str , Any ] | None ,
5373 required_steps : dict [str , list [tuple [Factor , ...]]],
@@ -154,21 +174,7 @@ def _parse_commands(
154174 )
155175 factor_descriptions [Factor (factor_name )] = factor_desc
156176
157- raw_when = command .pop ("when" , None )
158- if raw_when and not isinstance (raw_when , str ):
159- raise InvalidModelError (
160- f"The [tool.dev-cmd.commands.{ name } ] `when` value must be a string, "
161- f"given: { raw_when } of type { type (raw_when )} ."
162- )
163- try :
164- when = Marker (raw_when ) if raw_when else None
165- except InvalidMarker as e :
166- raise InvalidModelError (
167- f"The [tool.dev-cmd.commands.{ name } ] `when` value is not a valid marker "
168- f"expression: { e } { os .linesep } "
169- f"See: https://packaging.python.org/en/latest/specifications/"
170- f"dependency-specifiers/#environment-markers"
171- )
177+ when = _parse_when (command , table_path = f"[tool.dev-cmd.commands.{ name } ]" )
172178
173179 if data :
174180 raise InvalidModelError (
@@ -370,21 +376,7 @@ def _parse_tasks(
370376 f"given: { description } of type { type (description )} ."
371377 )
372378
373- raw_when = data .pop ("when" , None )
374- if raw_when and not isinstance (raw_when , str ):
375- raise InvalidModelError (
376- f"The [tool.dev-cmd.tasks.{ name } ] `when` value must be a string, "
377- f"given: { raw_when } of type { type (raw_when )} ."
378- )
379- try :
380- when = Marker (raw_when ) if raw_when else None
381- except InvalidMarker as e :
382- raise InvalidModelError (
383- f"The [tool.dev-cmd.tasks.{ name } ] `when` value is not a valid marker "
384- f"expression: { e } { os .linesep } "
385- f"See: https://packaging.python.org/en/latest/specifications/"
386- f"dependency-specifiers/#environment-markers"
387- )
379+ when = _parse_when (data , table_path = f"[tool.dev-cmd.tasks.{ name } ]" )
388380
389381 if data :
390382 raise InvalidModelError (
@@ -498,11 +490,19 @@ def _parse_grace_period(grace_period: Any) -> float | None:
498490 return float (grace_period )
499491
500492
501- def _parse_python (python : Any ) -> PythonConfig | None :
493+ def _parse_python (
494+ python : str | None , python_config_data : Any , pyproject_data : dict [str , Any ]
495+ ) -> Venv | None :
502496 if python is None :
503497 return None
498+ if not python_config_data :
499+ raise InvalidArgumentError (
500+ f"You requested a custom Python of { python } but have not configured "
501+ f"`[tool.dev-cmd.python]`.\n "
502+ f"See: https://github.com/jsirois/dev-cmd/blob/main/README.md#custom-pythons"
503+ )
504504
505- python_data = _assert_dict_str_keys (python , path = "[tool.dev-cmd.python]" )
505+ python_data = _assert_dict_str_keys (python_config_data , path = "[tool.dev-cmd.python]" )
506506 requirements = python_data .pop ("requirements" , None )
507507 if requirements is None :
508508 raise InvalidModelError (
@@ -527,32 +527,131 @@ def _parse_python(python: Any) -> PythonConfig | None:
527527 )
528528
529529 extra_requirements_data = requirements_data .pop ("extra-requirements" , None )
530+ input_keys_data = requirements_data .pop ("input-keys" , None )
530531 input_files_data = requirements_data .pop ("input-files" , None )
531532 if requirements_data :
532533 raise InvalidModelError (
533534 f"Unexpected configuration keys in the [tool.dev-cmd.python.requirements] table: "
534535 f"{ ' ' .join (requirements_data )} "
535536 )
536537
537- input_files = (
538+ input_files = set (
538539 _assert_list_str (input_files_data , path = "[tool.dev-cmd.python.requirements] `input-files`" )
539540 if input_files_data is not None
540- else [ "pyproject.toml" ]
541+ else ()
541542 )
542543
543- extra_requirements = (
544- _assert_list_str (
545- extra_requirements_data , path = "[tool.dev-cmd.python.requirements] `extra-requirements`"
544+ extra_requirements = ExtraRequirements .create ()
545+ if extra_requirements_data :
546+ if not isinstance (extra_requirements_data , list ):
547+ raise InvalidModelError (
548+ f"Expected [tool.dev-cmd.python.requirements] `extra-requirements` to be either a list "
549+ f"of strings or a list of tables but given: { extra_requirements_data } of type "
550+ f"{ type (extra_requirements_data )} ."
551+ )
552+
553+ if all (isinstance (item , dict ) for item in extra_requirements_data ):
554+ marker_environment = venv .marker_environment (python )
555+ activated_index : int | None = None
556+ for index , entry in enumerate (extra_requirements_data ):
557+ entry_data = cast (dict [str , Any ], entry )
558+ when = _parse_when (
559+ entry_data ,
560+ table_path = f"[tool.dev-cmd.python.requirements] `extra-requirements[{ index } ]`" ,
561+ )
562+ if when and not when .evaluate (marker_environment ):
563+ continue
564+ if activated_index is not None :
565+ raise InvalidModelError (
566+ f"The `extra-requirements` entries at index { activated_index } and { index } "
567+ f"are both active.{ os .linesep } "
568+ f"You can define multiple `extra-requirements`, but you must ensure that "
569+ f"they all define mutually exclusive `when` marker expressions."
570+ )
571+
572+ reqs_data = entry_data .pop ("reqs" , None )
573+ pip_req = entry_data .pop ("pip-req" , None )
574+ install_ops_data = entry_data .pop ("install-opts" , None )
575+
576+ if entry_data :
577+ raise InvalidModelError (
578+ f"Unexpected configuration keys in the [tool.dev-cmd.python.requirements] "
579+ f"`extra-requirements[{ index } ] table: { ' ' .join (entry_data )} "
580+ )
581+
582+ reqs : list [str ] | None = None
583+ if reqs_data :
584+ reqs = _assert_list_str (
585+ reqs_data ,
586+ path = f"[tool.dev-cmd.python.requirements] `extra-requirements[{ index } ].reqs`" ,
587+ )
588+
589+ if pip_req and not isinstance (pip_req , str ):
590+ raise InvalidModelError (
591+ f"The [tool.dev-cmd.python.requirements] "
592+ f"`extra-requirements[{ index } ].pip-req` value must be a string, but given: "
593+ f"{ pip_req } of type { type (pip_req )} ."
594+ )
595+
596+ install_opts : list [str ] | None = None
597+ if install_ops_data :
598+ install_opts = _assert_list_str (
599+ install_ops_data ,
600+ path = (
601+ f"[tool.dev-cmd.python.requirements] "
602+ f"`extra-requirements[{ index } ].install-opts`"
603+ ),
604+ )
605+
606+ extra_requirements = ExtraRequirements .create (
607+ reqs = reqs , pip_req = pip_req , install_opts = install_opts
608+ )
609+ activated_index = index
610+ else :
611+ extra_requirements = ExtraRequirements .create (
612+ reqs = _assert_list_str (
613+ extra_requirements_data ,
614+ path = "[tool.dev-cmd.python.requirements] `extra-requirements`" ,
615+ ),
616+ )
617+
618+ input_object = {
619+ "extra_requirements" : {
620+ "reqs" : extra_requirements .reqs ,
621+ "pip-req" : extra_requirements .pip_req ,
622+ "install-opts" : extra_requirements .install_opts ,
623+ }
624+ }
625+ if input_keys_data is None or input_keys_data :
626+ input_files .discard ("pyproject.toml" )
627+ input_keys = (
628+ _assert_list_str (
629+ input_keys_data , path = "[tool.dev-cmd.python.requirements] `input-keys`"
630+ )
631+ if input_keys_data is not None
632+ else ["build-system" , "project" , "project.optional-dependencies" ]
546633 )
547- if extra_requirements_data is not None
548- else ["-e" , "./" ]
549- )
634+ input_item_data : dict [str , Any ] = {}
635+ for key in input_keys :
636+ value = pyproject_data
637+ for component in key .split ("." ):
638+ value = value .get (component , None )
639+ if value is None :
640+ raise InvalidModelError (
641+ f"The [tool.dev-cmd.python.requirements] `input-keys` key of { key } could "
642+ f"not be found in pyproject.toml."
643+ )
644+ input_item_data [key ] = value
645+ input_object ["input-keys" ] = input_item_data
646+ input_data = json .dumps (input_object , sort_keys = True ).encode ()
550647
551- return PythonConfig (
648+ python_config = PythonConfig (
649+ input_data = input_data ,
552650 input_files = tuple (input_files ),
553651 requirements_export_command = tuple (export_command ),
554- extra_requirements = tuple ( extra_requirements ) ,
652+ extra_requirements = extra_requirements ,
555653 )
654+ return venv .ensure (python_config , python )
556655
557656
558657def _iter_all_required_step_names (
@@ -597,17 +696,13 @@ def parse_dev_config(
597696 f"[tool.dev-cmd] table in { pyproject_toml } : { e } "
598697 )
599698
600- python_config = _parse_python (dev_cmd_data .pop ("python" , None ))
601- python_venv : Venv | None = None
602699 marker_environment : dict [str , str ] | None = None
603- if requested_python :
604- if not python_config :
605- raise InvalidArgumentError (
606- f"You requested a custom Python of { requested_python } but have not configured "
607- f"`[tool.dev-cmd.python]`.\n "
608- f"See: https://github.com/jsirois/dev-cmd/blob/main/README.md#custom-pythons"
609- )
610- python_venv = venv .ensure (python_config , requested_python )
700+ python_venv = _parse_python (
701+ python = requested_python ,
702+ python_config_data = dev_cmd_data .pop ("python" , None ),
703+ pyproject_data = pyproject_data ,
704+ )
705+ if python_venv :
611706 marker_environment = dict (python_venv .marker_environment )
612707
613708 def pop_dict (key : str , * , path : str ) -> dict [str , Any ] | None :
0 commit comments