Repository: OpenAssetIO/OpenAssetIO (language: python)

Issue:
OpenAssetIO string substitution utility
https://github.com/OpenAssetIO/OpenAssetIO-MediaCreation/pull/71#discussion_r1406479090

## What
Expose a utility API method to perform string substitution on any string in the "OpenAssetIO format".
Document the "OpenAssetIO format" as mirroring the libfmt format

## Why
Hosts receive templated URL from managers, such as `file:///some/shot-{frame%3A04d}`, (percent encoded colon), and it's important that managers dont have to branch and produce different formats depending on the specific host they're trying to communicate with.

LocatableContent has support for template substitutions, like `fmt`, with the curly brackets.
The normal ways you format this is via std::format or python fstrings, however these are not known to be fully compatible, so we've ended up saying the meaningless "OpenAssetIO Format". **We need to define what this means, or produce a method that allows both languages to do the substitutions in the same way (by just forwarding to libfmt, which we use internally)**

## Acceptance criteria
- Follow design of notebook in https://github.com/OpenAssetIO/OpenAssetIO-MediaCreation/issues/70
Support partial substitution in `substitute`
## What

* Allow partial substitution in `utils.substitute` without throwing an exception, e.g. such that we can do `substitute("image.{frame}.{view}.exr", {"view": "right"}) == "image.{frame}.right.exr"`.
* Support for providing a default value, e.g. `{variable:format-default}` - TSC feedback
* Enhance syntax to support optional substrings e.g. `/mnt</lhs{show}/rhs>/file`, where `/lhs` and `/rhs` won't print if `{show}` is not provided - TSC  feedback.

## Why

We suspect that partial, or gradual, substitution will be useful in OpenAssetIO, especially in the context of serialisation and multi-component systems. However, the current implementation relies on libfmt, which [does not support this](https://github.com/fmtlib/fmt/issues/1048) in its public interface.

If a substitution variable is not provided, the source of the string (host/manager) may wish to provide a good default.

To make strings more re-usable e.g. across different but related file system layouts, it's useful to have a mechanism to make substrings around a token only printed when the token is substituted.

<!---
## Acceptance Criteria

An exhaustive list of falsifiable outcomes that define fully whether
this task is complete.

Remember to consider:

- Release note updates.
- Documentation updates.
- Additional tests or test updates.
- Impact on dependent repos.
--->



Discussion / hints:

Fix patch:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 5cee2a4d8..7ad8ba3b8 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -27,6 +27,10 @@ v1.0.0-beta.x.y
   logger.
   [#1014](https://github.com/OpenAssetIO/OpenAssetIO/issues/1014)
 
+- Added `utils.substitute` providing a common string substitution format
+  using named placeholders in libfmt/Python format string style syntax.
+  [#1213](https://github.com/OpenAssetIO/OpenAssetIO/issues/1213)
+
 ### Improvements
 
 - Added `getRelationship(s)` overloads for convenience, providing
diff --git a/src/openassetio-core/CMakeLists.txt b/src/openassetio-core/CMakeLists.txt
index 86ca706f6..ec23ffaa0 100644
--- a/src/openassetio-core/CMakeLists.txt
+++ b/src/openassetio-core/CMakeLists.txt
@@ -93,6 +93,7 @@ target_sources(
     src/utils/path/windows/pathTypes.cpp
     src/utils/path/posix.cpp
     src/utils/path/posix/detail.cpp
+    src/utils/substitute.cpp
 )
 
 # Public header dependency.
diff --git a/src/openassetio-core/include/openassetio/utils/substitute.hpp b/src/openassetio-core/include/openassetio/utils/substitute.hpp
new file mode 100644
index 000000000..7113fc357
--- /dev/null
+++ b/src/openassetio-core/include/openassetio/utils/substitute.hpp
@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2023 The Foundry Visionmongers Ltd
+#pragma once
+#include <string_view>
+
+#include <openassetio/export.h>
+
+#include <openassetio/InfoDictionary.hpp>
+#include <openassetio/typedefs.hpp>
+
+namespace openassetio {
+inline namespace OPENASSETIO_CORE_ABI_VERSION {
+namespace utils {
+/**
+ * Substitute placeholders in a given string using the provided
+ * dictionary mapping of tokens to values.
+ *
+ * The input string can contain placeholders in the form of `{key}`
+ * where `key` is a key in the provided dictionary. The placeholder will
+ * be replaced by the corresponding value from the dictionary.
+ *
+ * All placeholders must be valid keys in the dictionary. If a
+ * placeholder is not found in the dictionary, an exception will be
+ * thrown.
+ *
+ * Integers can be zero-padded in the format string. For example,
+ * `{key:03d}` will replace the placeholder with the integer value of
+ * `key` from the dictionary, padded with zeros to a width of 3 digits.
+ * The format specifier follows `libfmt` / Python format string syntax.
+ *
+ * Note that no format specifiers other than zero-padding are officially
+ * supported, though other specifiers may work. This is to keep the
+ * interop surface area as small as possible, e.g. to ease
+ * cross-language support.
+ *
+ * @param input The string in which substitutions are to be made.
+ *
+ * @param substitutions The dictionary containing the keys to be
+ * replaced and their corresponding values.
+ *
+ * @return The input string with all valid substitutions made.
+ *
+ * @throws errors.InputValidationException if a substitution variable is
+ * not found in the dictionary.
+ */
+OPENASSETIO_CORE_EXPORT openassetio::Str substitute(
+    std::string_view input, const openassetio::InfoDictionary& substitutions);
+}  // namespace utils
+}  // namespace OPENASSETIO_CORE_ABI_VERSION
+}  // namespace openassetio
diff --git a/src/openassetio-core/src/utils/substitute.cpp b/src/openassetio-core/src/utils/substitute.cpp
new file mode 100644
index 000000000..324e801a2
--- /dev/null
+++ b/src/openassetio-core/src/utils/substitute.cpp
@@ -0,0 +1,45 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2023 The Foundry Visionmongers Ltd
+#include <fmt/args.h>
+#include <fmt/format.h>
+
+#include <openassetio/errors/exceptions.hpp>
+#include <openassetio/utils/substitute.hpp>
+
+namespace openassetio {
+inline namespace OPENASSETIO_CORE_ABI_VERSION {
+namespace utils {
+
+openassetio::Str substitute(const std::string_view input,
+                            const openassetio::InfoDictionary& substitutions) {
+  fmt::dynamic_format_arg_store<fmt::format_context> args;
+  args.reserve(substitutions.size(), substitutions.size());
+
+  for (const auto& substitution : substitutions) {
+    // Note: lambda capture of structured binding is not supported in
+    // apple-clang on MacOS 11, so we must capture the `pair` and then
+    // destructure it within the lambda.
+    std::visit(
+        [&args, &substitution](const auto& value) {
+          const auto& [key, variantValue] = substitution;
+          using T = std::decay_t<decltype(value)>;
+          if constexpr (std::is_same_v<T, openassetio::Str>) {
+            args.push_back(fmt::arg(key.c_str(), std::string_view{value}));
+          } else {
+            args.push_back(fmt::arg(key.c_str(), value));
+          }
+        },
+        substitution.second);
+  }
+
+  try {
+    return fmt::vformat(input, args);
+  } catch (const fmt::format_error& exc) {
+    throw openassetio::errors::InputValidationException{fmt::format(
+        "substitute(): failed to process the input string '{}': {}", input, exc.what())};
+  }
+}
+
+}  // namespace utils
+}  // namespace OPENASSETIO_CORE_ABI_VERSION
+}  // namespace openassetio
diff --git a/src/openassetio-python/cmodule/src/utilsBinding.cpp b/src/openassetio-python/cmodule/src/utilsBinding.cpp
index 49f1b38e2..bac641b55 100644
--- a/src/openassetio-python/cmodule/src/utilsBinding.cpp
+++ b/src/openassetio-python/cmodule/src/utilsBinding.cpp
@@ -1,8 +1,10 @@
 // SPDX-License-Identifier: Apache-2.0
 // Copyright 2023 The Foundry Visionmongers Ltd
 #include <pybind11/pybind11.h>
+#include <pybind11/stl.h>
 
 #include <openassetio/utils/path.hpp>
+#include <openassetio/utils/substitute.hpp>
 
 #include "_openassetio.hpp"
 
@@ -20,4 +22,6 @@ void registerUtils(py::module_ &mod) {
            py::arg("pathType") = utils::PathType::kSystem)
       .def("pathFromUrl", &utils::FileUrlPathConverter::pathFromUrl, py::arg("fileUrl"),
            py::arg("pathType") = utils::PathType::kSystem);
+
+  mod.def("substitute", &utils::substitute, py::arg("input"), py::arg("substitutions"));
 }
diff --git a/src/openassetio-python/package/openassetio/utils.py b/src/openassetio-python/package/openassetio/utils.py
index 3aa232553..d6b5a560d 100644
--- a/src/openassetio-python/package/openassetio/utils.py
+++ b/src/openassetio-python/package/openassetio/utils.py
@@ -24,3 +24,5 @@
 PathType = _openassetio.utils.PathType
 
 FileUrlPathConverter = _openassetio.utils.FileUrlPathConverter
+
+substitute = _openassetio.utils.substitute


Test patch:
diff --git a/src/openassetio-python/tests/package/utils/test_utils.py b/src/openassetio-python/tests/package/utils/test_FileUrlPathConverter.py
similarity index 99%
rename from src/openassetio-python/tests/package/utils/test_utils.py
rename to src/openassetio-python/tests/package/utils/test_FileUrlPathConverter.py
index ea8098823..c61b558fa 100644
--- a/src/openassetio-python/tests/package/utils/test_utils.py
+++ b/src/openassetio-python/tests/package/utils/test_FileUrlPathConverter.py
@@ -14,7 +14,7 @@
 #   limitations under the License.
 #
 """
-Tests that cover the openassetio.utils namespace.
+Tests that cover the FileUrlPathConverter utility.
 """
 import collections
 import json
diff --git a/src/openassetio-python/tests/package/utils/test_substitute.py b/src/openassetio-python/tests/package/utils/test_substitute.py
new file mode 100644
index 000000000..786ba608c
--- /dev/null
+++ b/src/openassetio-python/tests/package/utils/test_substitute.py
@@ -0,0 +1,73 @@
+#
+#   Copyright 2023 The Foundry Visionmongers Ltd
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+"""
+Tests that cover the string token substitute utility.
+"""
+# pylint: disable=missing-function-docstring,missing-class-docstring,
+# pylint: disable=invalid-name
+import re
+
+import pytest
+
+from openassetio import utils, errors
+
+
+class Test_substitute:
+    def test_when_no_substitutions_then_returns_same_string(self):
+        assert utils.substitute("hello", {}) == "hello"
+
+    def test_when_substitutions_available_then_returns_substituted_string(self):
+        assert utils.substitute("hello {name}", {"name": "world"}) == "hello world"
+
+    def test_when_missing_substitution_variable_then_raises_InputValidationException(self):
+        expected_error = re.escape(
+            "substitute(): failed to process the input string 'hello {name}': argument not found"
+        )
+
+        with pytest.raises(errors.InputValidationException, match=expected_error):
+            utils.substitute("hello {name}", {})
+
+    def test_when_extra_substitution_variable_then_extra_is_ignored(self):
+        assert (
+            utils.substitute("hello {name}", {"name": "world", "extra": "ignored"})
+            == "hello world"
+        )
+
+    def test_when_allowed_value_types_then_substitutes_correctly(self):
+        assert utils.substitute("hello {name}", {"name": "world"}) == "hello world"
+        assert utils.substitute("hello {name}", {"name": 123}) == "hello 123"
+        assert utils.substitute("hello {name}", {"name": 1.23}) == "hello 1.23"
+        assert utils.substitute("hello {name}", {"name": True}) == "hello true"
+        assert utils.substitute("hello {name}", {"name": False}) == "hello false"
+
+    def test_when_unknown_value_types_then_raises_TypeError(self):
+        expected_error = re.escape(
+            "substitute(): incompatible function arguments."
+            " The following argument types are supported"
+        )
+
+        with pytest.raises(TypeError, match=expected_error):
+            utils.substitute("hello {name}", {"name": object()})
+
+        with pytest.raises(TypeError, match=expected_error):
+            utils.substitute("hello {name}", {"name": {"value": "world"}})
+
+    def test_when_integer_format_specifier_then_substitutes_correctly(self):
+        # We only officially support integer padding. Many other formats
+        # are technically possible.
+        assert utils.substitute("hello {name:04d}", {"name": 1}) == "hello 0001"
+        assert utils.substitute("hello {name:04d}", {"name": 123}) == "hello 0123"
+        assert utils.substitute("hello {name:04d}", {"name": 12345}) == "hello 12345"