diff --git a/lbuild/buildlog.py b/lbuild/buildlog.py
index 2b305d7..a5f89d8 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)
@@ -242,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/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/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:
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/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..bf4a0c3 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,45 @@ 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)
+
+ 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"")]:
+ log = lbuild.buildlog.BuildLog.from_xml(content, path="C:\\project")
+ self.assertEqual(["generated/out1"],
+ [o.local_filename_out() for o in log.operations])
+ self.assertEqual(["C:\\project\\generated\\out1"],
+ [o.filename_out for o in log.operations])
+
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()
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)