tloop-cli 0.4.0__tar.gz → 0.5.0__tar.gz

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 (28) hide show
  1. {tloop_cli-0.4.0/src/tloop_cli.egg-info → tloop_cli-0.5.0}/PKG-INFO +3 -3
  2. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/README.md +2 -2
  3. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/pyproject.toml +1 -1
  4. tloop_cli-0.5.0/src/cmd_edit.py +108 -0
  5. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/src/cmd_run.py +1 -2
  6. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/src/config.py +2 -28
  7. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/src/runner/__init__.py +1 -1
  8. tloop_cli-0.5.0/src/runner/cybervisor.py +35 -0
  9. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/src/task.py +8 -8
  10. {tloop_cli-0.4.0 → tloop_cli-0.5.0/src/tloop_cli.egg-info}/PKG-INFO +3 -3
  11. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/test/test_cli.py +61 -5
  12. tloop_cli-0.4.0/src/cmd_edit.py +0 -45
  13. tloop_cli-0.4.0/src/runner/cybervisor.py +0 -28
  14. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/LICENSE +0 -0
  15. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/setup.cfg +0 -0
  16. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/src/claude_runner.py +0 -0
  17. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/src/cmd_archive.py +0 -0
  18. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/src/cmd_migrate.py +0 -0
  19. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/src/git_ops.py +0 -0
  20. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/src/main.py +0 -0
  21. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/src/runner/claude.py +0 -0
  22. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/src/state.py +0 -0
  23. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/src/tloop_cli.egg-info/SOURCES.txt +0 -0
  24. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/src/tloop_cli.egg-info/dependency_links.txt +0 -0
  25. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/src/tloop_cli.egg-info/entry_points.txt +0 -0
  26. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/src/tloop_cli.egg-info/requires.txt +0 -0
  27. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/src/tloop_cli.egg-info/top_level.txt +0 -0
  28. {tloop_cli-0.4.0 → tloop_cli-0.5.0}/test/test_git_ops.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tloop-cli
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Automated Claude Code task runner
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.9
@@ -16,8 +16,8 @@ Dynamic: license-file
16
16
  ## 安装
17
17
 
18
18
  ```bash
19
- pip install t-loop # 从 PyPI 安装
20
- pip install -e . # 本地开发安装
19
+ pip install tloop-cli # 从 PyPI 安装
20
+ pip install -e . # 本地开发安装
21
21
  ```
22
22
 
23
23
  需要 Python >=3.9,唯一外部依赖:`pyyaml`。
@@ -5,8 +5,8 @@
5
5
  ## 安装
6
6
 
7
7
  ```bash
8
- pip install t-loop # 从 PyPI 安装
9
- pip install -e . # 本地开发安装
8
+ pip install tloop-cli # 从 PyPI 安装
9
+ pip install -e . # 本地开发安装
10
10
  ```
11
11
 
12
12
  需要 Python >=3.9,唯一外部依赖:`pyyaml`。
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tloop-cli"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Automated Claude Code task runner"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,108 @@
1
+ """tloop edit — open ~/.tloop/tasks.yaml in editor."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+
9
+ import config
10
+
11
+ KNOWN_EDITORS = {
12
+ "code": ("Visual Studio Code", "code"),
13
+ "vim": ("Vim", "vim"),
14
+ "nano": ("Nano", "nano"),
15
+ }
16
+
17
+ EDIT_HELP = """\
18
+ Open ~/.tloop/tasks.yaml in your editor.
19
+
20
+ On first run, you will be prompted to choose an editor (VS Code, Vim, Nano,
21
+ or a custom command). The choice is saved to ~/.tloop/settings.json.
22
+ Override anytime with: tloop edit --editor <command>
23
+
24
+ Task file format (~/.tloop/tasks.yaml):
25
+
26
+ tasks:
27
+ - name: My task
28
+ dir: ~/projects/my-project
29
+ prompt: |
30
+ Describe what Claude should do.
31
+ # OR:
32
+ prompt_file: ./prompts/my-task.md
33
+ branch: true # true=auto, "custom/name", false=skip
34
+
35
+ Each task runs in the specified directory. Completed tasks are
36
+ archived to ~/.tloop/archive/ after each run cycle.
37
+ """
38
+
39
+
40
+ def _load_settings():
41
+ if config.SETTINGS_FILE.exists():
42
+ try:
43
+ return json.loads(config.SETTINGS_FILE.read_text())
44
+ except (json.JSONDecodeError, OSError):
45
+ pass
46
+ return {}
47
+
48
+
49
+ def _save_settings(settings):
50
+ config.TLOOP_HOME.mkdir(exist_ok=True)
51
+ config.SETTINGS_FILE.write_text(json.dumps(settings, indent=2) + "\n")
52
+
53
+
54
+ def _prompt_editor():
55
+ options = []
56
+ for key, (label, cmd) in KNOWN_EDITORS.items():
57
+ if shutil.which(cmd):
58
+ options.append((key, label, cmd))
59
+
60
+ print(f"{config.BOLD}Choose your editor for tasks.yaml:{config.RESET}\n")
61
+ for i, (key, label, _) in enumerate(options, 1):
62
+ print(f" {i}) {label}")
63
+ print(f" {len(options) + 1}) Other (enter command manually)")
64
+ print()
65
+
66
+ while True:
67
+ choice = input(f"Enter number [1-{len(options) + 1}]: ").strip()
68
+ if choice.isdigit():
69
+ idx = int(choice)
70
+ if 1 <= idx <= len(options):
71
+ return options[idx - 1][2]
72
+ if idx == len(options) + 1:
73
+ cmd = input("Enter editor command: ").strip()
74
+ if cmd:
75
+ return cmd
76
+ print("Invalid choice, try again.")
77
+
78
+
79
+ def _resolve_editor(cli_editor=None):
80
+ if cli_editor:
81
+ return cli_editor
82
+ settings = _load_settings()
83
+ if "editor" in settings:
84
+ return settings["editor"]
85
+ editor = _prompt_editor()
86
+ _save_settings({"editor": editor})
87
+ print(f"{config.GREEN}Editor saved. Change anytime with: tloop edit --editor <command>{config.RESET}\n")
88
+ return editor
89
+
90
+
91
+ def add_parser(subparsers):
92
+ p = subparsers.add_parser(
93
+ "edit",
94
+ help="Open ~/.tloop/tasks.yaml in editor",
95
+ description=EDIT_HELP,
96
+ formatter_class=argparse.RawDescriptionHelpFormatter,
97
+ )
98
+ p.add_argument("--editor", help="Override editor command for this session")
99
+ p.set_defaults(func=handle)
100
+
101
+
102
+ def handle(args):
103
+ config.TLOOP_HOME.mkdir(exist_ok=True)
104
+ if not config.TASKS_FILE.exists():
105
+ config.TASKS_FILE.write_text(config.SAMPLE_TASKS_YAML)
106
+
107
+ editor = _resolve_editor(getattr(args, "editor", None))
108
+ subprocess.run([editor, str(config.TASKS_FILE)])
@@ -24,7 +24,6 @@ def handle(args):
24
24
 
25
25
  cfg = load_config()
26
26
  tasks = cfg.get("tasks", [])
27
- defaults = cfg.get("defaults", {})
28
27
 
29
28
  if not tasks:
30
29
  print("No tasks defined in tasks.yaml")
@@ -71,7 +70,7 @@ def handle(args):
71
70
  continue
72
71
 
73
72
  ran_any = True
74
- success = run_task(tasks[i], i, state, defaults)
73
+ success = run_task(tasks[i], i, state)
75
74
  if not success and not args.continue_on_fail:
76
75
  print(f"\n{config.YELLOW}Stopped. Use -c to continue after failures.{config.RESET}")
77
76
  break
@@ -8,6 +8,7 @@ import yaml
8
8
  TLOOP_HOME = Path.home() / ".tloop"
9
9
  TASKS_FILE = TLOOP_HOME / "tasks.yaml"
10
10
  STATE_FILE = TLOOP_HOME / "state.json"
11
+ SETTINGS_FILE = TLOOP_HOME / "settings.json"
11
12
  LOGS_DIR = TLOOP_HOME / "logs"
12
13
  ARCHIVE_DIR = TLOOP_HOME / "archive"
13
14
 
@@ -18,34 +19,7 @@ CYAN = "\033[96m"
18
19
  BOLD = "\033[1m"
19
20
  RESET = "\033[0m"
20
21
 
21
- SAMPLE_TASKS_YAML = """\
22
- # t-loop tasks.yaml
23
- # Define your Claude Code automation tasks here.
24
- #
25
- # Location: ~/.tloop/tasks.yaml
26
- #
27
- # Each task runs "cybervisor run <prompt>" in the specified directory.
28
- # Completed tasks are automatically archived after each run cycle.
29
- # View archives with: tloop archive
30
- #
31
- # prompt_file paths resolve in this order:
32
- # 1. Absolute path (after ~ expansion)
33
- # 2. Relative to ~/.tloop/
34
- # 3. Relative to the task's dir
35
-
36
- defaults:
37
- # model: opus
38
-
39
- tasks: []
40
- # - name: My first task
41
- # dir: ~/projects/my-project
42
- # prompt: |
43
- # Describe what Claude should do here.
44
- #
45
- # - name: Task with prompt file
46
- # dir: ~/projects/my-project
47
- # prompt_file: ./prompts/my-task.md
48
- """
22
+ SAMPLE_TASKS_YAML = "# Run 'tloop edit --help' for details on how to write this file.\ntasks: []\n"
49
23
 
50
24
 
51
25
  def ensure_tloop_home():
@@ -5,6 +5,6 @@ from abc import ABC, abstractmethod
5
5
 
6
6
  class Runner(ABC):
7
7
  @abstractmethod
8
- def run(self, prompt, cwd, model=None, log_file=None):
8
+ def run(self, prompt, cwd, log_file=None):
9
9
  """Run a task. Returns exit code (0=success)."""
10
10
  ...
@@ -0,0 +1,35 @@
1
+ """Cybervisor runner backend."""
2
+
3
+ import subprocess
4
+
5
+ from runner import Runner
6
+
7
+
8
+ class CybervisorRunner(Runner):
9
+ def run(self, prompt, cwd, log_file=None, prompt_file=None):
10
+ cmd = ["cybervisor", "run"]
11
+
12
+ with open(log_file, "a") if log_file else open("/dev/null", "a") as log:
13
+ stdin_fh = open(prompt_file, "r") if prompt_file else None
14
+ try:
15
+ process = subprocess.Popen(
16
+ cmd,
17
+ cwd=cwd,
18
+ stdin=stdin_fh if stdin_fh else subprocess.PIPE,
19
+ stdout=subprocess.PIPE,
20
+ stderr=subprocess.STDOUT,
21
+ text=True,
22
+ )
23
+ if not stdin_fh:
24
+ process.stdin.write(prompt)
25
+ process.stdin.close()
26
+ for line in process.stdout:
27
+ print(line, end="")
28
+ log.write(line)
29
+ log.flush()
30
+ process.wait()
31
+ finally:
32
+ if stdin_fh:
33
+ stdin_fh.close()
34
+
35
+ return process.returncode
@@ -28,12 +28,11 @@ def resolve_prompt_file(prompt_file, dir_path):
28
28
  return Path(prompt_file)
29
29
 
30
30
 
31
- def run_task(task, index, state, defaults):
31
+ def run_task(task, index, state):
32
32
  name = task.get("name", f"Task {index + 1}")
33
- dir_path = expand_dir(task.get("dir", defaults.get("dir", ".")))
33
+ dir_path = expand_dir(task.get("dir", "."))
34
34
  prompt = task.get("prompt", "")
35
35
  prompt_file = task.get("prompt_file")
36
- model = task.get("model", defaults.get("model"))
37
36
  branch_config = task.get("branch", True)
38
37
 
39
38
  if not os.path.isdir(dir_path):
@@ -46,10 +45,11 @@ def run_task(task, index, state, defaults):
46
45
  save_state(state)
47
46
  return False
48
47
 
48
+ resolved_pf = None
49
49
  if prompt_file:
50
- pf = resolve_prompt_file(prompt_file, dir_path)
51
- if pf.exists():
52
- prompt = pf.read_text()
50
+ resolved_pf = resolve_prompt_file(prompt_file, dir_path)
51
+ if resolved_pf.exists():
52
+ prompt = resolved_pf.read_text()
53
53
  else:
54
54
  print(f"{config.RED} Prompt file not found: {prompt_file}{config.RESET}")
55
55
  return False
@@ -100,12 +100,12 @@ def run_task(task, index, state, defaults):
100
100
  try:
101
101
  with open(log_file, "a") as log:
102
102
  log.write(f"Started: {started}\n")
103
- log.write(f"Command: cybervisor run <prompt>\n")
103
+ log.write(f"Command: cybervisor run < {'<prompt_file>' if resolved_pf else '<prompt>'}\n")
104
104
  log.write("-" * 60 + "\n\n")
105
105
  log.flush()
106
106
 
107
107
  runner = CybervisorRunner()
108
- returncode = runner.run(prompt, dir_path, model=model, log_file=log_file)
108
+ returncode = runner.run(prompt, dir_path, log_file=log_file, prompt_file=resolved_pf)
109
109
 
110
110
  if returncode == 0:
111
111
  state["tasks"][str(index)] = {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tloop-cli
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Automated Claude Code task runner
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.9
@@ -16,8 +16,8 @@ Dynamic: license-file
16
16
  ## 安装
17
17
 
18
18
  ```bash
19
- pip install t-loop # 从 PyPI 安装
20
- pip install -e . # 本地开发安装
19
+ pip install tloop-cli # 从 PyPI 安装
20
+ pip install -e . # 本地开发安装
21
21
  ```
22
22
 
23
23
  需要 Python >=3.9,唯一外部依赖:`pyyaml`。
@@ -15,6 +15,7 @@ import config
15
15
  import state as state_mod
16
16
  import task as task_mod
17
17
  import cmd_run
18
+ import cmd_edit
18
19
 
19
20
 
20
21
  def _init_git_repo(tmpdir):
@@ -44,6 +45,7 @@ def _setup_home(tmp_path):
44
45
  patch.object(config, "TLOOP_HOME", home),
45
46
  patch.object(config, "TASKS_FILE", home / "tasks.yaml"),
46
47
  patch.object(config, "STATE_FILE", home / "state.json"),
48
+ patch.object(config, "SETTINGS_FILE", home / "settings.json"),
47
49
  patch.object(config, "LOGS_DIR", home / "logs"),
48
50
  patch.object(config, "ARCHIVE_DIR", home / "archive"),
49
51
  ]
@@ -60,6 +62,7 @@ class EnsureTloopHomeTests(unittest.TestCase):
60
62
  patch.object(config, "TLOOP_HOME", self.home),
61
63
  patch.object(config, "TASKS_FILE", self.home / "tasks.yaml"),
62
64
  patch.object(config, "STATE_FILE", self.home / "state.json"),
65
+ patch.object(config, "SETTINGS_FILE", self.home / "settings.json"),
63
66
  patch.object(config, "LOGS_DIR", self.home / "logs"),
64
67
  patch.object(config, "ARCHIVE_DIR", self.home / "archive"),
65
68
  ]
@@ -86,7 +89,7 @@ class EnsureTloopHomeTests(unittest.TestCase):
86
89
  self.assertTrue(tasks_file.exists())
87
90
  content = tasks_file.read_text()
88
91
  self.assertIn("tasks:", content)
89
- self.assertIn("defaults:", content)
92
+ self.assertTrue(content.startswith("# Run 'tloop edit --help'"))
90
93
 
91
94
  def test_idempotent_no_exit_when_tasks_exist(self):
92
95
  self.home.mkdir(parents=True, exist_ok=True)
@@ -765,7 +768,7 @@ class RunTaskGitIntegrationTests(unittest.TestCase):
765
768
  "prompt": "do something",
766
769
  "branch": "feat/test",
767
770
  }
768
- result = task_mod.run_task(task, 0, state, {})
771
+ result = task_mod.run_task(task, 0, state)
769
772
  self.assertTrue(result)
770
773
  mock_clean.assert_called_once()
771
774
  mock_branch.assert_called_once_with(self.tmpdir, "feat/test")
@@ -778,7 +781,7 @@ class RunTaskGitIntegrationTests(unittest.TestCase):
778
781
  "dir": self.tmpdir,
779
782
  "prompt": "do something",
780
783
  }
781
- result = task_mod.run_task(task, 0, state, {})
784
+ result = task_mod.run_task(task, 0, state)
782
785
  self.assertFalse(result)
783
786
  self.assertEqual(state["tasks"]["0"]["status"], "failed")
784
787
  self.assertIn("auto-commit", state["tasks"]["0"]["error"])
@@ -793,7 +796,7 @@ class RunTaskGitIntegrationTests(unittest.TestCase):
793
796
  "prompt": "do something",
794
797
  "branch": True,
795
798
  }
796
- result = task_mod.run_task(task, 0, state, {})
799
+ result = task_mod.run_task(task, 0, state)
797
800
  self.assertFalse(result)
798
801
  self.assertEqual(state["tasks"]["0"]["status"], "failed")
799
802
  self.assertIn("branch", state["tasks"]["0"]["error"])
@@ -806,10 +809,63 @@ class RunTaskGitIntegrationTests(unittest.TestCase):
806
809
  "dir": self.tmpdir,
807
810
  "prompt": "do something",
808
811
  }
809
- task_mod.run_task(task, 0, state, {})
812
+ task_mod.run_task(task, 0, state)
810
813
  self.assertNotEqual(state["tasks"]["0"]["status"], "running")
811
814
  self.assertEqual(state["tasks"]["0"]["status"], "failed")
812
815
 
813
816
 
817
+ class EditorSelectionTests(unittest.TestCase):
818
+ """Tests for editor selection and persistence in cmd_edit."""
819
+
820
+ def setUp(self):
821
+ self.tmpdir = tempfile.mkdtemp()
822
+ self.home = Path(self.tmpdir) / "tloop"
823
+ self.home.mkdir(parents=True)
824
+ self.patches = [
825
+ patch.object(config, "TLOOP_HOME", self.home),
826
+ patch.object(config, "SETTINGS_FILE", self.home / "settings.json"),
827
+ ]
828
+ for p in self.patches:
829
+ p.start()
830
+
831
+ def tearDown(self):
832
+ for p in self.patches:
833
+ p.stop()
834
+
835
+ def test_cli_editor_overrides_settings(self):
836
+ cmd_edit._save_settings({"editor": "vim"})
837
+ result = cmd_edit._resolve_editor("code")
838
+ self.assertEqual(result, "code")
839
+
840
+ def test_saved_editor_used_when_no_cli_override(self):
841
+ cmd_edit._save_settings({"editor": "code"})
842
+ result = cmd_edit._resolve_editor()
843
+ self.assertEqual(result, "code")
844
+
845
+ def test_prompt_on_first_run(self):
846
+ with patch("builtins.input", side_effect=["1"]):
847
+ with patch.object(cmd_edit, "KNOWN_EDITORS", {"code": ("VS Code", "code")}):
848
+ with patch("shutil.which", return_value="/usr/local/bin/code"):
849
+ result = cmd_edit._resolve_editor()
850
+ self.assertEqual(result, "code")
851
+ settings = json.loads((self.home / "settings.json").read_text())
852
+ self.assertEqual(settings["editor"], "code")
853
+
854
+ def test_custom_editor_via_prompt(self):
855
+ with patch("builtins.input", side_effect=["2", "subl"]):
856
+ with patch.object(cmd_edit, "KNOWN_EDITORS", {"code": ("VS Code", "code")}):
857
+ with patch("shutil.which", return_value="/usr/local/bin/code"):
858
+ result = cmd_edit._resolve_editor()
859
+ self.assertEqual(result, "subl")
860
+
861
+ def test_corrupt_settings_file_triggers_prompt(self):
862
+ (self.home / "settings.json").write_text("not json")
863
+ with patch("builtins.input", side_effect=["1"]):
864
+ with patch.object(cmd_edit, "KNOWN_EDITORS", {"code": ("VS Code", "code")}):
865
+ with patch("shutil.which", return_value="/usr/local/bin/code"):
866
+ result = cmd_edit._resolve_editor()
867
+ self.assertEqual(result, "code")
868
+
869
+
814
870
  if __name__ == "__main__":
815
871
  unittest.main()
@@ -1,45 +0,0 @@
1
- """tloop edit — open ~/.tloop/tasks.yaml in editor."""
2
-
3
- import argparse
4
- import os
5
- import subprocess
6
-
7
- import config
8
-
9
-
10
- EDIT_HELP = """\
11
- Open ~/.tloop/tasks.yaml in your editor ($EDITOR, defaults to vi).
12
-
13
- Task file format (~/.tloop/tasks.yaml):
14
-
15
- defaults:
16
- model: opus # optional default model
17
-
18
- tasks:
19
- - name: My task
20
- dir: ~/projects/my-project
21
- prompt: |
22
- Describe what Claude should do.
23
- # OR:
24
- prompt_file: ./prompts/my-task.md
25
- model: opus # optional override
26
- branch: true # true=auto, "custom/name", false=skip
27
-
28
- Each task runs in the specified directory. Completed tasks are
29
- archived to ~/.tloop/archive/ after each run cycle.
30
- """
31
-
32
-
33
- def add_parser(subparsers):
34
- p = subparsers.add_parser(
35
- "edit",
36
- help="Open ~/.tloop/tasks.yaml in editor",
37
- description=EDIT_HELP,
38
- formatter_class=argparse.RawDescriptionHelpFormatter,
39
- )
40
- p.set_defaults(func=handle)
41
-
42
-
43
- def handle(args):
44
- editor = os.environ.get("EDITOR", "vi")
45
- subprocess.run([editor, str(config.TASKS_FILE)])
@@ -1,28 +0,0 @@
1
- """Cybervisor runner backend."""
2
-
3
- import subprocess
4
-
5
- from runner import Runner
6
-
7
-
8
- class CybervisorRunner(Runner):
9
- def run(self, prompt, cwd, model=None, log_file=None):
10
- cmd = ["cybervisor", "run", prompt]
11
- if model:
12
- cmd.extend(["--model", model])
13
-
14
- with open(log_file, "a") if log_file else open("/dev/null", "a") as log:
15
- process = subprocess.Popen(
16
- cmd,
17
- cwd=cwd,
18
- stdout=subprocess.PIPE,
19
- stderr=subprocess.STDOUT,
20
- text=True,
21
- )
22
- for line in process.stdout:
23
- print(line, end="")
24
- log.write(line)
25
- log.flush()
26
- process.wait()
27
-
28
- return process.returncode
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes