Skip to content
Merged
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
27 changes: 21 additions & 6 deletions ldclient/integrations/overrides.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions ldclient/testing/integrations/test_file_override_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<LabeledPath>'


@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)
Expand Down Expand Up @@ -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')
Expand Down
Loading