From 3306059cb3434948e1ff3f354e5431c9ab61cf7c Mon Sep 17 00:00:00 2001 From: Niklas Hauser Date: Mon, 12 Oct 2026 11:42:18 +0200 Subject: [PATCH 1/4] Return relative paths with forward slashes on all hosts Relative paths are written into generated files, where the backslashes of a Windows path are escape characters. Absolute paths remain native. --- lbuild/buildlog.py | 3 +- lbuild/environment.py | 9 ++--- lbuild/utils.py | 10 ++++++ test/buildlog_test.py | 19 +++++++++++ test/environment_test.py | 72 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 test/environment_test.py diff --git a/lbuild/buildlog.py b/lbuild/buildlog.py index 2b305d7..94f60fe 100644 --- a/lbuild/buildlog.py +++ b/lbuild/buildlog.py @@ -19,6 +19,7 @@ import lbuild.utils from .exception import LbuildBuildlogOverwritingFileException +from .utils import posixpath LOGGER = logging.getLogger('lbuild.buildlog') @@ -62,7 +63,7 @@ def local_filename_out(self, relative_to=None): @staticmethod def _local_filename(filename, path): localfile = join(relpath(dirname(filename), path), basename(filename)) - return normpath(localfile) + return posixpath(normpath(localfile)) def __repr__(self): return "<{}: {} -> {}>".format(self.module_name, self.filename_in, self.filename_out) diff --git a/lbuild/environment.py b/lbuild/environment.py index 84850ff..58daaf9 100644 --- a/lbuild/environment.py +++ b/lbuild/environment.py @@ -24,6 +24,7 @@ import lbuild.facade as lf import lbuild.exception as le from .parser import Parser +from .utils import posixpath SIMULATE = False SYMLINK_ON_COPY = False @@ -359,7 +360,7 @@ def relcwdoutpath(self, *path): """Relocate given path to the outpath in respect to current working directory""" relative = self.cwdpath("") path = os.path.join(self.cwdoutpath(""), *path) - return os.path.relpath(os.path.abspath(path), os.path.abspath(relative)) + return posixpath(os.path.relpath(os.path.abspath(path), os.path.abspath(relative))) def reloutpath(self, path, relative=None): if relative is None: @@ -367,7 +368,7 @@ def reloutpath(self, path, relative=None): else: relative = os.path.join(self.__outpath, relative) path = os.path.join(self.__outpath, path) - return os.path.relpath(os.path.abspath(path), os.path.abspath(relative)) + return posixpath(os.path.relpath(os.path.abspath(path), os.path.abspath(relative))) def generated_local_files(self, filterfunc=None): """ @@ -382,10 +383,10 @@ def generated_local_files(self, filterfunc=None): filenames = [] operations = self.buildlog.operations_per_module(self.__module.fullname) for operation in operations: - filename = os.path.normpath( + filename = posixpath(os.path.normpath( os.path.join(os.path.relpath(os.path.dirname(operation.filename_out), self.outpath('.')), - os.path.basename(operation.filename_out))) + os.path.basename(operation.filename_out)))) if filterfunc is None or filterfunc(filename): filenames.append(filename) return filenames diff --git a/lbuild/utils.py b/lbuild/utils.py index c7fa0ef..ab46b7e 100644 --- a/lbuild/utils.py +++ b/lbuild/utils.py @@ -29,6 +29,16 @@ ] +def posixpath(path): + """ + Convert a relative path to forward slashes on every host. + + Paths that are written into generated files must not contain the + backslashes of a Windows path, since they are escape characters there. + """ + return path.replace(os.sep, "/") + + def ignore_files(*files): """ Ignore file and folder names without checking the full path. diff --git a/test/buildlog_test.py b/test/buildlog_test.py index a8ba73d..a5a29ed 100644 --- a/test/buildlog_test.py +++ b/test/buildlog_test.py @@ -11,7 +11,9 @@ import os import sys +import ntpath import unittest +from unittest import mock # Hack to support the usage of `coverage` sys.path.append(os.path.abspath(".")) @@ -100,6 +102,23 @@ def test_should_create_local_path(self): self.assertEqual("in1", o1.local_filename_in()) self.assertEqual("out1", o1.local_filename_out("test")) + def test_should_create_local_path_with_forward_slashes(self): + # Use the Windows path functions on every host + names = ["join", "relpath", "dirname", "normpath", "basename", "isabs", "abspath"] + with mock.patch.multiple("lbuild.buildlog", **{n: getattr(ntpath, n) for n in names}), \ + mock.patch("os.path", ntpath), mock.patch("os.sep", "\\"): + log = lbuild.buildlog.BuildLog("C:\\project\\out", cwd="C:\\project") + self.module1._filename = "C:\\repo\\m1\\module.lb" + o1 = log.log(self.module1, "sub\\in1", "generated\\nanopb\\out1") + + self.assertEqual("C:\\project\\out\\generated\\nanopb\\out1", o1.filename_out) + self.assertEqual("sub/in1", o1.local_filename_in()) + self.assertEqual("generated/nanopb/out1", o1.local_filename_out()) + self.assertEqual("nanopb/out1", o1.local_filename_out("generated")) + self.assertEqual("out/generated/nanopb/out1", o1.local_filename_out("..")) + self.assertEqual("generated/nanopb/out1", + lbuild.facade.BuildLogOperationFacade(o1).filename) + if __name__ == '__main__': unittest.main() diff --git a/test/environment_test.py b/test/environment_test.py new file mode 100644 index 0000000..0ac6163 --- /dev/null +++ b/test/environment_test.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# Copyright (c) 2026, Niklas Hauser +# All Rights Reserved. +# +# The file is part of the lbuild project and is released under the +# 2-clause BSD license. See the file `LICENSE.txt` for the full license +# governing this code. + +import os +import sys +import ntpath +import unittest +from unittest import mock + +# Hack to support the usage of `coverage` +sys.path.append(os.path.abspath(".")) + +import lbuild + + +class EnvironmentTest(unittest.TestCase): + + def _environment(self, filepath, outpath, cwd): + repoinit = lbuild.repository.RepositoryInit(None, filepath) + repoinit.name = "repo" + repo = lbuild.repository.Repository(repoinit) + + module = lbuild.module.ModuleInit(repo, os.path.join(filepath, "module.lb")) + module.parent = repo.name + module.name = "module" + module.available = True + module, = lbuild.module.build_modules([module]) + + buildlog = lbuild.buildlog.BuildLog(outpath, cwd=cwd) + return lbuild.environment.Environment(module, buildlog) + + def test_should_return_relative_paths(self): + env = self._environment("/repo/m", "/project/out", "/project") + + self.assertEqual("out/modm/link", env.relcwdoutpath("modm/link")) + self.assertEqual("modm/src", env.reloutpath("modm/src")) + self.assertEqual("../ext/nanopb", env.reloutpath("ext/nanopb", "modm")) + + env.log_file("/repo/m/in", "/project/out/modm/src/out", 0) + self.assertEqual(["modm/src/out"], env.generated_local_files()) + + def test_should_return_relative_paths_with_forward_slashes(self): + # Use the Windows path functions on every host + names = ["join", "relpath", "dirname", "normpath", "basename", "isabs", "abspath"] + with mock.patch.multiple("lbuild.buildlog", **{n: getattr(ntpath, n) for n in names}), \ + mock.patch("os.path", ntpath), mock.patch("os.sep", "\\"): + env = self._environment("C:\\repo\\m", "C:\\project\\out", "C:\\project") + + self.assertEqual("out/modm/link", env.relcwdoutpath("modm\\link")) + self.assertEqual("../build", env.relcwdoutpath("..\\..\\build")) + self.assertEqual("modm/src", env.reloutpath("modm/src")) + self.assertEqual("../ext/nanopb", env.reloutpath("ext\\nanopb", "modm")) + self.assertEqual("../../build", env.reloutpath("..\\..\\build")) + + env.log_file("C:\\repo\\m\\in", "C:\\project\\out\\modm\\src\\out", 0) + self.assertEqual(["modm/src/out"], env.generated_local_files()) + + # Absolute paths remain native for file system access + self.assertEqual("C:\\project\\out\\modm\\src", env.outpath("modm", "src")) + self.assertEqual("C:\\project\\tools", env.cwdpath("tools")) + self.assertEqual("C:\\repo\\m\\file", env.modulepath("file")) + + +if __name__ == '__main__': + unittest.main() From a39a60cb79f206aed136a56cb9b5d9041f4b04ab Mon Sep 17 00:00:00 2001 From: Niklas Hauser Date: Mon, 12 Oct 2026 11:42:18 +0200 Subject: [PATCH 2/4] Return relative PathOption values with forward slashes --- lbuild/option.py | 4 ++++ test/option_test.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/lbuild/option.py b/lbuild/option.py index fa8f64d..73c3b70 100644 --- a/lbuild/option.py +++ b/lbuild/option.py @@ -18,6 +18,7 @@ from .node import BaseNode from .format import ColorWrapper as _cw +from .utils import posixpath import lbuild.exception as le LOGGER = logging.getLogger('lbuild.option') @@ -132,6 +133,9 @@ def _relocate_path(self, path): # relocate path relative to the option filepath to absolute if self._relocate(path): path = os.path.abspath(os.path.join(self._filepath, path)) + elif not os.path.isabs(path): + # relative paths are written into generated files + path = posixpath(path) return path def _validate_path(self, path): diff --git a/test/option_test.py b/test/option_test.py index f751a92..2b1c726 100644 --- a/test/option_test.py +++ b/test/option_test.py @@ -12,7 +12,9 @@ import os import sys import enum +import ntpath import unittest +from unittest import mock # Hack to support the usage of `coverage` sys.path.append(os.path.abspath(".")) @@ -184,6 +186,18 @@ def validate_path(path): option.value = "/absolute/filename.txt" self.assertEqual("/absolute/filename.txt", option.value) + def test_should_return_relative_path_with_forward_slashes(self): + option = PathOption("test", "description", default="build") + # Use the Windows separator on every host + with mock.patch("os.path.isabs", ntpath.isabs), mock.patch("os.sep", "\\"): + option.value = "..\\..\\build" + self.assertEqual("../../build", option.value) + option.value = "path\\folder/file.txt" + self.assertEqual("path/folder/file.txt", option.value) + # Absolute paths remain native for file system access + option.value = "C:\\path\\folder" + self.assertEqual("C:\\path\\folder", option.value) + def test_should_be_constructable_from_boolean(self): option = BooleanOption("test", "description", False, transform=lambda v: True if v == "world" else v) From 9b125689fe690abe36313d490da395ad19900ca9 Mon Sep 17 00:00:00 2001 From: Niklas Hauser Date: Mon, 12 Oct 2026 11:42:31 +0200 Subject: [PATCH 3/4] Write build log paths with forward slashes on all hosts Makes the build log independent of the host. Build logs with native paths written by previous versions are still read correctly. --- lbuild/buildlog.py | 6 +++--- test/buildlog_test.py | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/lbuild/buildlog.py b/lbuild/buildlog.py index 94f60fe..a5f89d8 100644 --- a/lbuild/buildlog.py +++ b/lbuild/buildlog.py @@ -243,16 +243,16 @@ def to_xml(self, path, to_string=True): with self.__lock: outpathnode = lxml.etree.SubElement(rootnode, "outpath") - outpathnode.text = relpath(self.outpath, path) + outpathnode.text = posixpath(relpath(self.outpath, path)) for operation in self.operations: operationnode = lxml.etree.SubElement(rootnode, "operation") modulenode = lxml.etree.SubElement(operationnode, "module") modulenode.text = operation.module_name srcnode = lxml.etree.SubElement(operationnode, "source") - srcnode.text = relpath(operation.filename_in, path) + srcnode.text = posixpath(relpath(operation.filename_in, path)) destnode = lxml.etree.SubElement(operationnode, "destination") - destnode.text = relpath(operation.filename_out, path) + destnode.text = posixpath(relpath(operation.filename_out, path)) if operation.time is not None: timenode = lxml.etree.SubElement(operationnode, "time") diff --git a/test/buildlog_test.py b/test/buildlog_test.py index a5a29ed..bf4a0c3 100644 --- a/test/buildlog_test.py +++ b/test/buildlog_test.py @@ -119,6 +119,28 @@ def test_should_create_local_path_with_forward_slashes(self): self.assertEqual("generated/nanopb/out1", lbuild.facade.BuildLogOperationFacade(o1).filename) + def test_should_read_xml_with_native_and_forward_slashes(self): + # Use the Windows path functions on every host + names = ["join", "relpath", "dirname", "normpath", "basename", "isabs", "abspath"] + with mock.patch.multiple("lbuild.buildlog", **{n: getattr(ntpath, n) for n in names}), \ + mock.patch("os.path", ntpath), mock.patch("os.sep", "\\"): + log = lbuild.buildlog.BuildLog("C:\\project", cwd="C:\\project") + self.module1._filename = "C:\\repo\\m1\\module.lb" + log.log(self.module1, "sub\\in1", "generated\\out1") + + xml = log.to_xml(path="C:\\project") + self.assertIn(b".", xml) + self.assertIn(b"../repo/m1/sub/in1", xml) + self.assertIn(b"generated/out1", xml) + + # Build logs written by previous versions contain native paths + for content in [xml, xml.replace(b"/", b"\\").replace(b"<\\", b" Date: Mon, 12 Oct 2026 12:30:52 +0200 Subject: [PATCH 4/4] Increase version --- lbuild/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lbuild/main.py b/lbuild/main.py index cf25e00..c819707 100644 --- a/lbuild/main.py +++ b/lbuild/main.py @@ -22,7 +22,7 @@ from lbuild.api import Builder -__version__ = '1.22.0' +__version__ = '1.23.0' class InitAction: