diff --git a/ldclient/integrations/overrides.py b/ldclient/integrations/overrides.py index e0bfe586..4d70e984 100644 --- a/ldclient/integrations/overrides.py +++ b/ldclient/integrations/overrides.py @@ -29,7 +29,7 @@ import math import os import threading -from typing import List, Optional, Union +from typing import List, Optional, Sequence, Union from ldclient.config import DataSourceBuilderConfig, OverrideSourceBuilder from ldclient.impl.integrations.files.filedata import ( @@ -103,14 +103,29 @@ class FileOverrideSourceBuilder(OverrideSourceBuilder): system. """ - def __init__(self, paths: Union[str, List[str]]): + def __init__(self, paths: Union[str, 'os.PathLike[str]', Sequence[Union[str, 'os.PathLike[str]']]]): """ :param paths: the files to load overrides from, as a single path or a list of paths. - The order is significant: it determines which file wins under the duplicate keys - handling when the same key appears in more than one file. Relative paths are - resolved against the current working directory when the source is built. + Each path is a string or a path object such as :class:`pathlib.Path`. The order is + significant: it determines which file wins under the duplicate keys handling when the + same key appears in more than one file. Relative paths are resolved against the + current working directory when the source is built. + :raises TypeError: when a path is neither a string nor a path object that names a string + path """ - self.__paths: List[str] = [paths] if isinstance(paths, str) else list(paths) + # bytes and bytearray are single values too, so that they get the error below instead of + # being read as a sequence of integers. + if isinstance(paths, (str, bytes, bytearray, os.PathLike)): + paths = [paths] + self.__paths: List[str] = [] + for path in paths: + try: + name: object = os.fspath(path) + except TypeError: + name = None + if not isinstance(name, str): + raise TypeError("file paths for the file-based override source must be strings or path objects; got %r" % (path,)) + self.__paths.append(name) self.__duplicate_keys_handling = DuplicateKeysHandling.FAIL self.__change_detection = ChangeDetection.POLLING self.__poll_interval = self.DEFAULT_POLL_INTERVAL diff --git a/ldclient/testing/integrations/test_file_override_source.py b/ldclient/testing/integrations/test_file_override_source.py index abfff9b8..4ae4f4f8 100644 --- a/ldclient/testing/integrations/test_file_override_source.py +++ b/ldclient/testing/integrations/test_file_override_source.py @@ -122,6 +122,66 @@ def test_builder_accepts_a_single_path_string(tmp_path): assert source._paths == [path] +def test_builder_accepts_a_single_path_object(tmp_path): + path = tmp_path / 'overrides.json' + source = FileOverrideSourceBuilder(path).build(SDKConfig('SDK_KEY')) + assert isinstance(source, _FileOverrideSource) + assert source._paths == [str(path)] + + +def test_builder_accepts_a_list_of_path_objects(tmp_path): + paths = [tmp_path / 'a.json', tmp_path / 'b.json'] + source = FileOverrideSourceBuilder(paths).build(SDKConfig('SDK_KEY')) + assert isinstance(source, _FileOverrideSource) + assert source._paths == [str(path) for path in paths] + + +class LabeledPath: + """A path object whose string form is not its path, as with :class:`os.DirEntry`.""" + + def __init__(self, path): + self._path = path + + def __fspath__(self): + return self._path + + def __str__(self): + return '' + + +@pytest.mark.parametrize('as_list', [False, True], ids=['single', 'list']) +def test_builder_uses_the_file_system_path_of_a_path_object(tmp_path, as_list): + path = os.path.join(str(tmp_path), 'overrides.json') + paths = [LabeledPath(path)] if as_list else LabeledPath(path) + source = FileOverrideSourceBuilder(paths).build(SDKConfig('SDK_KEY')) + assert isinstance(source, _FileOverrideSource) + assert source._paths == [path] + + +@pytest.mark.parametrize( + 'paths', + [ + b'/overrides.json', + bytearray(b'/overrides.json'), + [b'/overrides.json'], + LabeledPath(b'/overrides.json'), + [LabeledPath(b'/overrides.json')], + [42], + [None], + ], + ids=['bytes', 'bytearray', 'bytes in a list', 'path object naming bytes', 'path object naming bytes in a list', 'int', 'none'], +) +def test_builder_rejects_a_path_that_is_not_a_string_or_path_object(paths): + with pytest.raises(TypeError, match='file paths for the file-based override source must be strings or path objects'): + FileOverrideSourceBuilder(paths) + + +def test_builder_names_a_rejected_bytes_path_whole(): + with pytest.raises(TypeError) as excinfo: + FileOverrideSourceBuilder(b'/overrides.json') + assert "b'/overrides.json'" in str(excinfo.value) + + def test_builder_resolves_relative_paths(): source = FileOverrideSourceBuilder(['relative/overrides.json']).build(SDKConfig('SDK_KEY')) assert isinstance(source, _FileOverrideSource) @@ -256,6 +316,13 @@ def test_source_loads_initial_data_synchronously(tmp_path, sources): assert snapshot[0]['flag1'].is_override is False, "the source supplies unmarked definitions; the SDK marks them" +def test_source_loads_a_file_given_as_a_path_object(tmp_path, sources): + path = tmp_path / 'overrides.json' + write_file(str(path), '{"flagValues": {"flag1": true}}') + _, sink = build_source(sources, path) + assert flag_values(sink.require_snapshot()) == {'flag1': True} + + @yaml_required def test_source_loads_yaml(tmp_path, sources): path = os.path.join(str(tmp_path), 'overrides.yaml')