jazzoo 0.1.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.
Files changed (194) hide show
  1. jazzoo/__init__.py +57 -0
  2. jazzoo/aliases/__init__.py +0 -0
  3. jazzoo/aliases/errors.py +5 -0
  4. jazzoo/aliases/rules/__init__.py +0 -0
  5. jazzoo/aliases/rules/expand_alias.py +38 -0
  6. jazzoo/aliases/rules/get_alias_config_file.py +7 -0
  7. jazzoo/aliases/rules/read_aliases.py +36 -0
  8. jazzoo/blocks/__init__.py +0 -0
  9. jazzoo/blocks/actions/__init__.py +0 -0
  10. jazzoo/blocks/actions/run_block.py +80 -0
  11. jazzoo/blocks/models/__init__.py +0 -0
  12. jazzoo/blocks/models/block.py +21 -0
  13. jazzoo/blocks/rules/__init__.py +0 -0
  14. jazzoo/commands/__init__.py +19 -0
  15. jazzoo/commands/__main__.py +5 -0
  16. jazzoo/commands/actions/__init__.py +0 -0
  17. jazzoo/commands/actions/confirm_command.py +36 -0
  18. jazzoo/commands/actions/run.py +100 -0
  19. jazzoo/commands/actions/run_docker.py +67 -0
  20. jazzoo/commands/entrypoints/__init__.py +0 -0
  21. jazzoo/commands/entrypoints/jaz_run.py +76 -0
  22. jazzoo/commands/errors.py +26 -0
  23. jazzoo/commands/models/__init__.py +0 -0
  24. jazzoo/commands/models/command_result.py +15 -0
  25. jazzoo/commands/rules/__init__.py +0 -0
  26. jazzoo/commands/rules/find_executable.py +18 -0
  27. jazzoo/commands/rules/has_terminal.py +9 -0
  28. jazzoo/commands/rules/is_confirm_on.py +7 -0
  29. jazzoo/commands/rules/needs_tty.py +9 -0
  30. jazzoo/commands/settings.py +7 -0
  31. jazzoo/commands/utils/__init__.py +0 -0
  32. jazzoo/commands/utils/dataclass.py +34 -0
  33. jazzoo/commands/utils/format_command.py +9 -0
  34. jazzoo/commands/utils/ignoring_sigint.py +18 -0
  35. jazzoo/config/__init__.py +17 -0
  36. jazzoo/config/_vendor/__init__.py +0 -0
  37. jazzoo/config/_vendor/vendor.txt +1 -0
  38. jazzoo/config/_vendor/yaml/LICENSE +20 -0
  39. jazzoo/config/_vendor/yaml/__init__.py +390 -0
  40. jazzoo/config/_vendor/yaml/composer.py +139 -0
  41. jazzoo/config/_vendor/yaml/constructor.py +748 -0
  42. jazzoo/config/_vendor/yaml/dumper.py +62 -0
  43. jazzoo/config/_vendor/yaml/emitter.py +1137 -0
  44. jazzoo/config/_vendor/yaml/error.py +75 -0
  45. jazzoo/config/_vendor/yaml/events.py +86 -0
  46. jazzoo/config/_vendor/yaml/loader.py +63 -0
  47. jazzoo/config/_vendor/yaml/nodes.py +49 -0
  48. jazzoo/config/_vendor/yaml/parser.py +589 -0
  49. jazzoo/config/_vendor/yaml/reader.py +185 -0
  50. jazzoo/config/_vendor/yaml/representer.py +389 -0
  51. jazzoo/config/_vendor/yaml/resolver.py +227 -0
  52. jazzoo/config/_vendor/yaml/scanner.py +1435 -0
  53. jazzoo/config/_vendor/yaml/serializer.py +111 -0
  54. jazzoo/config/_vendor/yaml/tokens.py +104 -0
  55. jazzoo/config/errors.py +14 -0
  56. jazzoo/config/models/__init__.py +0 -0
  57. jazzoo/config/models/loaded_config.py +17 -0
  58. jazzoo/config/rules/__init__.py +0 -0
  59. jazzoo/config/rules/expand_include_path.py +73 -0
  60. jazzoo/config/rules/get_config.py +26 -0
  61. jazzoo/config/rules/get_loaded_config.py +63 -0
  62. jazzoo/config/rules/get_value.py +199 -0
  63. jazzoo/config/rules/prepare_config.py +5 -0
  64. jazzoo/config/rules/read_config_file.py +30 -0
  65. jazzoo/config/rules/read_config_layers.py +58 -0
  66. jazzoo/config/rules/split_config_path.py +7 -0
  67. jazzoo/config/settings.py +6 -0
  68. jazzoo/config/utils/__init__.py +0 -0
  69. jazzoo/config/utils/dataclass.py +34 -0
  70. jazzoo/config/utils/format_config_path.py +5 -0
  71. jazzoo/config/utils/merge_config.py +11 -0
  72. jazzoo/config/utils/render_scalar.py +17 -0
  73. jazzoo/config/utils/split_expressions.py +59 -0
  74. jazzoo/enums.py +15 -0
  75. jazzoo/environment/__init__.py +0 -0
  76. jazzoo/environment/actions/__init__.py +0 -0
  77. jazzoo/environment/actions/load_config.py +26 -0
  78. jazzoo/environment/actions/load_env.py +30 -0
  79. jazzoo/environment/utils/__init__.py +0 -0
  80. jazzoo/environment/utils/parse_dotenv.py +17 -0
  81. jazzoo/errors.py +43 -0
  82. jazzoo/expressions/__init__.py +0 -0
  83. jazzoo/expressions/actions/__init__.py +0 -0
  84. jazzoo/expressions/actions/evaluate.py +17 -0
  85. jazzoo/expressions/actions/evaluate_expression.py +344 -0
  86. jazzoo/expressions/models/__init__.py +0 -0
  87. jazzoo/expressions/models/boolean.py +25 -0
  88. jazzoo/expressions/models/literal.py +11 -0
  89. jazzoo/expressions/models/path_join.py +12 -0
  90. jazzoo/expressions/models/pipe.py +13 -0
  91. jazzoo/expressions/models/reference.py +21 -0
  92. jazzoo/expressions/models/ternary.py +20 -0
  93. jazzoo/expressions/rules/__init__.py +0 -0
  94. jazzoo/expressions/rules/apply_builtin_pipe.py +55 -0
  95. jazzoo/expressions/rules/get_by_path.py +47 -0
  96. jazzoo/expressions/rules/is_truthy.py +22 -0
  97. jazzoo/expressions/rules/parse_expression.py +201 -0
  98. jazzoo/expressions/rules/parse_references.py +318 -0
  99. jazzoo/expressions/rules/resolve_reference.py +71 -0
  100. jazzoo/expressions/rules/split_reference_path.py +25 -0
  101. jazzoo/expressions/rules/substitute_references.py +51 -0
  102. jazzoo/expressions/rules/tokenize_expression.py +207 -0
  103. jazzoo/expressions/utils/__init__.py +0 -0
  104. jazzoo/expressions/utils/join_path.py +13 -0
  105. jazzoo/expressions/utils/render_value.py +22 -0
  106. jazzoo/expressions/utils/shell_quote.py +6 -0
  107. jazzoo/expressions/utils/strip_one_newline.py +5 -0
  108. jazzoo/functions/__init__.py +0 -0
  109. jazzoo/functions/actions/__init__.py +0 -0
  110. jazzoo/functions/actions/call_builtin_function.py +62 -0
  111. jazzoo/functions/actions/call_function.py +262 -0
  112. jazzoo/functions/models/__init__.py +0 -0
  113. jazzoo/functions/models/call_site.py +25 -0
  114. jazzoo/functions/models/curried_function.py +18 -0
  115. jazzoo/functions/models/function.py +32 -0
  116. jazzoo/functions/models/parameter.py +11 -0
  117. jazzoo/functions/rules/__init__.py +0 -0
  118. jazzoo/functions/rules/format_signature.py +18 -0
  119. jazzoo/functions/rules/get_function_by_path.py +25 -0
  120. jazzoo/functions/rules/get_target_by_path.py +41 -0
  121. jazzoo/functions/rules/get_targets.py +13 -0
  122. jazzoo/functions/rules/parse_curry.py +81 -0
  123. jazzoo/modules/__init__.py +0 -0
  124. jazzoo/modules/actions/__init__.py +0 -0
  125. jazzoo/modules/actions/parse_module.py +248 -0
  126. jazzoo/modules/actions/resolve_import.py +261 -0
  127. jazzoo/modules/models/__init__.py +0 -0
  128. jazzoo/modules/models/absent.py +14 -0
  129. jazzoo/modules/models/assignment.py +13 -0
  130. jazzoo/modules/models/builtin_call.py +14 -0
  131. jazzoo/modules/models/frame.py +86 -0
  132. jazzoo/modules/models/import_statement.py +26 -0
  133. jazzoo/modules/models/module.py +28 -0
  134. jazzoo/modules/models/parsed_source.py +15 -0
  135. jazzoo/modules/models/scope.py +13 -0
  136. jazzoo/modules/models/variable.py +13 -0
  137. jazzoo/modules/rules/__init__.py +0 -0
  138. jazzoo/modules/rules/get_module_by_path.py +22 -0
  139. jazzoo/modules/rules/list_targets.py +46 -0
  140. jazzoo/modules/rules/parse_attribute_line.py +31 -0
  141. jazzoo/modules/rules/parse_body.py +136 -0
  142. jazzoo/modules/rules/parse_call_arguments.py +19 -0
  143. jazzoo/modules/rules/parse_dependencies.py +20 -0
  144. jazzoo/modules/rules/parse_import_statement.py +101 -0
  145. jazzoo/modules/rules/parse_parameters.py +18 -0
  146. jazzoo/modules/rules/parse_source.py +154 -0
  147. jazzoo/modules/rules/validate_module.py +489 -0
  148. jazzoo/runtime/__init__.py +0 -0
  149. jazzoo/runtime/entrypoints/__init__.py +0 -0
  150. jazzoo/runtime/entrypoints/jazzoo.py +214 -0
  151. jazzoo/runtime/entrypoints/zoo.py +150 -0
  152. jazzoo/runtime/utils/__init__.py +0 -0
  153. jazzoo/runtime/utils/format_error.py +13 -0
  154. jazzoo/runtime/utils/test_utils.py +103 -0
  155. jazzoo/settings.py +40 -0
  156. jazzoo/utils/__init__.py +0 -0
  157. jazzoo/utils/dataclass.py +34 -0
  158. jazzoo/utils/indent_of.py +2 -0
  159. jazzoo/utils/join_continuations.py +25 -0
  160. jazzoo/utils/split_top_level.py +31 -0
  161. jazzoo/zoo/__init__.py +0 -0
  162. jazzoo/zoo/actions/__init__.py +0 -0
  163. jazzoo/zoo/actions/activate_zoo_env.py +16 -0
  164. jazzoo/zoo/actions/create_venv.py +148 -0
  165. jazzoo/zoo/actions/forget_zoo_env.py +31 -0
  166. jazzoo/zoo/actions/init_zoo_env.py +129 -0
  167. jazzoo/zoo/actions/install_fish_files.py +84 -0
  168. jazzoo/zoo/actions/run_in_zoo_env.py +29 -0
  169. jazzoo/zoo/actions/write_latest_zoo_env_scripts.py +21 -0
  170. jazzoo/zoo/actions/write_recent_zoo_envs.py +19 -0
  171. jazzoo/zoo/errors.py +61 -0
  172. jazzoo/zoo/models/__init__.py +0 -0
  173. jazzoo/zoo/models/zoo_env.py +9 -0
  174. jazzoo/zoo/rules/__init__.py +0 -0
  175. jazzoo/zoo/rules/find_zoo_env_of_venv.py +14 -0
  176. jazzoo/zoo/rules/format_zoo_env_list.py +18 -0
  177. jazzoo/zoo/rules/get_activate_filename.py +8 -0
  178. jazzoo/zoo/rules/get_fish_config_dir.py +8 -0
  179. jazzoo/zoo/rules/get_jazzoo_version.py +30 -0
  180. jazzoo/zoo/rules/get_recent_zoo_env.py +10 -0
  181. jazzoo/zoo/rules/get_shell_name.py +8 -0
  182. jazzoo/zoo/rules/get_zoo_env.py +17 -0
  183. jazzoo/zoo/rules/get_zoo_env_dir.py +7 -0
  184. jazzoo/zoo/rules/get_zoo_link.py +7 -0
  185. jazzoo/zoo/rules/get_zoo_venv_dir.py +6 -0
  186. jazzoo/zoo/rules/list_zoo_envs.py +17 -0
  187. jazzoo/zoo/rules/read_recent_zoo_envs.py +17 -0
  188. jazzoo/zoo/rules/validate_zoo_env_name.py +13 -0
  189. jazzoo-0.1.0.dist-info/METADATA +18 -0
  190. jazzoo-0.1.0.dist-info/RECORD +194 -0
  191. jazzoo-0.1.0.dist-info/WHEEL +5 -0
  192. jazzoo-0.1.0.dist-info/entry_points.txt +5 -0
  193. jazzoo-0.1.0.dist-info/licenses/LICENSE +21 -0
  194. jazzoo-0.1.0.dist-info/top_level.txt +1 -0
jazzoo/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ from jazzoo.commands import (
2
+ CommandAbortedError,
3
+ CommandError,
4
+ CommandFailedError,
5
+ CommandNotFoundError,
6
+ CommandResult,
7
+ run,
8
+ run_docker,
9
+ )
10
+ from jazzoo.config import (
11
+ ConfigError,
12
+ ConfigExpansionError,
13
+ ConfigFileError,
14
+ ConfigKeyError,
15
+ get_config,
16
+ prepare_config,
17
+ )
18
+ from jazzoo.errors import (
19
+ ArgumentError,
20
+ ExecutionError,
21
+ JazzooError,
22
+ ModuleImportError,
23
+ ParseError,
24
+ ResolutionError,
25
+ )
26
+ from jazzoo.expressions.actions.evaluate import evaluate
27
+ from jazzoo.functions.rules.get_function_by_path import get_function_by_path
28
+ from jazzoo.functions.rules.get_targets import get_targets
29
+ from jazzoo.modules.actions.parse_module import parse_module
30
+ from jazzoo.modules.rules.get_module_by_path import get_module_by_path
31
+
32
+ __all__ = [
33
+ "ArgumentError",
34
+ "CommandAbortedError",
35
+ "CommandError",
36
+ "CommandFailedError",
37
+ "CommandNotFoundError",
38
+ "CommandResult",
39
+ "ConfigError",
40
+ "ConfigExpansionError",
41
+ "ConfigFileError",
42
+ "ConfigKeyError",
43
+ "ExecutionError",
44
+ "JazzooError",
45
+ "ModuleImportError",
46
+ "ParseError",
47
+ "ResolutionError",
48
+ "evaluate",
49
+ "get_config",
50
+ "get_function_by_path",
51
+ "get_module_by_path",
52
+ "get_targets",
53
+ "parse_module",
54
+ "prepare_config",
55
+ "run",
56
+ "run_docker",
57
+ ]
File without changes
@@ -0,0 +1,5 @@
1
+ from jazzoo.errors import JazzooError
2
+
3
+
4
+ class AliasError(JazzooError):
5
+ pass
File without changes
@@ -0,0 +1,38 @@
1
+ import shlex
2
+ import typing as T
3
+
4
+ from jazzoo.aliases.errors import AliasError
5
+ from jazzoo.aliases.rules.get_alias_config_file import get_alias_config_file
6
+ from jazzoo.aliases.rules.read_aliases import read_aliases
7
+
8
+ # The options of jaz that an expansion may contain
9
+ _ALLOWED_OPTIONS = ("--confirm", "--trace")
10
+
11
+
12
+ def expand_alias(name: str) -> T.Optional[T.List[str]]:
13
+ config_file = get_alias_config_file()
14
+ expansion = read_aliases(config_file).get(name)
15
+ if expansion is None:
16
+ return None
17
+
18
+ try:
19
+ tokens = shlex.split(expansion)
20
+ except ValueError as error:
21
+ raise AliasError(
22
+ f'alias "{name}" has an invalid expansion: {error}', filename=config_file
23
+ )
24
+ if not tokens:
25
+ raise AliasError(f'alias "{name}" has an empty expansion', filename=config_file)
26
+
27
+ # Only the options that change how the target runs are allowed
28
+ for token in tokens:
29
+ option = token.partition("=")[0]
30
+ if option.startswith("-") and option not in _ALLOWED_OPTIONS:
31
+ raise AliasError(
32
+ f'alias "{name}" may not use the option {option}',
33
+ filename=config_file,
34
+ )
35
+ if all(token in _ALLOWED_OPTIONS for token in tokens):
36
+ raise AliasError(f'alias "{name}" names no target', filename=config_file)
37
+
38
+ return tokens
@@ -0,0 +1,7 @@
1
+ import os
2
+
3
+ from jazzoo.settings import get_zoo_config_dir
4
+
5
+
6
+ def get_alias_config_file() -> str:
7
+ return os.path.join(get_zoo_config_dir(), "config")
@@ -0,0 +1,36 @@
1
+ import configparser
2
+ import os
3
+ import re
4
+ import typing as T
5
+
6
+ from jazzoo.aliases.errors import AliasError
7
+
8
+ # A target path: names separated by dots
9
+ _ALIAS_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*(\.[A-Za-z_][A-Za-z0-9_-]*)*$")
10
+
11
+
12
+ def read_aliases(config_file: str) -> T.Dict[str, str]:
13
+ if not os.path.exists(config_file):
14
+ return {}
15
+
16
+ # No section is special: a section header cannot contain a newline, so the
17
+ # default section of configparser never occurs
18
+ parser = configparser.ConfigParser(
19
+ interpolation=None, strict=True, default_section="\n"
20
+ )
21
+ # Alias names are case-sensitive
22
+ parser.optionxform = str # type: ignore[assignment]
23
+ try:
24
+ with open(config_file, encoding="utf-8") as f:
25
+ parser.read_file(f, source=config_file)
26
+ except (OSError, UnicodeDecodeError, configparser.Error) as error:
27
+ raise AliasError(f"invalid config file: {error}", filename=config_file)
28
+
29
+ if not parser.has_section("alias"):
30
+ return {}
31
+
32
+ aliases = dict(parser.items("alias"))
33
+ for name in aliases:
34
+ if not _ALIAS_NAME_RE.match(name):
35
+ raise AliasError(f'invalid alias name "{name}"', filename=config_file)
36
+ return aliases
File without changes
File without changes
@@ -0,0 +1,80 @@
1
+ import os
2
+ import stat
3
+ import subprocess
4
+ import sys
5
+ import tempfile
6
+ import typing as T
7
+
8
+ from jazzoo.errors import ExecutionError
9
+ from jazzoo.settings import DEFAULT_SHEBANG
10
+
11
+
12
+ def run_block(
13
+ shebang: T.Optional[str],
14
+ body: str,
15
+ filename: T.Optional[str] = None,
16
+ line: T.Optional[int] = None,
17
+ is_quiet: bool = False,
18
+ is_interactive: bool = False,
19
+ ) -> str:
20
+ """Run one block as a single subprocess and return its raw stdout.
21
+
22
+ The block is written to a temporary executable file so the shebang selects the
23
+ interpreter, exactly as it would for a script on disk.
24
+ """
25
+ shebang = shebang or DEFAULT_SHEBANG
26
+ script = shebang + "\n" + body
27
+ if not script.endswith("\n"):
28
+ script += "\n"
29
+
30
+ handle = tempfile.NamedTemporaryFile(
31
+ mode="w", suffix=".jaz-block", delete=False, encoding="utf-8"
32
+ )
33
+ try:
34
+ handle.write(script)
35
+ handle.close()
36
+ os.chmod(handle.name, os.stat(handle.name).st_mode | stat.S_IEXEC)
37
+
38
+ if is_interactive:
39
+ # Whatever was echoed must reach the console before the block's output
40
+ sys.stdout.flush()
41
+ sys.stderr.flush()
42
+ completed = subprocess.run([handle.name])
43
+ else:
44
+ completed = subprocess.run(
45
+ [handle.name],
46
+ capture_output=True,
47
+ text=True,
48
+ )
49
+ finally:
50
+ os.unlink(handle.name)
51
+
52
+ # An interactive block writes to the console itself, and Jazzoo never sees its
53
+ # output, not even in a quiet call.
54
+ if is_interactive:
55
+ if completed.returncode != 0:
56
+ raise ExecutionError(
57
+ f"recipe failed with exit code {completed.returncode}: "
58
+ f"command failed: {body.strip()}",
59
+ filename=filename,
60
+ line=line,
61
+ )
62
+ return ""
63
+
64
+ # The block's own output goes to the console as well as being captured for
65
+ # ⚙️.stdout, so a target behaves like the commands it runs. A quiet block keeps
66
+ # its stdout for the caller, but its stderr is always shown.
67
+ if completed.stdout and not is_quiet:
68
+ print(completed.stdout, end="")
69
+ if completed.stderr:
70
+ print(completed.stderr, end="", file=sys.stderr)
71
+
72
+ if completed.returncode != 0:
73
+ message = completed.stderr.strip() or f"command failed: {body.strip()}"
74
+ raise ExecutionError(
75
+ f"recipe failed with exit code {completed.returncode}: {message}",
76
+ filename=filename,
77
+ line=line,
78
+ )
79
+
80
+ return completed.stdout
File without changes
@@ -0,0 +1,21 @@
1
+ import typing as T
2
+
3
+ from jazzoo.enums import BlockMode
4
+ from jazzoo.utils.dataclass import dataclass
5
+
6
+
7
+ @dataclass
8
+ class BlockLine:
9
+ text: str
10
+ line: int
11
+ # Set by a leading `@`, which hides the echoed command but not its output.
12
+ is_quiet: bool = False
13
+
14
+
15
+ @dataclass
16
+ class Block:
17
+ mode: BlockMode
18
+ # The interpreter line, e.g. "#!/usr/bin/env python3". None in jazzoo mode.
19
+ shebang: T.Optional[str] = None
20
+ lines: T.List[BlockLine] = []
21
+ filename: T.Optional[str] = None
File without changes
@@ -0,0 +1,19 @@
1
+ from .actions.run import run
2
+ from .actions.run_docker import run_docker
3
+ from .errors import (
4
+ CommandAbortedError,
5
+ CommandError,
6
+ CommandFailedError,
7
+ CommandNotFoundError,
8
+ )
9
+ from .models.command_result import CommandResult
10
+
11
+ __all__ = [
12
+ "CommandAbortedError",
13
+ "CommandError",
14
+ "CommandFailedError",
15
+ "CommandNotFoundError",
16
+ "CommandResult",
17
+ "run",
18
+ "run_docker",
19
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .entrypoints.jaz_run import main
4
+
5
+ sys.exit(main())
File without changes
@@ -0,0 +1,36 @@
1
+ import os
2
+ import typing as T
3
+
4
+ from ..errors import CommandAbortedError, CommandError
5
+ from ..settings import CONFIRM_PROMPT, TERMINAL
6
+ from ..utils.format_command import format_command
7
+
8
+
9
+ def confirm_command(
10
+ command: T.Union[str, T.List[str]], cwd: T.Optional[str] = None
11
+ ) -> bool:
12
+ try:
13
+ reader = open(TERMINAL, "r", encoding="utf-8")
14
+ writer = open(TERMINAL, "w", encoding="utf-8")
15
+ except OSError:
16
+ raise CommandError("--confirm needs a terminal")
17
+
18
+ with reader, writer:
19
+ # Asks again until the answer is y, n, q or Enter
20
+ while True:
21
+ writer.write(format_command(command, cwd or os.getcwd()) + "\n")
22
+ writer.write(CONFIRM_PROMPT)
23
+ writer.flush()
24
+
25
+ line = reader.readline()
26
+ # An empty line has a newline; no newline means the end of input
27
+ if not line:
28
+ raise CommandAbortedError("aborted by the user")
29
+
30
+ answer = line.strip().lower()
31
+ if answer in ("", "y"):
32
+ return True
33
+ if answer == "n":
34
+ return False
35
+ if answer == "q":
36
+ raise CommandAbortedError("aborted by the user")
@@ -0,0 +1,100 @@
1
+ import os
2
+ import shutil
3
+ import signal
4
+ import subprocess
5
+ import sys
6
+ import typing as T
7
+
8
+ from ..errors import CommandFailedError
9
+ from ..models.command_result import CommandResult
10
+ from ..rules.find_executable import find_executable
11
+ from ..rules.is_confirm_on import is_confirm_on
12
+ from ..utils.format_command import format_command
13
+ from ..utils.ignoring_sigint import ignoring_sigint
14
+ from .confirm_command import confirm_command
15
+
16
+
17
+ def run(
18
+ command: T.Union[str, T.List[str]],
19
+ cwd: T.Optional[str] = None,
20
+ env: T.Optional[T.Dict[str, str]] = None,
21
+ capture: bool = False,
22
+ check: bool = True,
23
+ echo: bool = False,
24
+ ) -> CommandResult:
25
+ # Check the arguments before anything runs
26
+ if isinstance(command, str):
27
+ if not command.strip():
28
+ raise ValueError("the command is empty")
29
+ elif not command:
30
+ raise ValueError("the command is empty")
31
+ if cwd is not None and not os.path.isdir(cwd):
32
+ raise ValueError(f"directory not found: {cwd}")
33
+
34
+ environ = {**os.environ, **(env or {})}
35
+
36
+ # A string runs in bash; a list runs directly
37
+ if isinstance(command, str):
38
+ args = ["bash", "-c", command]
39
+ executable = shutil.which("bash") or "/bin/bash"
40
+ else:
41
+ args = list(command)
42
+ executable = find_executable(args[0], environ)
43
+
44
+ # The prompt shows the command, so it replaces the echo
45
+ if is_confirm_on():
46
+ if not confirm_command(command, cwd):
47
+ skipped_output = "" if capture else None
48
+ return CommandResult(
49
+ command=command,
50
+ returncode=0,
51
+ stdout=skipped_output,
52
+ stderr=skipped_output,
53
+ skipped=True,
54
+ )
55
+ elif echo:
56
+ print(format_command(command, cwd))
57
+
58
+ # The command writes to the same file descriptors as the script, so what the
59
+ # script printed before must come first
60
+ sys.stdout.flush()
61
+ sys.stderr.flush()
62
+
63
+ output = subprocess.PIPE if capture else None
64
+ process = subprocess.Popen(
65
+ args,
66
+ executable=executable,
67
+ cwd=cwd,
68
+ env=environ,
69
+ stdout=output,
70
+ stderr=output,
71
+ text=True,
72
+ )
73
+
74
+ # SIGINT is ignored only after the command has started, because an ignored
75
+ # signal would be inherited by the command. A ctrl-c then reaches the command
76
+ # only.
77
+ with ignoring_sigint():
78
+ stdout, stderr = process.communicate()
79
+
80
+ if process.returncode == -signal.SIGINT:
81
+ raise KeyboardInterrupt
82
+
83
+ result = CommandResult(
84
+ command=command,
85
+ returncode=process.returncode,
86
+ stdout=stdout,
87
+ stderr=stderr,
88
+ )
89
+ if check and result.returncode != 0:
90
+ raise CommandFailedError(_failure_message(result, cwd), result)
91
+ return result
92
+
93
+
94
+ def _failure_message(result: CommandResult, cwd: T.Optional[str]) -> str:
95
+ message = f"command failed with exit code {result.returncode}: " + format_command(
96
+ result.command, cwd
97
+ )
98
+ if result.stderr and result.stderr.strip():
99
+ message += "\n" + result.stderr.strip()
100
+ return message
@@ -0,0 +1,67 @@
1
+ import typing as T
2
+
3
+ from ..models.command_result import CommandResult
4
+ from ..rules.needs_tty import needs_tty
5
+
6
+ Command = T.Union[str, T.List[str]]
7
+ RunFunction = T.Callable[..., CommandResult]
8
+
9
+
10
+ def run_docker(
11
+ container: str,
12
+ user: T.Optional[str] = None,
13
+ workdir: T.Optional[str] = None,
14
+ env: T.Optional[T.Dict[str, str]] = None,
15
+ shell: str = "bash",
16
+ ) -> T.Callable[[RunFunction], RunFunction]:
17
+ if not container or not container.strip():
18
+ raise ValueError("the container is empty")
19
+
20
+ container_env = dict(env or {})
21
+
22
+ def decorator(run: RunFunction) -> RunFunction:
23
+ def run_in_container(
24
+ command: Command,
25
+ cwd: T.Optional[str] = None,
26
+ env: T.Optional[T.Dict[str, str]] = None,
27
+ capture: bool = False,
28
+ check: bool = True,
29
+ echo: bool = False,
30
+ ) -> CommandResult:
31
+ # Check the command before the docker command hides it
32
+ if isinstance(command, str):
33
+ if not command.strip():
34
+ raise ValueError("the command is empty")
35
+ command_args = [shell, "-c", command]
36
+ elif not command:
37
+ raise ValueError("the command is empty")
38
+ else:
39
+ command_args = list(command)
40
+
41
+ # The options come in a fixed order
42
+ docker_command = ["docker", "exec", "--interactive"]
43
+ if needs_tty(capture):
44
+ docker_command.append("--tty")
45
+ if user:
46
+ docker_command += ["--user", user]
47
+ # A cwd only replaces the workdir for this call
48
+ call_workdir = cwd or workdir
49
+ if call_workdir:
50
+ docker_command += ["--workdir", call_workdir]
51
+ for name, value in {**container_env, **(env or {})}.items():
52
+ docker_command += ["--env", f"{name}={value}"]
53
+ docker_command += [container, *command_args]
54
+
55
+ # The cwd and env belong to the container, not to the docker client
56
+ return run(
57
+ docker_command,
58
+ cwd=None,
59
+ env=None,
60
+ capture=capture,
61
+ check=check,
62
+ echo=echo,
63
+ )
64
+
65
+ return run_in_container
66
+
67
+ return decorator
File without changes
@@ -0,0 +1,76 @@
1
+ import argparse
2
+ import sys
3
+ import typing as T
4
+
5
+ from ..actions.run import run as run_command
6
+ from ..errors import (
7
+ CommandAbortedError,
8
+ CommandError,
9
+ CommandNotFoundError,
10
+ UsageError,
11
+ )
12
+
13
+ USAGE = (
14
+ "usage: jaz-run [--echo] [--] <executable> [<argument> ...]\n"
15
+ ' jaz-run [--echo] -c "<command>"'
16
+ )
17
+
18
+
19
+ class _ArgumentParser(argparse.ArgumentParser):
20
+ def error(self, message: str):
21
+ raise UsageError(message)
22
+
23
+
24
+ def _parse_args(argv: T.Optional[T.List[str]] = None):
25
+ parser = _ArgumentParser(prog="jaz-run", add_help=False)
26
+ parser.add_argument("--echo", dest="echo", action="store_true")
27
+ parser.add_argument("-c", dest="string_command", default=None)
28
+ parser.add_argument("--help", "-h", dest="help", action="store_true")
29
+ # The first argument that is not an option of jaz-run starts the command
30
+ parser.add_argument("command", nargs=argparse.REMAINDER)
31
+ return parser.parse_args(argv)
32
+
33
+
34
+ def _get_command(args) -> T.Union[str, T.List[str]]:
35
+ command = list(args.command)
36
+ if command[:1] == ["--"]:
37
+ command = command[1:]
38
+
39
+ if args.string_command is not None:
40
+ if command:
41
+ raise UsageError("-c takes a single command")
42
+ return args.string_command
43
+
44
+ if not command:
45
+ raise UsageError("no command given")
46
+ return command
47
+
48
+
49
+ def run(argv: T.Optional[T.List[str]] = None) -> int:
50
+ args = _parse_args(argv)
51
+ if args.help:
52
+ print(USAGE)
53
+ return 0
54
+
55
+ result = run_command(_get_command(args), check=False, echo=args.echo)
56
+ return result.returncode
57
+
58
+
59
+ def main(argv: T.Optional[T.List[str]] = None) -> int:
60
+ try:
61
+ return run(argv)
62
+ except KeyboardInterrupt:
63
+ return 130
64
+ except CommandAbortedError as error:
65
+ print(f"jaz-run: {error}", file=sys.stderr)
66
+ return 130
67
+ except CommandNotFoundError as error:
68
+ print(f"jaz-run: {error}", file=sys.stderr)
69
+ return 127
70
+ except (CommandError, ValueError) as error:
71
+ print(f"jaz-run: {error}", file=sys.stderr)
72
+ return 2
73
+
74
+
75
+ if __name__ == "__main__":
76
+ sys.exit(main())
@@ -0,0 +1,26 @@
1
+ import typing as T
2
+
3
+ if T.TYPE_CHECKING:
4
+ from .models.command_result import CommandResult
5
+
6
+
7
+ class CommandError(Exception):
8
+ pass
9
+
10
+
11
+ class CommandNotFoundError(CommandError):
12
+ pass
13
+
14
+
15
+ class CommandAbortedError(CommandError):
16
+ pass
17
+
18
+
19
+ class UsageError(CommandError):
20
+ pass
21
+
22
+
23
+ class CommandFailedError(CommandError):
24
+ def __init__(self, message: str, result: "CommandResult"):
25
+ super().__init__(message)
26
+ self.result = result
File without changes
@@ -0,0 +1,15 @@
1
+ import typing as T
2
+
3
+ from ..utils.dataclass import dataclass
4
+
5
+
6
+ @dataclass
7
+ class CommandResult:
8
+ # The command, as it was given to run
9
+ command: T.Union[str, T.List[str]]
10
+ returncode: int
11
+ # None unless the output was captured
12
+ stdout: T.Optional[str] = None
13
+ stderr: T.Optional[str] = None
14
+ # True when the user skipped the command at the confirmation prompt
15
+ skipped: bool = False
File without changes
@@ -0,0 +1,18 @@
1
+ import os
2
+ import shutil
3
+ import typing as T
4
+
5
+ from ..errors import CommandNotFoundError
6
+
7
+
8
+ def find_executable(name: str, environ: T.Dict[str, str]) -> str:
9
+ # A name with a slash is a path, as in a shell
10
+ if "/" in name:
11
+ if os.path.isfile(name) and os.access(name, os.X_OK):
12
+ return name
13
+ raise CommandNotFoundError(f"command not found: {name}")
14
+
15
+ path = shutil.which(name, path=environ.get("PATH", os.defpath))
16
+ if path is None:
17
+ raise CommandNotFoundError(f"command not found: {name}")
18
+ return path
@@ -0,0 +1,9 @@
1
+ from ..settings import TERMINAL
2
+
3
+
4
+ def has_terminal() -> bool:
5
+ try:
6
+ with open(TERMINAL, "w"):
7
+ return True
8
+ except OSError:
9
+ return False
@@ -0,0 +1,7 @@
1
+ import os
2
+
3
+ from ..settings import CONFIRM_ENV_VAR
4
+
5
+
6
+ def is_confirm_on() -> bool:
7
+ return os.environ.get(CONFIRM_ENV_VAR) == "1"
@@ -0,0 +1,9 @@
1
+ import sys
2
+
3
+
4
+ def needs_tty(capture: bool) -> bool:
5
+ # A tty would mix stderr into captured output, and docker refuses one when the
6
+ # script has no terminal
7
+ if capture:
8
+ return False
9
+ return sys.stdin.isatty() and sys.stdout.isatty()