Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Unit tests
name: Tox Tests

on:
push:
Expand All @@ -11,12 +11,12 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]

runs-on: ubuntu-24.04

steps:
- name: Install age
- name: Install age + subversion
run: |
sudo apt-get update
sudo apt-get install -y age
Expand All @@ -35,7 +35,10 @@ jobs:
- name: Show environment
run: set

- name: Test
run: uv tool install tox --with tox-uv-bare --with tox-gh
- name: Install tox
run: uv tool install --python ${{ matrix.python-version }} tox --with tox-uv-bare --with tox-gh

- run: tox
- name: Run tests
run: tox
env:
TOXENV: "${{ matrix.python-version }}"
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ coverage.json
/src/batou/secrets/tests/fixture/gnupg/random_seed
/src/batou/secrets/tests/fixture/gnupg/S.*
__pycache__
batou-stubs/ruff_rules
monkeytype.sqlite3
pip-selfcheck.json
pip-wheel-metadata
pyvenv.cfg
Expand Down
9 changes: 9 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,12 @@ repos:
hooks:
- id: ruff-check
- id: ruff-format
- repo: local
hooks:
- id: ty-check
name: ty type check
entry: uv run --with ty ty check src/ stubs/ examples/
language: system
types: [python]
pass_filenames: false
always_run: true
4 changes: 4 additions & 0 deletions CHANGES.d/20260504_005557_ts_ts_types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- Add separate type stub package for type-checking batou itself and batou deployments.
Unlike batou itself, they are written in Python 3.14+ style for more accurate typing.
This is okay for stubs which are consumed by IDE and type checkers like `ty` or `mypy`.
The package will be published separately as `batou-stubs` on PyPI.
39 changes: 39 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
build-backend = "hatchling.build"
requires = ["hatchling>=1.27"]

[tool.uv.workspace]
members = ["stubs"]

[tool.uv.sources]
stubs = { workspace = true }

[project]
name = "batou"
description = "A utility for automating multi-host, multi-environment software builds and deployments."
Expand Down Expand Up @@ -72,10 +78,21 @@ lint = [
"pre-commit",
"ruff",
]
typing = [
"flake8-pyi",
"mypy",
"ty",
]
release = [
"zest.releaser",
]

[tool.mypy]
files = ["stubs"]
[[tool.mypy.overrides]]
module = ["pytest", "pytest.*"]
ignore_missing_imports = true

[tool.ruff]
line-length = 80
exclude = [
Expand Down Expand Up @@ -134,6 +151,28 @@ commands = [
["pre-commit", "run", "--all-files", "--show-diff-on-failure"]
]

[tool.tox.env.stubs]
description = "Lint + type-check stubs (ruff, flake8-pyi, mypy, stubtest, ty)"
skip_install = true
setenv = { MYPYPATH = "stubs" }
commands = [
["uv", "run", "ruff", "check", "--config", "stubs/ruff.toml", "stubs/"],
["uv", "run", "ruff", "format", "--config", "stubs/ruff.toml", "--check", "stubs/"],
["uv", "run", "--with", "flake8-pyi", "flake8", "--select=Y0", "stubs/"],
["uv", "run", "--with", "mypy", "mypy", "stubs/"],
["uv", "run", "--with", "mypy", "python", "-m", "mypy.stubtest", "batou", "--allowlist", "stubs/stubtest-allowlist.txt"],
["uv", "run", "--with", "ty", "ty", "check", "stubs/"],
]

[tool.tox.env.typing]
description = "Type-check batou source code with stubs"
skip_install = true
setenv = { MYPYPATH = "stubs" }
commands = [
["uv", "run", "--with", "mypy", "mypy", "src/", "--strict", "--ignore-missing-imports"],
["uv", "run", "--with", "ty", "ty", "check", "src/", "examples/"],
]

[tool.scriv]
format = "md"
fragment_directory = "CHANGES.d"
Expand Down
10 changes: 6 additions & 4 deletions src/batou/lib/tests/test_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ def test_verify_should_pass_checkum_function_to_hash(self):
component = Download("url", checksum="foobar:1234")
component.configure()
with (
mock.patch("os.path.exists") as exists,
mock.patch("batou.utils.hash") as buh,
mock.patch("os.path.exists", autospec=True) as exists,
mock.patch("batou.utils.hash", autospec=True) as buh,
):
exists.return_value = True
try:
Expand All @@ -34,8 +34,10 @@ def test_update_should_raise_AssertionError_on_checksum_mismatch(self):
download = Download("url", checksum="foobar:1234")
download.configure()
with (
mock.patch("batou.lib.download.urlretrieve") as retrieve,
mock.patch("batou.utils.hash") as buh,
mock.patch(
"batou.lib.download.urlretrieve", autospec=True
) as retrieve,
mock.patch("batou.utils.hash", autospec=True) as buh,
self.assertRaises(AssertionError) as err,
):
retrieve.return_value = "url", []
Expand Down
28 changes: 14 additions & 14 deletions src/batou/lib/tests/test_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -1067,26 +1067,26 @@ def test_directory_does_not_copy_excluded_files(root):
assert len(os.listdir("work/mycomponent/target")) == 1


@patch("os.chown")
@patch("os.chown", autospec=True)
def test_owner_lazy(chown, root):
with open("asdf", "w"):
pass
file = File("asdf", owner=getpass.getuser())
root.component += file
root.component.deploy()
assert not os.chown.called
assert not chown.called


@patch("os.chown")
@patch("os.stat")
@patch("os.chown", autospec=True)
def test_owner_calls_chown(chown, stat, root):
os.stat.return_value = Mock()
os.stat.return_value.st_uid = 0
os.stat.return_value.st_mode = 0
# Real stat_result so genericpath.isfile (S_ISREG) works on all Pythons.
# st_mode=0o100644 → regular file, st_uid=0 → triggers chown.
stat.return_value = os.stat_result((0o100644, 0, 0, 1, 0, 0, 0, 0, 0, 0))
file = File("asdf", owner=getpass.getuser(), content="")
root.component += file
root.component.deploy()
assert os.chown.called
assert chown.called


def test_owner_is_configurable_when_user_doesnt_exist_yet(root):
Expand All @@ -1103,26 +1103,26 @@ def current_group():
return group.gr_name


@patch("os.chown")
@patch("os.chown", autospec=True)
def test_group_lazy(chown, root):
with open("asdf", "w"):
pass
file = File("asdf", group=current_group())
root.component += file
root.component.deploy()
assert not os.chown.called
assert not chown.called


@patch("os.chown")
@patch("os.stat")
@patch("os.chown", autospec=True)
def test_group_calls_chown(chown, stat, root):
os.stat.return_value = Mock()
os.stat.return_value.st_gid = 0
os.stat.return_value.st_mode = 0
# Real stat_result so genericpath.isfile (S_ISREG) works on all Pythons.
# st_mode=0o100644 → regular file, st_gid=0 → triggers chown.
stat.return_value = os.stat_result((0o100644, 0, 0, 1, 0, 0, 0, 0, 0, 0))
file = File("asdf", group=current_group(), content="")
root.component += file
root.component.deploy()
assert os.chown.called
assert chown.called


def test_group_is_configurable_when_group_doesnt_exist_yet(root):
Expand Down
17 changes: 8 additions & 9 deletions src/batou/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,27 +224,26 @@ def main(args: Optional[list] = None) -> None:
)
migrate.set_defaults(func=batou.migrate.main)

args = parser.parse_args(args)
parsed = parser.parse_args(args)

# Consume global arguments
batou.output.enable_debug = args.debug
batou.secrets.encryption.debug = args.debug
batou.secrets.manage.debug = args.debug
batou.output.enable_debug = parsed.debug
batou.secrets.encryption.debug = parsed.debug

# Pass over to function
if args.func.__name__ == "print_usage":
args.func()
if parsed.func.__name__ == "print_usage":
parsed.func()
sys.exit(1)

if args.func != batou.migrate.main:
if parsed.func != batou.migrate.main:
output.backend = TerminalBackend()
batou.migrate.assert_up_to_date()

func_args = dict(args._get_kwargs())
func_args = dict(parsed._get_kwargs())
del func_args["func"]
del func_args["debug"]
try:
return args.func(**func_args)
return parsed.func(**func_args)
except batou.FileLockedError as e:
# Nicer error reporting for non-deployment commands.
print(e)
30 changes: 15 additions & 15 deletions src/batou/tests/test_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ def test_parse_host_components():
}


@mock.patch("batou.environment.Environment.add_root")
@mock.patch("batou.environment.Environment.add_root", autospec=True)
def test_load_hosts_should_merge_single_and_multi_definition(add_root):
e = Environment("name")
config = Config(None)
Expand All @@ -139,12 +139,12 @@ def test_load_hosts_should_merge_single_and_multi_definition(add_root):
)
e.load_hosts(config)
assert [
mock.call("bar", e.hosts["foo"], [], False),
mock.call("bar", e.hosts["baz"], [], False),
mock.call(e, "bar", e.hosts["foo"], [], False),
mock.call(e, "bar", e.hosts["baz"], [], False),
] == add_root.call_args_list


@mock.patch("batou.environment.Environment.add_root")
@mock.patch("batou.environment.Environment.add_root", autospec=True)
def test_load_hosts_should_load_single_hosts_section(add_root):
e = Environment("name")
config = Config(None)
Expand All @@ -155,10 +155,10 @@ def test_load_hosts_should_load_single_hosts_section(add_root):
"""
)
e.load_hosts(config)
add_root.assert_called_once_with("bar", e.hosts["foo"], [], False)
add_root.assert_called_once_with(e, "bar", e.hosts["foo"], [], False)


@mock.patch("batou.environment.Environment.add_root")
@mock.patch("batou.environment.Environment.add_root", autospec=True)
def test_load_hosts_should_load_multi_hosts_section(add_root):
pass
e = Environment("name")
Expand All @@ -170,10 +170,10 @@ def test_load_hosts_should_load_multi_hosts_section(add_root):
"""
)
e.load_hosts(config)
add_root.assert_called_once_with("bar", e.hosts["foo"], [], False)
add_root.assert_called_once_with(e, "bar", e.hosts["foo"], [], False)


@mock.patch("batou.environment.Environment.add_root")
@mock.patch("batou.environment.Environment.add_root", autospec=True)
def test_load_hosts_multi_should_use_ignore_flag(add_root):
pass
e = Environment("name")
Expand All @@ -189,7 +189,7 @@ def test_load_hosts_multi_should_use_ignore_flag(add_root):
assert e.hosts["foo"].ignore


@mock.patch("batou.environment.Environment.add_root")
@mock.patch("batou.environment.Environment.add_root", autospec=True)
def test_load_hosts_should_break_on_duplicate_definition(add_root):
e = Environment("name")
config = Config(None)
Expand All @@ -206,7 +206,7 @@ def test_load_hosts_should_break_on_duplicate_definition(add_root):
assert "foo" == e.exceptions[0].affected_hostname


@mock.patch("batou.environment.Environment.add_root")
@mock.patch("batou.environment.Environment.add_root", autospec=True)
def test_load_hosts_multi_should_use_env_platform(add_root):
e = Environment("name")
e.platform = mock.sentinel.platform
Expand All @@ -222,7 +222,7 @@ def test_load_hosts_multi_should_use_env_platform(add_root):
assert e.hosts["foo"].platform == mock.sentinel.platform


@mock.patch("batou.environment.Environment.add_root")
@mock.patch("batou.environment.Environment.add_root", autospec=True)
def test_load_hosts_multi_should_use_host_platform_if_given(add_root):
pass
e = Environment("name")
Expand All @@ -239,7 +239,7 @@ def test_load_hosts_multi_should_use_host_platform_if_given(add_root):
assert e.hosts["foo"].platform == "specific"


@mock.patch("batou.environment.Environment.add_root")
@mock.patch("batou.environment.Environment.add_root", autospec=True)
def test_load_hosts_single_should_use_env_platform(add_root):
e = Environment("name")
e.platform = mock.sentinel.platform
Expand Down Expand Up @@ -285,12 +285,12 @@ def _get_components(hostname):
assert "hello3" not in localhost.components


@mock.patch("batou.remote_core.Output.line")
@mock.patch("batou.remote_core.Output.line", autospec=True)
def test_log_in_component_configure_is_put_out(output, sample_service):
e = Environment("test-with-provide-require")
e.load()
e.configure()
log = "\n".join(c[0][0].strip() for c in output.call_args_list)
log = "\n".join(c[0][1].strip() for c in output.call_args_list)
# Provide is *always* logged first, due to provide/require ordering.
assert (
"""\
Expand All @@ -305,7 +305,7 @@ def test_log_in_component_configure_is_put_out(output, sample_service):
for root in e.root_dependencies():
root.component.deploy(True)

log = "\n".join(c[0][0].strip() for c in output.call_args_list)
log = "\n".join(c[0][1].strip() for c in output.call_args_list)
assert (
"""\
localhost: Hello
Expand Down
2 changes: 1 addition & 1 deletion src/batou/tests/test_remote_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ def test_build_batou_virtualenv_exists(mock_remote_core, tmpdir):


def test_expand_deployment_base(tmpdir):
with mock.patch("os.path.expanduser") as expanduser:
with mock.patch("os.path.expanduser", autospec=True) as expanduser:
expanduser.return_value = str(tmpdir)
remote_core.ensure_repository("~/deployment", "rsync")
assert remote_core.target_directory == str(tmpdir)
Expand Down
4 changes: 2 additions & 2 deletions src/batou/tests/test_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,13 @@ def test_jinja2_template_file():
assert ref.read() == result


@mock.patch("batou.remote_core.Output.line")
@mock.patch("batou.remote_core.Output.line", autospec=True)
def test_jinja2_large_template_str(output):
tmpl = TemplateEngine.get("jinja2")
result = tmpl.expand("hello {{hello}}" * 15000, sample_dict)
assert result.startswith("hello world")

log = "\n".join(c[0][0].strip() for c in output.call_args_list)
log = "\n".join(c[0][1].strip() for c in output.call_args_list)

assert (
"""\
Expand Down
Loading
Loading