xflow.framework 0.1.0__tar.gz → 0.1.1__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 (54) hide show
  1. xflow_framework-0.1.1/PKG-INFO +29 -0
  2. xflow_framework-0.1.1/pyproject.toml +45 -0
  3. xflow_framework-0.1.1/setup.py +32 -0
  4. xflow_framework-0.1.1/tests/test_cli.py +137 -0
  5. xflow_framework-0.1.1/tests/test_command_result.py +39 -0
  6. xflow_framework-0.1.1/tests/test_container.py +41 -0
  7. xflow_framework-0.1.1/tests/test_env.py +126 -0
  8. xflow_framework-0.1.1/tests/test_node.py +182 -0
  9. xflow_framework-0.1.1/tests/test_pipeline.py +172 -0
  10. xflow_framework-0.1.1/tests/test_utils.py +35 -0
  11. xflow_framework-0.1.1/xflow/framework/statics/initdir/pipelines/__pycache__/__init__.cpython-310.pyc +0 -0
  12. xflow_framework-0.1.1/xflow/framework/statics/initdir/pipelines/__pycache__/__init__.cpython-312.pyc +0 -0
  13. xflow_framework-0.1.1/xflow/framework/statics/initdir/pipelines/__pycache__/example.cpython-310.pyc +0 -0
  14. xflow_framework-0.1.1/xflow/framework/statics/initdir/pipelines/__pycache__/example.cpython-312.pyc +0 -0
  15. xflow_framework-0.1.1/xflow/framework/statics/initdir/pipelines/__pycache__/postgres.cpython-310.pyc +0 -0
  16. xflow_framework-0.1.1/xflow/framework/statics/initdir/pipelines/__pycache__/template.cpython-310.pyc +0 -0
  17. xflow_framework-0.1.1/xflow/framework/statics/initdir/pipelines/__pycache__/template.cpython-312.pyc +0 -0
  18. xflow_framework-0.1.1/xflow/framework/version.py +5 -0
  19. xflow_framework-0.1.1/xflow.framework.egg-info/PKG-INFO +29 -0
  20. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow.framework.egg-info/SOURCES.txt +14 -5
  21. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow.framework.egg-info/dependency_links.txt +0 -0
  22. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow.framework.egg-info/entry_points.txt +0 -0
  23. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow.framework.egg-info/requires.txt +0 -2
  24. xflow_framework-0.1.1/xflow.framework.egg-info/top_level.txt +1 -0
  25. xflow_framework-0.1.0/MANIFEST.in +0 -5
  26. xflow_framework-0.1.0/PKG-INFO +0 -43
  27. xflow_framework-0.1.0/pyproject.toml +0 -3
  28. xflow_framework-0.1.0/requirements.txt +0 -6
  29. xflow_framework-0.1.0/setup.py +0 -67
  30. xflow_framework-0.1.0/xflow/framework/statics/initdir/.gitignore +0 -215
  31. xflow_framework-0.1.0/xflow/framework/statics/initdir/scripts/.gitkeep +0 -0
  32. xflow_framework-0.1.0/xflow/framework/statics/initdir/workdir/.gitkeep +0 -0
  33. xflow_framework-0.1.0/xflow/framework/version.py +0 -4
  34. xflow_framework-0.1.0/xflow.framework.egg-info/PKG-INFO +0 -43
  35. xflow_framework-0.1.0/xflow.framework.egg-info/top_level.txt +0 -3
  36. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/LICENSE +0 -0
  37. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/README.md +0 -0
  38. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/setup.cfg +0 -0
  39. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/__init__.py +0 -0
  40. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/common.py +0 -0
  41. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/container.py +0 -0
  42. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/env.py +0 -0
  43. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/errors.py +0 -0
  44. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/main.py +0 -0
  45. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/node.py +0 -0
  46. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/pipeline.py +0 -0
  47. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/ssh.py +0 -0
  48. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/statics/initdir/README.md +0 -0
  49. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/statics/initdir/env.yml +0 -0
  50. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/statics/initdir/pipelines/__init__.py +0 -0
  51. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/statics/initdir/pipelines/example.py +0 -0
  52. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/statics/initdir/pipelines/template.py +0 -0
  53. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/statics/initdir/requirements.txt +0 -0
  54. {xflow_framework-0.1.0 → xflow_framework-0.1.1}/xflow/framework/utils.py +0 -0
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: xflow.framework
3
+ Version: 0.1.1
4
+ Summary: 可以用 Python 写 Pipeline 的 CICD 框架。
5
+ Author-email: zhaowcheng <zhaowcheng@163.com>
6
+ License-Expression: BSD-2-Clause
7
+ Project-URL: Homepage, https://github.com/zhaowcheng/xflow.framework
8
+ Project-URL: Issues, https://github.com/zhaowcheng/xflow.framework/issues
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: ruamel.yaml
18
+ Requires-Dist: paramiko
19
+ Requires-Dist: decorator
20
+ Requires-Dist: filelock
21
+ Requires-Dist: docker
22
+ Requires-Dist: typed-settings[click,option-groups,pydantic]
23
+ Dynamic: description
24
+ Dynamic: description-content-type
25
+ Dynamic: license-file
26
+
27
+ # xflow.framework
28
+
29
+ 可以用 Python 写 Pipeline 的 CICD 框架。
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "xflow.framework"
7
+ description = "可以用 Python 写 Pipeline 的 CICD 框架。"
8
+ authors = [
9
+ { name = "zhaowcheng", email = "zhaowcheng@163.com" },
10
+ ]
11
+ license = "BSD-2-Clause"
12
+ license-files = ["LICENSE"]
13
+ requires-python = ">=3.10"
14
+ dependencies = [
15
+ "ruamel.yaml",
16
+ "paramiko",
17
+ "decorator",
18
+ "filelock",
19
+ "docker",
20
+ "typed-settings[pydantic,click,option-groups]",
21
+ ]
22
+ classifiers = [
23
+ "Operating System :: OS Independent",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ ]
29
+ dynamic = ["version", "readme"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/zhaowcheng/xflow.framework"
33
+ Issues = "https://github.com/zhaowcheng/xflow.framework/issues"
34
+
35
+ [project.scripts]
36
+ xflow = "xflow.framework.main:main"
37
+
38
+ [tool.setuptools.dynamic]
39
+
40
+ [tool.setuptools.packages.find]
41
+ include = ["xflow*"]
42
+ exclude = ["tests", "tests.*"]
43
+
44
+ [tool.setuptools.package-data]
45
+ "xflow.framework" = ["statics/initdir/**"]
@@ -0,0 +1,32 @@
1
+ # Build hook: read version from xflow/framework/version.py,
2
+ # generate long_description from README files.
3
+ # All other metadata lives in pyproject.toml.
4
+ import re
5
+
6
+ from setuptools import setup
7
+
8
+
9
+ _VERSION_FILE = "xflow/framework/version.py"
10
+
11
+
12
+ def _find_version() -> str:
13
+ with open(_VERSION_FILE, encoding="utf8") as f:
14
+ m = re.search(r'''__version__\s*=\s*['"]([^'"]+)''', f.read())
15
+ return m.group(1) if m else "0.0.0"
16
+
17
+
18
+ def _build_long_description(version: str) -> str:
19
+ with open("README.md", encoding="utf8") as f:
20
+ desc = f.read()
21
+ desc = desc.replace("/blob/master/", f"/blob/v{version}/")
22
+ desc = desc.replace("/tree/master/", f"/tree/v{version}/")
23
+ return desc
24
+
25
+
26
+ _VERSION = _find_version()
27
+
28
+ setup(
29
+ version=_VERSION,
30
+ long_description=_build_long_description(_VERSION),
31
+ long_description_content_type="text/markdown",
32
+ )
@@ -0,0 +1,137 @@
1
+ import os
2
+ import sys
3
+ import tempfile
4
+ import textwrap
5
+ import unittest
6
+ from pathlib import Path
7
+ from unittest.mock import patch
8
+
9
+ import click
10
+ from click.testing import CliRunner
11
+
12
+ from xflow.framework.main import RunGroup, main
13
+
14
+
15
+ class FakeNode:
16
+ is_container = False
17
+ existed = True
18
+
19
+ def mkcwd(self):
20
+ pass
21
+
22
+ def rmcwd(self):
23
+ pass
24
+
25
+ def remove(self, force=False):
26
+ pass
27
+
28
+
29
+ class FakeEnv:
30
+ def __init__(self, envfile):
31
+ self.envfile = envfile
32
+
33
+ def get_node(self, name):
34
+ return FakeNode()
35
+
36
+
37
+ class CliTest(unittest.TestCase):
38
+ def setUp(self):
39
+ self.old_cwd = os.getcwd()
40
+ self.drop_pipeline_modules()
41
+
42
+ def tearDown(self):
43
+ os.chdir(self.old_cwd)
44
+ self.drop_pipeline_modules()
45
+
46
+ def drop_pipeline_modules(self):
47
+ for name in list(sys.modules):
48
+ if name == "pipelines" or name.startswith("pipelines."):
49
+ sys.modules.pop(name)
50
+
51
+ def test_init_copies_template_into_empty_project(self):
52
+ runner = CliRunner()
53
+ with tempfile.TemporaryDirectory() as tmpdir:
54
+ projdir = Path(tmpdir).joinpath("project")
55
+
56
+ result = runner.invoke(main, ["-p", str(projdir), "init"])
57
+
58
+ self.assertEqual(result.exit_code, 0, result.output)
59
+ self.assertTrue(projdir.joinpath("env.yml").is_file())
60
+ self.assertTrue(projdir.joinpath("pipelines", "example.py").is_file())
61
+ self.assertIn(f"Initialized {projdir}", result.output)
62
+
63
+ def test_init_rejects_non_empty_project_directory(self):
64
+ runner = CliRunner()
65
+ with tempfile.TemporaryDirectory() as tmpdir:
66
+ projdir = Path(tmpdir).joinpath("project")
67
+ projdir.mkdir()
68
+ projdir.joinpath("keep.txt").write_text("data")
69
+
70
+ result = runner.invoke(main, ["-p", str(projdir), "init"])
71
+
72
+ self.assertEqual(result.exit_code, 1)
73
+ self.assertIn("exists and is not empty", result.output)
74
+
75
+ def test_run_group_lists_pipeline_modules_only(self):
76
+ with tempfile.TemporaryDirectory() as tmpdir:
77
+ pipelines = Path(tmpdir).joinpath("pipelines")
78
+ pipelines.mkdir()
79
+ pipelines.joinpath("__init__.py").write_text("")
80
+ pipelines.joinpath("template.py").write_text("")
81
+ pipelines.joinpath("demo.py").write_text("")
82
+ pipelines.joinpath("build.py").write_text("")
83
+ pipelines.joinpath("notes.txt").write_text("")
84
+ group = RunGroup("run")
85
+ ctx = click.Context(group, obj={"projdir": tmpdir})
86
+
87
+ self.assertCountEqual(group.list_commands(ctx), ["demo", "build"])
88
+
89
+ def test_run_executes_pipeline_command(self):
90
+ runner = CliRunner()
91
+ with tempfile.TemporaryDirectory() as tmpdir:
92
+ projdir = Path(tmpdir)
93
+ pipelines = projdir.joinpath("pipelines")
94
+ pipelines.mkdir()
95
+ pipelines.joinpath("__init__.py").write_text("")
96
+ pipelines.joinpath("demo.py").write_text(
97
+ textwrap.dedent(
98
+ """
99
+ from pathlib import Path
100
+ from xflow.framework.pipeline import Pipeline
101
+
102
+ class demo(Pipeline):
103
+ def stage1(self):
104
+ Path(self.projdir).joinpath("ran.txt").write_text("ok")
105
+ """
106
+ )
107
+ )
108
+
109
+ with patch("xflow.framework.main.Env", FakeEnv):
110
+ result = runner.invoke(main, ["-p", str(projdir), "run", "-n", "local", "demo"])
111
+
112
+ self.assertEqual(result.exit_code, 0, result.output)
113
+ self.assertEqual(projdir.joinpath("ran.txt").read_text(), "ok")
114
+
115
+ def test_failed_pipeline_command_returns_nonzero(self):
116
+ runner = CliRunner()
117
+ with tempfile.TemporaryDirectory() as tmpdir:
118
+ projdir = Path(tmpdir)
119
+ pipelines = projdir.joinpath("pipelines")
120
+ pipelines.mkdir()
121
+ pipelines.joinpath("__init__.py").write_text("")
122
+ pipelines.joinpath("broken.py").write_text(
123
+ textwrap.dedent(
124
+ """
125
+ from xflow.framework.pipeline import Pipeline
126
+
127
+ class broken(Pipeline):
128
+ def stage1(self):
129
+ raise RuntimeError("boom")
130
+ """
131
+ )
132
+ )
133
+
134
+ with patch("xflow.framework.main.Env", FakeEnv):
135
+ result = runner.invoke(main, ["-p", str(projdir), "run", "-n", "local", "broken"])
136
+
137
+ self.assertEqual(result.exit_code, 1)
@@ -0,0 +1,39 @@
1
+ import unittest
2
+
3
+ from xflow.framework.ssh import CommandResult
4
+
5
+
6
+ class CommandResultTest(unittest.TestCase):
7
+ def test_normalizes_output_and_keeps_metadata(self):
8
+ result = CommandResult("\x1b[31m hello\x1b[0m\xe9\r\nworld\n", rc=7, cmd="echo hello")
9
+
10
+ self.assertEqual(str(result), "hello\nworld")
11
+ self.assertEqual(result.rc, 7)
12
+ self.assertEqual(result.cmd, "echo hello")
13
+
14
+ def test_getfield_finds_last_matching_line(self):
15
+ result = CommandResult(
16
+ """\
17
+ UID PID CMD
18
+ postgres 45 /opt/pgsql/bin/postgres
19
+ postgres 51 postgres: checkpointer process
20
+ postgres 52 postgres: writer process
21
+ """
22
+ )
23
+
24
+ self.assertEqual(result.getfield("postgres", 2), "52")
25
+ self.assertEqual(result.getfield("checkpointer", 1, sep=":"), "postgres 51 postgres")
26
+ self.assertEqual(result.getfield(1, 2), "PID")
27
+ self.assertIsNone(result.getfield("missing", 1))
28
+
29
+ def test_getcol_returns_available_fields(self):
30
+ result = CommandResult(
31
+ """\
32
+ name: value
33
+ other: second
34
+ lonely
35
+ """
36
+ )
37
+
38
+ self.assertEqual(result.getcol(1, sep=":"), ["name", "other", "lonely"])
39
+ self.assertEqual(result.getcol(2, sep=":"), [" value", " second"])
@@ -0,0 +1,41 @@
1
+ import unittest
2
+
3
+ from xflow.framework.container import ContainerConnection
4
+
5
+
6
+ class DummyContainer:
7
+ name = "created"
8
+
9
+
10
+ class ContainerConnectionTest(unittest.TestCase):
11
+ def test_requires_exactly_one_container_name_or_image(self):
12
+ with self.assertRaisesRegex(ValueError, "only one"):
13
+ ContainerConnection("127.0.0.1")
14
+
15
+ with self.assertRaisesRegex(ValueError, "only one"):
16
+ ContainerConnection("127.0.0.1", name="existing", image="busybox")
17
+
18
+ def test_client_certificate_and_key_must_be_paired(self):
19
+ with self.assertRaisesRegex(ValueError, "clientcert"):
20
+ ContainerConnection("127.0.0.1", name="existing", clientcert="cert.pem")
21
+
22
+ with self.assertRaisesRegex(ValueError, "clientcert"):
23
+ ContainerConnection("127.0.0.1", name="existing", clientkey="key.pem")
24
+
25
+ def test_connection_string_and_existed_flag(self):
26
+ existing = ContainerConnection("127.0.0.1", name="existing")
27
+ self.assertTrue(existing.existed)
28
+ self.assertEqual(existing._connstr, "docker://127.0.0.1:2375:existing")
29
+
30
+ created = ContainerConnection("127.0.0.1", user="root", image="busybox")
31
+ self.assertFalse(created.existed)
32
+ self.assertEqual(created._connstr, "docker://root@127.0.0.1:2375:busybox->...")
33
+
34
+ created._container = DummyContainer()
35
+ self.assertEqual(created._connstr, "docker://root@127.0.0.1:2375:busybox->created")
36
+
37
+ def test_default_locale_environment_is_set(self):
38
+ conn = ContainerConnection("127.0.0.1", name="existing", envs={"LANG": "C.UTF-8"})
39
+
40
+ self.assertEqual(conn._envs["LANG"], "C.UTF-8")
41
+ self.assertEqual(conn._envs["LANGUAGE"], "en_US.UTF-8")
@@ -0,0 +1,126 @@
1
+ import tempfile
2
+ import textwrap
3
+ import unittest
4
+ from pathlib import Path
5
+ from unittest.mock import patch
6
+
7
+ from xflow.framework.env import Env
8
+ from xflow.framework.errors import NoSuchDockerError, NoSuchNodeError
9
+
10
+
11
+ class FakeNode:
12
+ kind = "base"
13
+
14
+ def __init__(self, **kwargs):
15
+ self.kwargs = kwargs
16
+ self.name = kwargs["name"]
17
+
18
+
19
+ class FakeNativeNode(FakeNode):
20
+ kind = "native"
21
+
22
+
23
+ class FakeContainerNode(FakeNode):
24
+ kind = "container"
25
+
26
+
27
+ class EnvTest(unittest.TestCase):
28
+ def write_env(self, content):
29
+ tmpdir = tempfile.TemporaryDirectory()
30
+ self.addCleanup(tmpdir.cleanup)
31
+ envfile = Path(tmpdir.name).joinpath("env.yml")
32
+ envfile.write_text(textwrap.dedent(content), encoding="utf8")
33
+ return envfile
34
+
35
+ def test_loads_native_and_container_nodes(self):
36
+ envfile = self.write_env(
37
+ """
38
+ nodes:
39
+ - name: native
40
+ docker:
41
+ ip: 192.0.2.10
42
+ sshport: 22
43
+ user: builder
44
+ password: secret
45
+ workdir: /work/native
46
+ envs:
47
+ A: B
48
+ - name: container
49
+ docker: local-docker
50
+ user: root
51
+ workdir: /work/container
52
+ container: buildbox
53
+ image:
54
+ runargs: {}
55
+ envs:
56
+ C: D
57
+ dockers:
58
+ - name: local-docker
59
+ ip: 127.0.0.1
60
+ port: 2375
61
+ tls:
62
+ cacert:
63
+ clientcert:
64
+ clientkey:
65
+ """
66
+ )
67
+
68
+ with patch("xflow.framework.env.NativeNode", FakeNativeNode), patch(
69
+ "xflow.framework.env.ContainerNode", FakeContainerNode
70
+ ):
71
+ env = Env(envfile)
72
+
73
+ native = env.get_node("native")
74
+ container = env.get_node("container")
75
+
76
+ self.assertIsInstance(native, FakeNativeNode)
77
+ self.assertEqual(native.kwargs["ip"], "192.0.2.10")
78
+ self.assertEqual(native.kwargs["envs"], {"A": "B"})
79
+
80
+ self.assertIsInstance(container, FakeContainerNode)
81
+ self.assertEqual(container.kwargs["ip"], "127.0.0.1")
82
+ self.assertEqual(container.kwargs["container"], "buildbox")
83
+ self.assertIsNone(container.kwargs["image"])
84
+ self.assertEqual(container.kwargs["envs"], {"C": "D"})
85
+
86
+ def test_missing_docker_reference_raises(self):
87
+ envfile = self.write_env(
88
+ """
89
+ nodes:
90
+ - name: container
91
+ docker: missing
92
+ user: root
93
+ workdir: /work/container
94
+ container: buildbox
95
+ image:
96
+ runargs: {}
97
+ envs: {}
98
+ dockers: []
99
+ """
100
+ )
101
+
102
+ with patch("xflow.framework.env.ContainerNode", FakeContainerNode):
103
+ with self.assertRaisesRegex(NoSuchDockerError, "missing"):
104
+ Env(envfile)
105
+
106
+ def test_get_node_raises_for_unknown_name(self):
107
+ envfile = self.write_env(
108
+ """
109
+ nodes:
110
+ - name: native
111
+ docker:
112
+ ip: 192.0.2.10
113
+ sshport: 22
114
+ user: builder
115
+ password: secret
116
+ workdir: /work/native
117
+ envs: {}
118
+ dockers: []
119
+ """
120
+ )
121
+
122
+ with patch("xflow.framework.env.NativeNode", FakeNativeNode):
123
+ env = Env(envfile)
124
+
125
+ with self.assertRaisesRegex(NoSuchNodeError, "unknown"):
126
+ env.get_node("unknown")
@@ -0,0 +1,182 @@
1
+ import tempfile
2
+ import unittest
3
+ from contextlib import contextmanager
4
+ from pathlib import Path, PurePosixPath
5
+
6
+ from xflow.framework.node import Node
7
+ from xflow.framework.pipeline import Pipeline
8
+ from xflow.framework.ssh import CommandResult
9
+
10
+
11
+ class FakeConnection:
12
+ existed = True
13
+
14
+ def __init__(self):
15
+ self.current_dir = ""
16
+ self.dir_calls = []
17
+ self.exec_calls = []
18
+ self.getfile_calls = []
19
+ self.putfile_calls = []
20
+ self.remove_calls = []
21
+ self.existing = set()
22
+ self.uploaded_text = {}
23
+
24
+ @contextmanager
25
+ def dir(self, path):
26
+ self.dir_calls.append(path)
27
+ previous = self.current_dir
28
+ self.current_dir = str(path)
29
+ try:
30
+ yield
31
+ finally:
32
+ self.current_dir = previous
33
+
34
+ def exec(self, cmd, envs=None):
35
+ self.exec_calls.append((cmd, envs, self.current_dir))
36
+ if cmd.startswith("mkdir -p "):
37
+ self.existing.add(cmd.removeprefix("mkdir -p "))
38
+ return CommandResult("ok", rc=0, cmd=cmd)
39
+
40
+ def getfile(self, rfile, ldir):
41
+ self.getfile_calls.append((rfile, ldir))
42
+
43
+ def putfile(self, lfile, rdir):
44
+ lfile = Path(lfile)
45
+ rdir = PurePosixPath(rdir)
46
+ rfile = rdir.joinpath(lfile.name)
47
+ self.putfile_calls.append((lfile, rdir))
48
+ self.existing.add(str(rfile))
49
+ try:
50
+ self.uploaded_text[str(rfile)] = lfile.read_text()
51
+ except (FileNotFoundError, UnicodeDecodeError):
52
+ pass
53
+
54
+ def exists(self, path):
55
+ return str(path) in self.existing
56
+
57
+ def remove(self, force=False):
58
+ self.remove_calls.append(force)
59
+
60
+
61
+ class DummyPipeline(Pipeline):
62
+ def stage1(self):
63
+ pass
64
+
65
+
66
+ class FakeEnv:
67
+ def __init__(self, node):
68
+ self.node = node
69
+
70
+ def get_node(self, name):
71
+ return self.node
72
+
73
+
74
+ class NodeTest(unittest.TestCase):
75
+ def make_node_and_pipeline(self):
76
+ conn = FakeConnection()
77
+ node = Node("local", "builder", "/base", conn)
78
+ tmpdir = tempfile.TemporaryDirectory()
79
+ self.addCleanup(tmpdir.cleanup)
80
+ pipeline = DummyPipeline(tmpdir.name, FakeEnv(node), "local", DummyPipeline.Options())
81
+ pipeline.buildid = 42
82
+ return node, conn, pipeline
83
+
84
+ def test_cwd_uses_current_pipeline_and_dir_context(self):
85
+ node, _, pipeline = self.make_node_and_pipeline()
86
+
87
+ self.assertEqual(node.cwd, PurePosixPath("/base/DummyPipeline/42"))
88
+
89
+ with node.dir("src"):
90
+ self.assertEqual(node.cwd, PurePosixPath("/base/DummyPipeline/42/src"))
91
+
92
+ with node.dir("/absolute"):
93
+ self.assertEqual(node.cwd, PurePosixPath("/absolute"))
94
+
95
+ self.assertEqual(node.cwd, PurePosixPath("/base/DummyPipeline/42"))
96
+ self.assertIs(pipeline.node, node)
97
+
98
+ def test_mkcwd_and_rmcwd_execute_against_pipeline_workdir(self):
99
+ node, conn, pipeline = self.make_node_and_pipeline()
100
+
101
+ node.mkcwd()
102
+ node.rmcwd()
103
+
104
+ self.assertEqual(
105
+ conn.exec_calls,
106
+ [
107
+ ("mkdir -p /base/DummyPipeline/42", None, ""),
108
+ ("rm -rf /base/DummyPipeline/42", None, ""),
109
+ ],
110
+ )
111
+ self.assertIs(pipeline.node, node)
112
+
113
+ def test_exec_runs_inside_current_workdir(self):
114
+ node, conn, pipeline = self.make_node_and_pipeline()
115
+
116
+ with node.dir("src"):
117
+ result = node.exec("make test", envs={"A": "B"})
118
+
119
+ self.assertEqual(result, "ok")
120
+ self.assertEqual(conn.dir_calls, [PurePosixPath("/base/DummyPipeline/42/src")])
121
+ self.assertEqual(conn.exec_calls, [("make test", {"A": "B"}, "/base/DummyPipeline/42/src")])
122
+ self.assertIs(pipeline.node, node)
123
+
124
+ def test_nixenv_wraps_command_and_clears_after_context(self):
125
+ node, conn, pipeline = self.make_node_and_pipeline()
126
+
127
+ with node.nixenv("/flake", system="x86_64-linux", name="dev", options="--impure"):
128
+ node.exec("make")
129
+ node.exec("pwd")
130
+
131
+ self.assertEqual(
132
+ conn.exec_calls[0][0],
133
+ "nix develop /flake#devShells.x86_64-linux.dev --impure --log-format raw -c make",
134
+ )
135
+ self.assertEqual(conn.exec_calls[1][0], "pwd")
136
+ self.assertIs(pipeline.node, node)
137
+
138
+ def test_exec_script_uploads_missing_script_once(self):
139
+ node, conn, pipeline = self.make_node_and_pipeline()
140
+ with tempfile.TemporaryDirectory() as tmpdir:
141
+ script = Path(tmpdir).joinpath("build.sh")
142
+ script.write_text("#!/bin/sh\necho ok\n")
143
+
144
+ node.exec_script(script, "arg1")
145
+ node.exec_script(script, "arg2")
146
+
147
+ remote_script = "/base/DummyPipeline/42/scripts/build.sh"
148
+ self.assertEqual(conn.exec_calls[0][0], "mkdir -p /base/DummyPipeline/42/scripts")
149
+ self.assertEqual(conn.exec_calls[1][0], f"chmod +x {remote_script}")
150
+ self.assertEqual(conn.exec_calls[2][0], f"{remote_script} arg1")
151
+ self.assertEqual(conn.exec_calls[3][0], f"{remote_script} arg2")
152
+ self.assertEqual(len(conn.putfile_calls), 1)
153
+ self.assertIs(pipeline.node, node)
154
+
155
+ def test_file_helpers_delegate_to_connection(self):
156
+ node, conn, pipeline = self.make_node_and_pipeline()
157
+
158
+ node.getfile("/remote/out.txt", "/local")
159
+ node.putfile("/local/in.txt", "/remote")
160
+ self.assertFalse(node.exists("/missing"))
161
+
162
+ self.assertEqual(conn.getfile_calls, [("/remote/out.txt", "/local")])
163
+ self.assertEqual(conn.putfile_calls[-1], (Path("/local/in.txt"), PurePosixPath("/remote")))
164
+ self.assertIs(pipeline.node, node)
165
+
166
+ def test_write_uploads_text_file_to_remote_directory(self):
167
+ node, conn, pipeline = self.make_node_and_pipeline()
168
+
169
+ node.write("hello", "/remote/message.txt")
170
+
171
+ self.assertEqual(conn.uploaded_text["/remote/message.txt"], "hello")
172
+ self.assertIs(pipeline.node, node)
173
+
174
+ def test_git_clones_and_checks_out_revision(self):
175
+ node, conn, pipeline = self.make_node_and_pipeline()
176
+
177
+ node.git("https://example.invalid/repo.git", "v1.0", options="--depth 1")
178
+
179
+ self.assertEqual(conn.exec_calls[0][0], "git clone --depth 1 https://example.invalid/repo.git repo")
180
+ self.assertEqual(conn.dir_calls[1], PurePosixPath("/base/DummyPipeline/42/repo"))
181
+ self.assertEqual(conn.exec_calls[1][0], "git checkout v1.0")
182
+ self.assertIs(pipeline.node, node)