From 8940f18c09a493df3537968550b42bf3bbbcebf5 Mon Sep 17 00:00:00 2001 From: Siva Sai <26503640+ysskrishna@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:33:28 +0530 Subject: [PATCH 01/17] Siva | cleanup package --- nestedutils/access.py | 279 ++++++++++++++++++++++++----------- nestedutils/enums.py | 34 ++--- nestedutils/exceptions.py | 13 +- nestedutils/helpers.py | 135 +++++++++-------- tests/test_delete.py | 2 +- tests/test_edge_cases.py | 57 ++++--- tests/test_exists.py | 20 +-- tests/test_get.py | 115 +++++++++------ tests/test_normalize_path.py | 6 +- tests/test_set.py | 195 +++++++++++------------- 10 files changed, 486 insertions(+), 370 deletions(-) diff --git a/nestedutils/access.py b/nestedutils/access.py index 5d65645..07feb18 100644 --- a/nestedutils/access.py +++ b/nestedutils/access.py @@ -1,36 +1,48 @@ -from typing import Any, List, Literal, Union +from typing import Any, List, Union from .exceptions import PathError -from .enums import PathErrorCode, FillStrategy -from .helpers import normalize_path, navigate, create_container, fill_list_gaps, ensure_container_at_index -from .helpers import is_int_key, resolve_write_index +from .enums import PathErrorCode +from .helpers import normalize_path, navigate, is_int_key, resolve_write_index, resolve_read_index +from .helpers import create_intermediate_container -def get_at(data: Any, path: Union[str, List[Any]], default: Any = None) -> Any: +_MISSING = object() + + +def get_at(data: Any, path: Union[str, List[Any]], *, default: Any = _MISSING) -> Any: """Retrieve a value from a nested data structure. Navigates through nested dictionaries, lists, and tuples using a path specified as either - a dot-notation string or a list of keys/indices. Returns ``default`` if the path does not - exist or an index is out of bounds (positive or negative). Supports negative indexing for - lists and tuples. + a dot-notation string or a list of keys/indices. By default, raises PathError if the path + does not exist. Supports negative indexing for lists and tuples. + + This function raises PathError for missing paths by default. Use the `default` parameter + to return a value instead of raising. Args: data: The data structure to navigate (dict, list, tuple, or nested combinations). path: Path to the value. Accepts either a dot-separated string (e.g., "a.b.0.name") or a list of keys/indices (e.g., ["a", "b", 0, "name"]). Indices may be integers or strings representing integers (including negative indices). - default: Value to return if the path does not exist (default: None). + default: Value to return if the path does not exist. If not provided, raises PathError + for missing paths. Returns: - The value at the specified path, or ``default`` if the path does not exist. + The value at the specified path, or ``default`` if the path does not exist and + ``default`` is provided. Raises: - PathError: If the path is malformed, empty, or contains empty keys. + PathError: If the path is malformed, empty, contains empty keys, or path doesn't exist + (when default is not provided). Examples: ```python data = {"a": {"b": {"c": 5}}} get_at(data, "a.b.c") # Returns: 5 + # Missing paths raise by default + get_at(data, "a.b.d") # Raises: PathError + + # Explicit default for optional values get_at(data, "a.b.d", default=99) # Returns: 99 data = {"items": [{"name": "apple"}, {"name": "banana"}]} @@ -46,12 +58,45 @@ def get_at(data: Any, path: Union[str, List[Any]], default: Any = None) -> Any: """ keys = normalize_path(path) current = data - MISSING = object() for key in keys: - current = navigate(current, key, MISSING) - if current is MISSING: - return default + if isinstance(current, dict): + if key not in current: + if default is not _MISSING: + return default + raise PathError( + f"Key '{key}' not found in path", + PathErrorCode.MISSING_KEY + ) + current = current[key] + + elif isinstance(current, (list, tuple)): + if not is_int_key(key): + if default is not _MISSING: + return default + raise PathError( + f"Expected numeric index, got '{key}'", + PathErrorCode.INVALID_INDEX + ) + + idx = resolve_read_index(current, key) + if idx is None: + if default is not _MISSING: + return default + raise PathError( + f"Index '{key}' out of bounds in path", + PathErrorCode.INVALID_INDEX + ) + + current = current[idx] + + else: + if default is not _MISSING: + return default + raise PathError( + f"Cannot navigate into {type(current).__name__} at '{key}'", + PathErrorCode.INVALID_PATH + ) return current @@ -60,8 +105,11 @@ def exists_at(data: Any, path: Union[str, List[Any]]) -> bool: """Check if a path exists in a nested data structure. Navigates through nested dictionaries, lists, and tuples. Returns True if the full path - exists, False otherwise (including for any out-of-bounds index, positive or negative). - Supports negative indexing for lists and tuples. + exists and is accessible, False otherwise (including out-of-bounds indices). Supports + negative indexing for lists and tuples. + + This function never raises PathError for missing paths - it returns False instead. + PathError is only raised for malformed paths. Args: data: The data structure to navigate (dict, list, tuple, or nested combinations). @@ -69,10 +117,10 @@ def exists_at(data: Any, path: Union[str, List[Any]]) -> bool: list of keys/indices (e.g., ["a", "b", 0, "name"]). Returns: - True if the path exists, False otherwise. + True if the path exists and is accessible, False otherwise. Raises: - PathError: If the path is malformed, empty, or contains empty keys. + PathError: Only if the path format is invalid (empty, malformed, exceeds max depth). Examples: ```python @@ -89,96 +137,106 @@ def exists_at(data: Any, path: Union[str, List[Any]]) -> bool: data = (10, 20, 30) exists_at(data, "2") # Returns: True exists_at(data, "5") # Returns: False + + # Even None values return True if path exists + data = {"a": {"b": None}} + exists_at(data, "a.b") # Returns: True + exists_at(data, "a.b.c") # Returns: False (can't navigate into None) ``` """ - keys = normalize_path(path) - current = data - MISSING = object() - - for key in keys: - current = navigate(current, key, MISSING) - if current is MISSING: + try: + get_at(data, path) # Uses strict mode internally + return True + except PathError as e: + # Return False for "not found" errors or navigation into non-navigable types + if e.code in (PathErrorCode.MISSING_KEY, PathErrorCode.INVALID_INDEX, PathErrorCode.INVALID_PATH): + # Check if it's a navigation error (trying to navigate into None, etc.) + # vs a path format error (empty path, wrong type, etc.) + if e.code == PathErrorCode.INVALID_PATH: + # If message indicates navigation into non-navigable type, return False + if "Cannot navigate into" in str(e.message): + return False + # Otherwise it's a path format error, re-raise + raise return False - - return True + # Re-raise for any other errors + raise def set_at( data: Any, path: Union[str, List[Any]], value: Any, - fill_strategy: Literal["auto", "none", "dict", "list"] = "auto" + *, + create: bool = False ) -> None: - """Set a value in a nested data structure, creating intermediate containers as needed. + """Set a value in a nested data structure. - Navigates to the specified path and sets the value, automatically creating missing - intermediate dictionaries or lists according to ``fill_strategy``. + Navigates to the specified path and sets the value. By default (create=False), + raises PathError if any intermediate key is missing. With create=True, automatically + creates missing intermediate containers (dicts for string keys, lists for numeric keys). List indexing rules: - - Non-negative indices can extend the list, filling gaps as needed. - - Negative indices can only modify existing elements (no extension allowed). - - Out-of-bounds negative indices raise PathError. + - Positive indices can append (index == len(list)) but NOT create gaps (index > len(list)) + - Negative indices can only modify existing elements + - Out-of-bounds negative indices raise PathError + - Index cannot exceed MAX_LIST_SIZE (10000) Args: - data: The mutable data structure to modify (dict or list). Intermediate containers are - created automatically, but the root must be mutable. + data: The mutable data structure to modify (dict or list). The root container + must already exist and be mutable. path: Path where to set the value. Accepts either a dot-separated string (e.g., "a.b.0.name") or a list of keys/indices (e.g., ["a", "b", 0, "name"]). value: The value to set at the specified path. - fill_strategy: Controls how missing intermediate containers are created. Must be one of: - - 'auto' (default): creates {} for dict keys, [] for list indices, and None for sparse - list gaps. - - 'none': fills sparse list gaps with None. - - 'dict': always creates dictionaries {} for missing containers. - - 'list': always creates lists [] for missing containers. + create: If True, automatically create missing intermediate containers. + If False (default), raise PathError if path doesn't exist. Returns: None. The function modifies ``data`` in place. Raises: - PathError: If the path is malformed, empty, attempts to modify a tuple, uses an invalid - fill_strategy, or uses an out-of-bounds negative index. - - Note: - When extending lists with gaps (e.g., setting index 5 on a list of length 2), - intermediate positions are filled based on ``fill_strategy`` ('auto' and 'none' use None; - 'dict' uses {}; 'list' uses []). + PathError: If path is malformed, path doesn't exist (when create=False), + attempts to modify tuple, uses out-of-bounds negative index, or would + create sparse list. Examples: ```python - data = {} - set_at(data, "user.profile.name", "Alice") + # create=False (default) - path must exist + data = {"user": {"profile": {}}} + set_at(data, "user.profile.name", "Alice") # OK - path exists # data is now: {'user': {'profile': {'name': 'Alice'}}} data = {} - set_at(data, "items.0.name", "Item 1") - # data is now: {'items': [{'name': 'Item 1'}]} + set_at(data, "user.name", "Bob") # PathError - path doesn't exist + # create=True - auto-create missing parts data = {} - set_at(data, "items.5", "last", fill_strategy="none") - # data is now: {'items': [None, None, None, None, None, 'last']} + set_at(data, "user.profile.name", "Alice", create=True) + # data is now: {'user': {'profile': {'name': 'Alice'}}} + # List operations - sequential only (no gaps) data = {} - set_at(data, "items.2.sub.value", 42) # 'auto' creates dict at target index, None for gaps - # data is now: {'items': [None, None, {'sub': {'value': 42}}]} + set_at(data, "items.0", "first", create=True) # OK - creates list + # data is now: {'items': ['first']} + + set_at(data, "items.1", "second", create=True) # OK - appends + # data is now: {'items': ['first', 'second']} - data = [1, 2, 3] - set_at(data, "5", 99) # extends with None gaps - # data is now: [1, 2, 3, None, None, 99] + set_at(data, "items.5", "x", create=True) # PathError - would create gap - set_at(data, "-1", 100) # modifies existing last element - # data is now: [1, 2, 3, None, None, 100] + # Negative indices - modify existing only + data = {"items": [1, 2, 3]} + set_at(data, "items.-1", 99) # OK - modifies last element + # data is now: {'items': [1, 2, 99]} + + set_at(data, "items.-5", 0) # PathError - out of bounds + + # Modifying existing nested structures + data = {"a": [{"x": 1}]} + set_at(data, "a.0.y", 2, create=True) # Adds key to existing dict + # data is now: {'a': [{'x': 1, 'y': 2}]} ``` """ - try: - strategy = FillStrategy(fill_strategy) - except ValueError: - valid = {s.value for s in FillStrategy} - raise PathError( - f"Invalid fill_strategy: {fill_strategy}. Valid: {valid}", - PathErrorCode.INVALID_FILL_STRATEGY - ) - keys = normalize_path(path) current = data @@ -187,25 +245,54 @@ def set_at( next_key = keys[i + 1] if isinstance(current, dict): - if key not in current or current[key] is None: - current[key] = create_container(strategy, next_key) + if key not in current: + if not create: + raise PathError( + f"Key '{key}' does not exist. Use create=True to auto-create path.", + PathErrorCode.MISSING_KEY + ) + # Create intermediate container based on next key type + current[key] = create_intermediate_container(next_key) + elif current[key] is None: + if not create: + raise PathError( + f"Key '{key}' is None. Use create=True to replace with container.", + PathErrorCode.MISSING_KEY + ) + current[key] = create_intermediate_container(next_key) + current = current[key] elif isinstance(current, list): if not is_int_key(key): raise PathError( - f"Expected numeric index, got '{key}'", + f"Expected numeric index for list, got '{key}'", PathErrorCode.INVALID_INDEX ) - idx = resolve_write_index(current, key, allow_extension=True) - fill_list_gaps(current, idx, strategy) - ensure_container_at_index(current, idx, strategy, next_key) + idx = resolve_write_index(current, key) + + # Extend list if needed (only for append, not gaps) + if idx == len(current): + if not create: + raise PathError( + f"Index {idx} does not exist. Use create=True to append.", + PathErrorCode.INVALID_INDEX + ) + current.append(create_intermediate_container(next_key)) + elif current[idx] is None: + if not create: + raise PathError( + f"Index {idx} is None. Use create=True to replace with container.", + PathErrorCode.MISSING_KEY + ) + current[idx] = create_intermediate_container(next_key) + current = current[idx] elif isinstance(current, tuple): raise PathError( - "Cannot modify tuple (immutable)", + "Cannot modify tuple (immutable container)", PathErrorCode.IMMUTABLE_CONTAINER ) @@ -224,20 +311,26 @@ def set_at( elif isinstance(current, list): if not is_int_key(final_key): raise PathError( - f"Expected numeric index, got '{final_key}'", + f"Expected numeric index for list, got '{final_key}'", PathErrorCode.INVALID_INDEX ) - idx = resolve_write_index(current, final_key, allow_extension=True) - - while len(current) <= idx: - current.append(None) + idx = resolve_write_index(current, final_key) - current[idx] = value + # Extend list if appending + if idx == len(current): + if not create: + raise PathError( + f"Index {idx} does not exist. Use create=True to append.", + PathErrorCode.INVALID_INDEX + ) + current.append(value) + else: + current[idx] = value elif isinstance(current, tuple): raise PathError( - "Cannot modify tuple (immutable)", + "Cannot modify tuple (immutable container)", PathErrorCode.IMMUTABLE_CONTAINER ) @@ -312,7 +405,12 @@ def delete_at( PathErrorCode.INVALID_INDEX ) - idx = resolve_write_index(current, key, allow_extension=False) + idx = resolve_read_index(current, key) + if idx is None: + raise PathError( + f"Index '{key}' out of bounds", + PathErrorCode.INVALID_INDEX + ) current = current[idx] else: @@ -345,7 +443,12 @@ def delete_at( PathErrorCode.INVALID_INDEX ) - idx = resolve_write_index(current, final_key, allow_extension=False) + idx = resolve_read_index(current, final_key) + if idx is None: + raise PathError( + f"Index '{final_key}' out of bounds", + PathErrorCode.INVALID_INDEX + ) return current.pop(idx) elif isinstance(current, tuple): diff --git a/nestedutils/enums.py b/nestedutils/enums.py index 849dfa8..679c789 100644 --- a/nestedutils/enums.py +++ b/nestedutils/enums.py @@ -1,7 +1,7 @@ """Enumerations for nestedutils library. -This module defines error codes and configuration enums used throughout -the nestedutils library for consistent error handling and behavior control. +This module defines error codes used throughout the nestedutils library +for consistent error handling. """ from enum import Enum @@ -16,32 +16,24 @@ class PathErrorCode(Enum): data = {"a": {"b": 1}} try: - get_at(data, "a.c.d") + result = get_at(data, "a.c.d") + # get_at raises PathError for missing paths in v2.0 except PathError as e: if e.code == PathErrorCode.MISSING_KEY: print("Key not found") ``` """ INVALID_INDEX = "INVALID_INDEX" + """Raised when a list index is invalid (non-numeric, out of bounds, or would create sparse list).""" + MISSING_KEY = "MISSING_KEY" + """Raised when a required key doesn't exist (in set_at with create=False or delete_at).""" + EMPTY_PATH = "EMPTY_PATH" + """Raised when path is empty or contains empty keys.""" + IMMUTABLE_CONTAINER = "IMMUTABLE_CONTAINER" - INVALID_PATH = "INVALID_PATH" - INVALID_FILL_STRATEGY = "INVALID_FILL_STRATEGY" - - -class FillStrategy(Enum): - """Strategy for filling missing containers when setting nested paths. + """Raised when attempting to modify an immutable container (tuple).""" - Example: - ```python - from nestedutils import set_at, FillStrategy - - data = {} - set_at(data, "items.0.name", "apple", fill_strategy=FillStrategy.AUTO) - ``` - """ - AUTO = "auto" - NONE = "none" - DICT = "dict" - LIST = "list" \ No newline at end of file + INVALID_PATH = "INVALID_PATH" + """Raised when path format is invalid (wrong type, exceeds max depth, etc.).""" \ No newline at end of file diff --git a/nestedutils/exceptions.py b/nestedutils/exceptions.py index d177581..dc7058d 100644 --- a/nestedutils/exceptions.py +++ b/nestedutils/exceptions.py @@ -18,14 +18,21 @@ class PathError(Exception): Example: ```python - from nestedutils import get_at, PathError, PathErrorCode + from nestedutils import set_at, PathError, PathErrorCode data = {"a": {"b": 1}} + + # set_at raises when path doesn't exist (create=False is default) try: - get_at(data, "a.c.d") + set_at(data, "a.c.d", "value") except PathError as e: if e.code == PathErrorCode.MISSING_KEY: - print("Key not found") + print(f"Path doesn't exist: {e.message}") + + # get_at raises for missing paths in v2.0 - use default= for optional values + from nestedutils import get_at + result = get_at(data, "a.c.d", default="not found") + print(result) # "not found" ``` """ diff --git a/nestedutils/helpers.py b/nestedutils/helpers.py index c881216..7a43b98 100644 --- a/nestedutils/helpers.py +++ b/nestedutils/helpers.py @@ -1,6 +1,6 @@ from typing import Any, List, Union, Optional from .exceptions import PathError -from .enums import PathErrorCode, FillStrategy +from .enums import PathErrorCode from .constants import MAX_DEPTH, MAX_LIST_SIZE @@ -16,14 +16,15 @@ def normalize_path(path: Union[str, List[Any]]) -> List[str]: path: Either a dot-notation string (e.g., "a.b.c") or a list of keys. Returns: - List of string keys representing the path. + List of string keys representing the path. All elements are guaranteed to be strings. Raises: PathError: If path format is invalid, path is empty, contains empty keys, or exceeds maximum depth. """ if isinstance(path, list): - keys = [key for key in path] + # Convert all list elements to strings + keys = [str(key) for key in path] elif isinstance(path, str): keys = path.split(".") else: @@ -50,6 +51,9 @@ def normalize_path(path: Union[str, List[Any]]) -> List[str]: def is_int_key(key: str) -> bool: """Check if a key represents a valid integer index. + STRICT CHECKING: Only accepts string representations of integers. + Rejects: bool, float, complex, or any other type. + Args: key: String to check. @@ -63,9 +67,19 @@ def is_int_key(key: str) -> bool: True >>> is_int_key("abc") False - >>> is_int_key("-") + >>> is_int_key("") + False + >>> is_int_key(True) # Not a string + False + >>> is_int_key(3.14) # Not a string False """ + if not isinstance(key, str): + return False + + if not key: # Empty string + return False + try: int(key) return True @@ -118,29 +132,50 @@ def resolve_read_index(container: Union[list, tuple], key: str) -> Optional[int] return None -def resolve_write_index(container: list, key: str, allow_extension: bool = True) -> int: +def resolve_write_index(container: list, key: str) -> int: """Resolve index for write operations (set_at). - Write operations have stricter semantics: + Write operations have strict semantics to prevent sparse lists: - Negative indices must reference existing elements (no extension) - - Positive indices can extend the list if allow_extension=True - - Positive indices cannot exceed MAX_LIST_SIZE to prevent memory exhaustion + - Positive indices can only extend by 1 (append operation) + - Index must be <= len(list) (can append, but not create gaps) + - Index cannot exceed MAX_LIST_SIZE Args: container: The list to index into. key: String representation of the index. - allow_extension: If True, positive indices can exceed current list length. - If False, all indices must reference existing elements. Returns: Resolved positive index. Raises: - PathError: If index is out of bounds, exceeds MAX_LIST_SIZE, or key cannot be parsed as integer. + PathError: If index is out of bounds, would create sparse list, + exceeds MAX_LIST_SIZE, or key is not a valid integer. + + Examples: + >>> lst = [10, 20, 30] + >>> resolve_write_index(lst, "3") # Append + 3 + >>> resolve_write_index(lst, "1") # Modify existing + 1 + >>> resolve_write_index(lst, "-1") # Modify last + 2 + >>> resolve_write_index(lst, "5") # Would create gap + PathError: Index 5 out of bounds for list of length 3 (no sparse lists) + >>> resolve_write_index(lst, "-5") # Negative out of bounds + PathError: Index -5 out of bounds for list of length 3 """ idx = parse_int_key(key) length = len(container) + # Check maximum size limit first (before any calculations) + if idx > MAX_LIST_SIZE: + raise PathError( + f"List index {idx} exceeds maximum size {MAX_LIST_SIZE}", + PathErrorCode.INVALID_INDEX + ) + + # Handle negative indices if idx < 0: resolved = length + idx if resolved < 0 or resolved >= length: @@ -150,16 +185,11 @@ def resolve_write_index(container: list, key: str, allow_extension: bool = True) ) return resolved - # Check maximum list size when extension is allowed - if allow_extension and idx > MAX_LIST_SIZE: + # Handle positive indices - NO SPARSE LISTS + if idx > length: raise PathError( - f"List index {idx} exceeds maximum size {MAX_LIST_SIZE}", - PathErrorCode.INVALID_INDEX - ) - - if not allow_extension and idx >= length: - raise PathError( - f"Index {key} out of bounds for list of length {length}", + f"Index {idx} out of bounds for list of length {length} " + f"(no sparse lists allowed - index must be <= {length})", PathErrorCode.INVALID_INDEX ) @@ -196,58 +226,25 @@ def navigate(container: Any, key: str, default: Any) -> Any: return default -def create_container(strategy: FillStrategy, next_key: str) -> Union[dict, list]: - """Create a new container based on fill strategy and next key. +def create_intermediate_container(next_key: str) -> Union[dict, list]: + """Create intermediate container based on next key type. - Args: - strategy: The fill strategy to use. - next_key: The next key in the path (used for "auto" strategy). - - Returns: - A new dict or list. - """ - if strategy == FillStrategy.DICT: - return {} - elif strategy == FillStrategy.LIST: - return [] - else: # auto or none - return [] if is_int_key(next_key) else {} - - -def fill_list_gaps(target: list, up_to_index: int, strategy: FillStrategy) -> None: - """Fill gaps in a list up to (but not including) target index. + Logic: + - If next_key is numeric → create list + - Otherwise → create dict Args: - target: The list to extend. - up_to_index: The target index to reach. - strategy: How to fill the gaps (None for auto/none, dict/list for others). - """ - while len(target) < up_to_index: - if strategy == FillStrategy.DICT: - target.append({}) - elif strategy == FillStrategy.LIST: - target.append([]) - else: # auto or none - target.append(None) - - -def ensure_container_at_index( - target: list, - index: int, - strategy: FillStrategy, - next_key: str -) -> None: - """Ensure there's a navigable container at the given index. + next_key: The next key in the path. - Creates a new container if index doesn't exist or contains None. + Returns: + Empty list or dict. - Args: - target: The list to modify. - index: The index to ensure a container at. - strategy: How to create containers. - next_key: The next key in the path (for "auto" strategy). + Examples: + >>> create_intermediate_container("0") + [] + >>> create_intermediate_container("name") + {} + >>> create_intermediate_container("-1") + [] """ - if len(target) == index: - target.append(create_container(strategy, next_key)) - elif target[index] is None: - target[index] = create_container(strategy, next_key) \ No newline at end of file + return [] if is_int_key(next_key) else {} \ No newline at end of file diff --git a/tests/test_delete.py b/tests/test_delete.py index 3c63c18..8f918b6 100644 --- a/tests/test_delete.py +++ b/tests/test_delete.py @@ -206,7 +206,7 @@ def test_delete_after_set(self): """Delete value that was just set.""" d = {} from nestedutils import set_at - set_at(d, "a.b.c", 1) + set_at(d, "a.b.c", 1, create=True) val = delete_at(d, "a.b.c") assert val == 1 assert d == {"a": {"b": {}}} diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index d64af72..cee2791 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -38,9 +38,8 @@ def test_all_error_codes_exist(self): PathErrorCode.EMPTY_PATH, PathErrorCode.IMMUTABLE_CONTAINER, PathErrorCode.INVALID_PATH, - PathErrorCode.INVALID_FILL_STRATEGY, ] - assert len(codes) == 6 + assert len(codes) == 5 def test_error_code_values(self): """Verify error code string values.""" @@ -49,7 +48,6 @@ def test_error_code_values(self): assert PathErrorCode.EMPTY_PATH.value == "EMPTY_PATH" assert PathErrorCode.IMMUTABLE_CONTAINER.value == "IMMUTABLE_CONTAINER" assert PathErrorCode.INVALID_PATH.value == "INVALID_PATH" - assert PathErrorCode.INVALID_FILL_STRATEGY.value == "INVALID_FILL_STRATEGY" class TestPathNormalizationEdgeCases: @@ -69,22 +67,26 @@ class TestComplexIntegrationScenarios: def test_round_trip_operations(self): """Multiple operations in sequence.""" d = {} - set_at(d, "a.b.c", 1) + set_at(d, "a.b.c", 1, create=True) assert get_at(d, "a.b.c") == 1 - set_at(d, "a.b.d", 2) + set_at(d, "a.b.d", 2, create=True) assert get_at(d, "a.b.d") == 2 val = delete_at(d, "a.b.c") assert val == 1 - assert get_at(d, "a.b.c") is None + with pytest.raises(PathError): + get_at(d, "a.b.c") assert get_at(d, "a.b.d") == 2 def test_mixed_numeric_and_string_keys(self): """Mixed numeric and string keys.""" d = {} - set_at(d, "a.0.b.1.c", 42) + # Build structure sequentially - first create index 0, then index 1 + set_at(d, "a.0.b.0.c", 10, create=True) # Create list at a[0]["b"] with first element + set_at(d, "a.0.b.1.c", 42, create=True) # Now can append to list assert isinstance(d["a"], list) assert isinstance(d["a"][0], dict) assert isinstance(d["a"][0]["b"], list) + assert d["a"][0]["b"][0]["c"] == 10 assert d["a"][0]["b"][1]["c"] == 42 @@ -96,8 +98,12 @@ def test_get_none_vs_missing(self): d1 = {"a": None} d2 = {} assert get_at(d1, "a") is None - assert get_at(d2, "a") is None - # Both return None, but one has the key, one doesn't + with pytest.raises(PathError): + get_at(d2, "a") + # With default, both return None + assert get_at(d1, "a", default="missing") is None + assert get_at(d2, "a", default="missing") == "missing" + # But one has the key, one doesn't assert "a" in d1 assert "a" not in d2 @@ -109,14 +115,14 @@ def test_very_deep_nesting(self): """Very deeply nested structure.""" d = {} path = ".".join(["level" + str(i) for i in range(20)]) - set_at(d, path, "deep") + set_at(d, path, "deep", create=True) assert get_at(d, path) == "deep" def test_many_keys_in_dict(self): """Dict with many keys.""" d = {} for i in range(100): - set_at(d, f"key{i}", i) + set_at(d, f"key{i}", i, create=True) for i in range(100): assert get_at(d, f"key{i}") == i @@ -148,19 +154,19 @@ class TestSpecialCharacters: def test_keys_with_special_chars(self): """Keys with special characters.""" d = {} - set_at(d, "a-b.c_d.e@f", 42) + set_at(d, "a-b.c_d.e@f", 42, create=True) assert get_at(d, "a-b.c_d.e@f") == 42 def test_keys_with_spaces_in_list_form(self): """Keys with spaces using list form.""" d = {} - set_at(d, ["key with spaces", "another key"], 1) + set_at(d, ["key with spaces", "another key"], 1, create=True) assert get_at(d, ["key with spaces", "another key"]) == 1 def test_keys_with_newlines_in_list_form(self): """Keys with newlines using list form.""" d = {} - set_at(d, ["key\nwith\nnewlines"], 1) + set_at(d, ["key\nwith\nnewlines"], 1, create=True) assert get_at(d, ["key\nwith\nnewlines"]) == 1 @@ -174,15 +180,18 @@ def test_negative_index_boundary_conditions(self): assert get_at(d, "a.-1") == 30 assert get_at(d, "a.-2") == 20 assert get_at(d, "a.-3") == 10 - # Just out of bounds - assert get_at(d, "a.-4") is None - assert get_at(d, "a.-5") is None + # Just out of bounds - raises PathError + with pytest.raises(PathError): + get_at(d, "a.-4") + with pytest.raises(PathError): + get_at(d, "a.-5") def test_negative_index_single_element_list(self): """Negative index on single element list.""" d = {"a": [42]} assert get_at(d, "a.-1") == 42 - assert get_at(d, "a.-2") is None + with pytest.raises(PathError): + get_at(d, "a.-2") # Can modify with negative index set_at(d, "a.-1", 99) assert d["a"] == [99] @@ -212,9 +221,12 @@ def test_negative_index_with_tuples(self): def test_negative_index_very_large_negative(self): """Very large negative numbers should be out of bounds.""" d = {"a": [1, 2, 3]} - assert get_at(d, "a.-1000") is None - assert get_at(d, "a.-999999") is None - # Should not raise, just return default + with pytest.raises(PathError): + get_at(d, "a.-1000") + with pytest.raises(PathError): + get_at(d, "a.-999999") + # With default, returns default + assert get_at(d, "a.-1000", default="missing") == "missing" def test_negative_index_chaining(self): """Chaining multiple negative index operations.""" @@ -235,5 +247,6 @@ def test_negative_index_after_list_mutation(self): assert get_at(d, "items.-2") == 4 assert get_at(d, "items.-4") == 1 # -5 should now be out of bounds - assert get_at(d, "items.-5") is None + with pytest.raises(PathError): + get_at(d, "items.-5") diff --git a/tests/test_exists.py b/tests/test_exists.py index 3c0e6eb..04fe0ab 100644 --- a/tests/test_exists.py +++ b/tests/test_exists.py @@ -221,7 +221,7 @@ def test_exists_after_set(self): from nestedutils import set_at d = {} assert exists_at(d, "a.b.c") is False - set_at(d, "a.b.c", 1) + set_at(d, "a.b.c", 1, create=True) assert exists_at(d, "a.b.c") is True def test_exists_after_delete(self): @@ -233,15 +233,17 @@ def test_exists_after_delete(self): assert exists_at(d, "a.b") is False assert exists_at(d, "a.c") is True - def test_exists_with_sparse_list(self): - """Check existence in sparse list created with fill_strategy.""" + def test_exists_with_sequential_list(self): + """Check existence in sequentially built list.""" from nestedutils import set_at d = {} - set_at(d, "items.5", "Item 6", fill_strategy="none") - assert exists_at(d, "items.5") is True - assert exists_at(d, "items.0") is True # None values exist - assert exists_at(d, "items.4") is True # None values exist - assert exists_at(d, "items.6") is False + set_at(d, "items.0", "Item 1", create=True) + set_at(d, "items.1", "Item 2", create=True) + set_at(d, "items.2", "Item 3", create=True) + assert exists_at(d, "items.0") is True + assert exists_at(d, "items.1") is True + assert exists_at(d, "items.2") is True + assert exists_at(d, "items.3") is False def test_exists_root_level(self): """Check existence at root level.""" @@ -256,7 +258,7 @@ def test_exists_nested_none_replacement(self): d = {"a": None} assert exists_at(d, "a") is True assert exists_at(d, "a.b") is False - set_at(d, "a.b.c", 10) + set_at(d, "a.b.c", 10, create=True) assert exists_at(d, "a") is True assert exists_at(d, "a.b") is True assert exists_at(d, "a.b.c") is True diff --git a/tests/test_get.py b/tests/test_get.py index 5e3c7ac..6218ee1 100644 --- a/tests/test_get.py +++ b/tests/test_get.py @@ -35,11 +35,17 @@ def test_get_intermediate_nested_object(self): # Can also get deeper values assert get_at(data, "user.profile.address.city") == "SF" - def test_get_missing_returns_default(self): - """Get missing key returns default value.""" + def test_get_missing_raises_error(self): + """Get missing key raises PathError by default.""" + d = {"a": {"b": 1}} + with pytest.raises(PathError) as exc_info: + get_at(d, "a.c") + assert exc_info.value.code == PathErrorCode.MISSING_KEY + + def test_get_missing_with_default(self): + """Get missing key returns default when explicitly provided.""" d = {"a": {"b": 1}} assert get_at(d, "a.c", default=99) == 99 - assert get_at(d, "x.y.z") is None assert get_at(d, "x.y.z", default="missing") == "missing" def test_get_list_index(self): @@ -64,11 +70,14 @@ def test_get_negative_index_nested_structure(self): assert get_at(d, "items.-1") == {"name": "banana"} def test_get_negative_index_out_of_bounds(self): - """Get with out-of-bounds negative index returns default.""" + """Get with out-of-bounds negative index raises PathError by default.""" d = {"items": [{"name": "apple"}, {"name": "banana"}]} - assert get_at(d, "items.-5.name") is None + with pytest.raises(PathError) as exc_info: + get_at(d, "items.-5.name") + assert exc_info.value.code == PathErrorCode.INVALID_INDEX assert get_at(d, "items.-5.name", default="not found") == "not found" - assert get_at(d, "items.-10") is None + with pytest.raises(PathError): + get_at(d, "items.-10") def test_get_negative_index_deeply_nested(self): """Get with negative index in deeply nested structure.""" @@ -84,20 +93,28 @@ def test_get_nested_list_dict_mix(self): assert get_at(d, "a.0.b") == 1 def test_get_list_index_out_of_bounds(self): - """Get from out-of-bounds list index returns default.""" + """Get from out-of-bounds list index raises PathError by default.""" d = {"a": [10, 20]} - assert get_at(d, "a.5") is None + with pytest.raises(PathError) as exc_info: + get_at(d, "a.5") + assert exc_info.value.code == PathErrorCode.INVALID_INDEX assert get_at(d, "a.5", default=-1) == -1 - assert get_at(d, "a.-10") is None + with pytest.raises(PathError): + get_at(d, "a.-10") # Test both positive and negative out-of-bounds - assert get_at(d, "a.100") is None - assert get_at(d, "a.-100") is None - assert get_at(d, "a.-3") is None # Just out of bounds for length 2 + with pytest.raises(PathError): + get_at(d, "a.100") + with pytest.raises(PathError): + get_at(d, "a.-100") + with pytest.raises(PathError): + get_at(d, "a.-3") # Just out of bounds for length 2 def test_get_list_index_non_integer(self): - """Get with non-integer key on list returns default.""" + """Get with non-integer key on list raises PathError by default.""" d = {"a": [1]} - assert get_at(d, "a.x") is None + with pytest.raises(PathError) as exc_info: + get_at(d, "a.x") + assert exc_info.value.code == PathErrorCode.INVALID_INDEX assert get_at(d, "a.x", default="not found") == "not found" @@ -140,22 +157,22 @@ def test_keys_with_dots_in_list_form(self): assert get_at(d, ["a.b", "c.d"]) == 10 def test_complex_keys_in_list_form(self): - """List form handles any key type: special characters, dots, integers.""" + """List form handles any key type: special characters, dots, integers (converted to strings).""" # Create data structure with complex keys + # Note: normalize_path converts all list elements to strings, so integer keys become string keys data = { "user-info": { "first.name": { - 123: "found it!" + "123": "found it!" # String key since normalize_path converts to strings } } } # Verify each level works independently - assert get_at(data, ["user-info"]) == {"first.name": {123: "found it!"}} - assert get_at(data, ["user-info", "first.name"]) == {123: "found it!"} + assert get_at(data, ["user-info"]) == {"first.name": {"123": "found it!"}} + assert get_at(data, ["user-info", "first.name"]) == {"123": "found it!"} - # String form cannot handle these keys (dots would be interpreted as separators) - # But list form works perfectly + # List form works - integer 123 in path is converted to string "123" assert get_at(data, ["user-info", "first.name", 123]) == "found it!" # Test with default for missing path @@ -166,15 +183,22 @@ class TestGetEdgeCases: """Edge cases for get_at.""" def test_get_empty_dict(self): - """Get from empty dict.""" + """Get from empty dict raises PathError by default.""" d = {} - assert get_at(d, "a") is None - assert get_at(d, "a.b.c") is None + with pytest.raises(PathError): + get_at(d, "a") + with pytest.raises(PathError): + get_at(d, "a.b.c") + # With default, returns default + assert get_at(d, "a", default=None) is None + assert get_at(d, "a.b.c", default="missing") == "missing" def test_get_empty_list(self): - """Get from empty list.""" + """Get from empty list raises PathError by default.""" d = {"a": []} - assert get_at(d, "a.0") is None + with pytest.raises(PathError): + get_at(d, "a.0") + assert get_at(d, "a.0", default=None) is None def test_get_none_value(self): """Get None value explicitly stored.""" @@ -182,28 +206,34 @@ def test_get_none_value(self): assert get_at(d, "a") is None def test_none_blocks_navigation(self): - """None as intermediate value should block navigation and return default.""" + """None as intermediate value should block navigation and raise PathError by default.""" # None at first level blocks further navigation d = {"a": None} - assert get_at(d, "a.b") is None + with pytest.raises(PathError): + get_at(d, "a.b") assert get_at(d, "a.b", default="missing") == "missing" - assert get_at(d, "a.b.c") is None + with pytest.raises(PathError): + get_at(d, "a.b.c") assert get_at(d, "a.b.c", default=99) == 99 # None at deeper level also blocks navigation d2 = {"a": {"b": None}} - assert get_at(d2, "a.b.c") is None + with pytest.raises(PathError): + get_at(d2, "a.b.c") assert get_at(d2, "a.b.c", default="not found") == "not found" - assert get_at(d2, "a.b.c.d.e") is None + with pytest.raises(PathError): + get_at(d2, "a.b.c.d.e") # None in list should also block navigation d3 = {"items": [None, {"name": "apple"}]} - assert get_at(d3, "items.0.name") is None + with pytest.raises(PathError): + get_at(d3, "items.0.name") assert get_at(d3, "items.0.name", default="no name") == "no name" # None in nested structure d4 = {"data": {"user": None, "other": {"value": 42}}} - assert get_at(d4, "data.user.name") is None + with pytest.raises(PathError): + get_at(d4, "data.user.name") assert get_at(d4, "data.user.name", default="default") == "default" # But other paths should still work assert get_at(d4, "data.other.value") == 42 @@ -235,26 +265,25 @@ def test_get_from_dict_with_numeric_keys(self): assert get_at(d, "0.1.2") == 5 def test_get_dict_with_integer_vs_string_keys(self): - """Test that integer and string keys are correctly distinguished. + """Test that normalize_path converts all list elements to strings. - The library correctly preserves key types in list form paths, allowing - access to both integer keys and string keys with the same numeric value. + Since normalize_path() converts all list elements to strings to ensure + List[str] return type, integer keys in list paths are converted to strings. """ # Dictionary with both integer key and string key data = {0: "int_value", "0": "string_value"} - # Using integer in path accesses integer key - assert get_at(data, [0]) == "int_value" - - # Using string in path accesses string key - assert get_at(data, ["0"]) == "string_value" + # Both integer and string in path are converted to string "0" + # So both access the string key "0" + assert get_at(data, [0]) == "string_value" # Converted to ["0"] + assert get_at(data, ["0"]) == "string_value" # Already string - # Verify direct access matches + # Verify direct access still distinguishes assert data[0] == "int_value" assert data["0"] == "string_value" - # Both keys are distinct and accessible - assert get_at(data, [0]) != get_at(data, ["0"]) + # List paths can only access string keys now (all converted to strings) + assert get_at(data, [0]) == get_at(data, ["0"]) # Both become "0" def test_get_mixed_types(self): """Get from structure with mixed types.""" diff --git a/tests/test_normalize_path.py b/tests/test_normalize_path.py index fb2002c..e50b913 100644 --- a/tests/test_normalize_path.py +++ b/tests/test_normalize_path.py @@ -24,9 +24,9 @@ class TestNormalizePathValid: (["a", "b", "c"], ["a", "b", "c"]), # List paths - mixed types (converted to strings) - (["user", 0, "name"], ["user", 0, "name"]), - ([0, 1, 2], [0, 1, 2]), - (["items", -1], ["items", -1]), + (["user", 0, "name"], ["user", "0", "name"]), + ([0, 1, 2], ["0", "1", "2"]), + (["items", -1], ["items", "-1"]), ]) def test_valid_paths(self, path, expected): """Test that valid paths are normalized correctly.""" diff --git a/tests/test_set.py b/tests/test_set.py index bb84364..0cb0c9f 100644 --- a/tests/test_set.py +++ b/tests/test_set.py @@ -19,22 +19,31 @@ def test_set_nested_existing(self): assert d == {"a": {"b": {"c": 9}}} def test_set_auto_create_dicts(self): - """Auto-create nested dicts when setting.""" + """Auto-create nested dicts when setting with create=True.""" d = {} - set_at(d, "x.y.z", 10) + set_at(d, "x.y.z", 10, create=True) assert d == {"x": {"y": {"z": 10}}} def test_set_auto_create_lists(self): - """Auto-create lists when using numeric keys.""" + """Auto-create lists when using numeric keys with create=True.""" d = {} - set_at(d, "a.0.b", 1) + set_at(d, "a.0.b", 1, create=True) assert d == {"a": [{"b": 1}]} - def test_set_auto_sparse_list(self): - """Auto-create sparse list with None fill.""" + def test_set_requires_create_for_missing_path(self): + """set_at raises PathError for missing paths by default.""" + d = {} + with pytest.raises(PathError) as exc_info: + set_at(d, "x.y.z", 10) + assert exc_info.value.code == PathErrorCode.MISSING_KEY + + def test_set_no_sparse_lists(self): + """Sparse list creation is not allowed.""" d = {"a": []} - set_at(d, "a.5", 99) - assert d == {"a": [None, None, None, None, None, 99]} + with pytest.raises(PathError) as exc_info: + set_at(d, "a.5", 99, create=True) + assert exc_info.value.code == PathErrorCode.INVALID_INDEX + assert "no sparse lists" in str(exc_info.value.message).lower() def test_set_negative_index_write(self): """Set value using negative index.""" @@ -89,99 +98,64 @@ def test_set_negative_index_all_valid_indices(self): def test_set_inside_list_of_dicts(self): """Set value inside list containing dicts.""" d = {"a": [{}]} - set_at(d, "a.0.x.y", 5) + set_at(d, "a.0.x.y", 5, create=True) assert d == {"a": [{"x": {"y": 5}}]} -class TestSetFillStrategy: - """Tests for fill_strategy parameter.""" - - def test_set_fill_strategy_none(self): - """Fill strategy 'none' fills missing list items with None.""" - d = {} - set_at(d, "a.3", 7, fill_strategy="none") - assert d == {"a": [None, None, None, 7]} - - def test_set_fill_strategy_dict(self): - """Fill strategy 'dict' always creates dicts.""" - d = {} - set_at(d, "a.0.b", 1, fill_strategy="dict") - assert d == {"a": {"0": {"b": 1}}} +class TestSetCreateFlag: + """Tests for create parameter.""" - def test_set_fill_strategy_list(self): - """Fill strategy 'list' always creates lists.""" + def test_set_create_true_auto_creates(self): + """create=True auto-creates missing intermediate containers.""" d = {} - set_at(d, "a.0.1", 42, fill_strategy="list") - assert d == {"a": [[None, 42]]} + set_at(d, "a.b.c", 1, create=True) + assert d == {"a": {"b": {"c": 1}}} - def test_set_invalid_fill_strategy(self): - """Invalid fill_strategy should raise PathError.""" + def test_set_create_false_raises_for_missing(self): + """create=False (default) raises PathError for missing paths.""" d = {} with pytest.raises(PathError) as exc_info: - set_at(d, "a.b", 1, fill_strategy="unknown") - assert exc_info.value.code == PathErrorCode.INVALID_FILL_STRATEGY - - def test_all_numeric_path_with_dict_strategy(self): - """Numeric strings should be dict keys with dict strategy.""" - d = {} - set_at(d, "0.1.2", 5, fill_strategy="dict") - assert d == {"0": {"1": {"2": 5}}} + set_at(d, "a.b.c", 1) + assert exc_info.value.code == PathErrorCode.MISSING_KEY - def test_all_numeric_path_with_auto_strategy(self): - """All numeric path with auto should create nested lists.""" + def test_set_create_true_sequential_list_building(self): + """create=True allows sequential list building.""" d = {} - set_at(d, "0.1", 5, fill_strategy="auto") - assert isinstance(d["0"], list) - assert d["0"][1] == 5 + set_at(d, "items.0", "first", create=True) + set_at(d, "items.1", "second", create=True) + assert d == {"items": ["first", "second"]} - def test_multiple_operations_different_strategies(self): - """Multiple sets with different strategies on same structure.""" + def test_set_create_true_no_gaps_allowed(self): + """create=True does not allow sparse lists.""" d = {} - set_at(d, "a.0", 1, fill_strategy="auto") - set_at(d, "a.5", 2, fill_strategy="dict") # Should still be list - assert isinstance(d["a"], list) - assert len(d["a"]) == 6 - - @pytest.mark.parametrize("strategy", ["auto", "none", "dict", "list"]) - def test_fill_strategy_creates_correct_type(self, strategy): - """Each fill strategy creates appropriate container type.""" - d = {} - if strategy == "dict": - set_at(d, "a.b.c", 1, fill_strategy=strategy) - assert isinstance(d["a"], dict) - assert isinstance(d["a"]["b"], dict) - elif strategy == "list": - set_at(d, "a.0.1", 1, fill_strategy=strategy) - assert isinstance(d["a"], list) - assert isinstance(d["a"][0], list) - else: # auto or none - set_at(d, "a.0.b", 1, fill_strategy=strategy) - assert isinstance(d["a"], list) - assert isinstance(d["a"][0], dict) + set_at(d, "items.0", "first", create=True) + with pytest.raises(PathError) as exc_info: + set_at(d, "items.5", "x", create=True) + assert exc_info.value.code == PathErrorCode.INVALID_INDEX class TestSetNoneValues: """Tests for handling None values.""" def test_set_through_existing_none_in_list(self): - """Should replace None with a container when navigating deeper.""" + """Should replace None with a container when navigating deeper with create=True.""" d = {"a": [None, None, None]} - set_at(d, "a.1.x", 5) + set_at(d, "a.1.x", 5, create=True) assert d == {"a": [None, {"x": 5}, None]} - def test_set_through_none_created_by_sparse_fill(self): - """Auto-filled None should be replaceable.""" - d = {} - set_at(d, "a.5", 1) # Creates [None, None, None, None, None, 1] - set_at(d, "a.2.b", 99) # Should replace None at index 2 - assert d["a"][2] == {"b": 99} - def test_set_through_none_in_dict(self): - """Should replace None value in dict when navigating deeper.""" + """Should replace None value in dict when navigating deeper with create=True.""" d = {"a": None} - set_at(d, "a.b.c", 10) + set_at(d, "a.b.c", 10, create=True) assert d == {"a": {"b": {"c": 10}}} + def test_set_through_none_requires_create(self): + """Setting through None requires create=True.""" + d = {"a": None} + with pytest.raises(PathError) as exc_info: + set_at(d, "a.b.c", 10) + assert exc_info.value.code == PathErrorCode.MISSING_KEY + def test_set_none_value_explicitly(self): """Setting None as a value should work.""" d = {"a": 1} @@ -189,16 +163,16 @@ def test_set_none_value_explicitly(self): assert d == {"a": None} def test_set_none_in_nested_path(self): - """Setting None deep in path.""" + """Setting None deep in path with create=True.""" d = {} - set_at(d, "a.b.c", None) + set_at(d, "a.b.c", None, create=True) assert d == {"a": {"b": {"c": None}}} def test_set_none_then_replace(self): - """Set None then replace with container.""" + """Set None then replace with container using create=True.""" d = {} set_at(d, "a", None) - set_at(d, "a.b", 1) + set_at(d, "a.b", 1, create=True) assert d == {"a": {"b": 1}} @@ -254,8 +228,10 @@ def test_set_negative_index_on_empty_list(self): def test_set_negative_index_on_empty_dict(self): """Can't create list with negative index as first operation.""" d = {} + # First, need to create the list with create=True + # But negative index can't be used to create (must reference existing element) with pytest.raises(PathError) as exc_info: - set_at(d, "a.-1", 5) + set_at(d, "a.-1", 5, create=True) assert exc_info.value.code == PathErrorCode.INVALID_INDEX def test_numeric_key_on_existing_dict(self): @@ -267,19 +243,19 @@ def test_numeric_key_on_existing_dict(self): def test_invalid_negative_format(self): """String starting with - but not a valid negative number.""" d = {} - set_at(d, "a.-5x", 1) + set_at(d, "a.-5x", 1, create=True) assert d == {"a": {"-5x": 1}} # Should be dict key def test_just_minus_sign(self): """Just a minus sign should be dict key.""" d = {} - set_at(d, "a.-", 1) + set_at(d, "a.-", 1, create=True) assert d == {"a": {"-": 1}} def test_multiple_minus_signs(self): """Multiple minus signs.""" d = {} - set_at(d, "a.--5", 1) + set_at(d, "a.--5", 1, create=True) assert d == {"a": {"--5": 1}} @@ -289,25 +265,25 @@ class TestSetPathNormalization: def test_path_as_list_form(self): """Set using list form path.""" d = {} - set_at(d, ["a", "b", "c"], 1) + set_at(d, ["a", "b", "c"], 1, create=True) assert d == {"a": {"b": {"c": 1}}} def test_keys_with_dots_in_list_form(self): """Using list form allows keys with dots.""" d = {} - set_at(d, ["a.b", "c.d"], 10) + set_at(d, ["a.b", "c.d"], 10, create=True) assert d == {"a.b": {"c.d": 10}} def test_unicode_keys(self): """Unicode in keys should work.""" d = {} - set_at(d, "你好.world.🌍", 42) + set_at(d, "你好.world.🌍", 42, create=True) assert d["你好"]["world"]["🌍"] == 42 def test_path_list_with_integers(self): """List form path with integer keys.""" d = {} - set_at(d, ["a", 0, "b"], 1) + set_at(d, ["a", 0, "b"], 1, create=True) assert d == {"a": [{"b": 1}]} @@ -317,12 +293,14 @@ class TestSetComplex: def test_set_deep_mixed_structure(self): """Set in deeply nested mixed structure.""" d = {} - set_at(d, "x.0.y.1.z", 42) + # Need to build sequentially - first create index 0, then index 1 + set_at(d, "x.0.y.0.z", 10, create=True) # Create first element + set_at(d, "x.0.y.1.z", 42, create=True) # Now can append assert d == { "x": [ { "y": [ - None, + {"z": 10}, {"z": 42} ] } @@ -330,9 +308,9 @@ def test_set_deep_mixed_structure(self): } def test_set_at_creates_correct_types_auto(self): - """Auto strategy creates correct types.""" + """create=True creates correct types.""" d = {} - set_at(d, "root.0.child.0", 10) + set_at(d, "root.0.child.0", 10, create=True) assert d == {"root": [{"child": [10]}]} def test_overwrite_value_with_container(self): @@ -343,32 +321,27 @@ def test_overwrite_value_with_container(self): set_at(d, "a.c", 2) assert d == {"a": {"b": 1, "c": 2}} - def test_final_key_extends_list(self): - """Final key extending list should always fill with None.""" + def test_final_key_extends_list_sequentially(self): + """Final key can only extend list by 1 (append).""" d = {"a": []} - set_at(d, "a.5", 99) - assert d == {"a": [None, None, None, None, None, 99]} + set_at(d, "a.0", "first", create=True) + set_at(d, "a.1", "second", create=True) + assert d == {"a": ["first", "second"]} - def test_set_positive_extension_from_docstring(self): - """Positive index extension example from docstring.""" + def test_set_positive_extension_sequential_only(self): + """Positive index can only append, not create gaps.""" data = [1, 2, 3] - set_at(data, "5", 99) - assert data == [1, 2, 3, None, None, 99] - - def test_very_large_sparse_list(self): - """Creating very large sparse list should work.""" - d = {} - set_at(d, "a.1000", 1) - assert len(d["a"]) == 1001 - assert d["a"][1000] == 1 - assert all(d["a"][i] is None for i in range(1000)) + set_at(data, "3", 99, create=True) # Append + assert data == [1, 2, 3, 99] + with pytest.raises(PathError): + set_at(data, "5", 100, create=True) # Would create gap def test_set_multiple_values_same_structure(self): """Set multiple values in same structure.""" d = {} - set_at(d, "a.b.c", 1) - set_at(d, "a.b.d", 2) - set_at(d, "a.e", 3) + set_at(d, "a.b.c", 1, create=True) + set_at(d, "a.b.d", 2, create=True) # Path exists now, no create needed + set_at(d, "a.e", 3, create=True) # Path exists now, no create needed assert d == {"a": {"b": {"c": 1, "d": 2}, "e": 3}} def test_set_overwrite_existing_value(self): From 49726aa52c13316d674a38e4a6902ca4def69908 Mon Sep 17 00:00:00 2001 From: Siva Sai <26503640+ysskrishna@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:44:16 +0530 Subject: [PATCH 02/17] Siva | cleanup navigate function --- nestedutils/access.py | 2 +- nestedutils/helpers.py | 30 ------------------------------ 2 files changed, 1 insertion(+), 31 deletions(-) diff --git a/nestedutils/access.py b/nestedutils/access.py index 07feb18..a62afdb 100644 --- a/nestedutils/access.py +++ b/nestedutils/access.py @@ -1,7 +1,7 @@ from typing import Any, List, Union from .exceptions import PathError from .enums import PathErrorCode -from .helpers import normalize_path, navigate, is_int_key, resolve_write_index, resolve_read_index +from .helpers import normalize_path, is_int_key, resolve_write_index, resolve_read_index from .helpers import create_intermediate_container diff --git a/nestedutils/helpers.py b/nestedutils/helpers.py index 7a43b98..70dd9cb 100644 --- a/nestedutils/helpers.py +++ b/nestedutils/helpers.py @@ -196,36 +196,6 @@ def resolve_write_index(container: list, key: str) -> int: return idx -def navigate(container: Any, key: str, default: Any) -> Any: - """Navigate one level into a container. - - Used by read operations (get_at, exists_at). Returns default - for any navigation failures rather than raising exceptions. - - Args: - container: Container to navigate into (dict, list, or tuple). - key: Key or index to access. - default: Sentinel value to return on failure. - - Returns: - The value at the key, or default if navigation fails. - """ - if isinstance(container, dict): - return container.get(key, default) - - if isinstance(container, (list, tuple)): - if not is_int_key(key): - return default - - idx = resolve_read_index(container, key) - if idx is None: - return default - - return container[idx] - - return default - - def create_intermediate_container(next_key: str) -> Union[dict, list]: """Create intermediate container based on next key type. From dd774516c83281a9b4b9860407799a5b5e6c6c62 Mon Sep 17 00:00:00 2001 From: Siva Sai <26503640+ysskrishna@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:46:26 +0530 Subject: [PATCH 03/17] Siva | Add NON_NAVIGABLE_TYPE --- nestedutils/access.py | 16 ++++------------ nestedutils/enums.py | 5 ++++- tests/test_edge_cases.py | 4 +++- tests/test_set.py | 2 +- 4 files changed, 12 insertions(+), 15 deletions(-) diff --git a/nestedutils/access.py b/nestedutils/access.py index a62afdb..0514cd2 100644 --- a/nestedutils/access.py +++ b/nestedutils/access.py @@ -95,7 +95,7 @@ def get_at(data: Any, path: Union[str, List[Any]], *, default: Any = _MISSING) - return default raise PathError( f"Cannot navigate into {type(current).__name__} at '{key}'", - PathErrorCode.INVALID_PATH + PathErrorCode.NON_NAVIGABLE_TYPE ) return current @@ -149,17 +149,9 @@ def exists_at(data: Any, path: Union[str, List[Any]]) -> bool: return True except PathError as e: # Return False for "not found" errors or navigation into non-navigable types - if e.code in (PathErrorCode.MISSING_KEY, PathErrorCode.INVALID_INDEX, PathErrorCode.INVALID_PATH): - # Check if it's a navigation error (trying to navigate into None, etc.) - # vs a path format error (empty path, wrong type, etc.) - if e.code == PathErrorCode.INVALID_PATH: - # If message indicates navigation into non-navigable type, return False - if "Cannot navigate into" in str(e.message): - return False - # Otherwise it's a path format error, re-raise - raise + if e.code in (PathErrorCode.MISSING_KEY, PathErrorCode.INVALID_INDEX, PathErrorCode.NON_NAVIGABLE_TYPE): return False - # Re-raise for any other errors + # Re-raise for path format errors and any other errors raise @@ -299,7 +291,7 @@ def set_at( else: raise PathError( f"Cannot navigate into {type(current).__name__}", - PathErrorCode.INVALID_PATH + PathErrorCode.NON_NAVIGABLE_TYPE ) # Set final value diff --git a/nestedutils/enums.py b/nestedutils/enums.py index 679c789..132b66b 100644 --- a/nestedutils/enums.py +++ b/nestedutils/enums.py @@ -36,4 +36,7 @@ class PathErrorCode(Enum): """Raised when attempting to modify an immutable container (tuple).""" INVALID_PATH = "INVALID_PATH" - """Raised when path format is invalid (wrong type, exceeds max depth, etc.).""" \ No newline at end of file + """Raised when path format is invalid (wrong type, exceeds max depth, etc.).""" + + NON_NAVIGABLE_TYPE = "NON_NAVIGABLE_TYPE" + """Raised when attempting to navigate into a non-container type (e.g., None, int, str).""" \ No newline at end of file diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index cee2791..646a313 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -38,8 +38,9 @@ def test_all_error_codes_exist(self): PathErrorCode.EMPTY_PATH, PathErrorCode.IMMUTABLE_CONTAINER, PathErrorCode.INVALID_PATH, + PathErrorCode.NON_NAVIGABLE_TYPE, ] - assert len(codes) == 5 + assert len(codes) == 6 def test_error_code_values(self): """Verify error code string values.""" @@ -48,6 +49,7 @@ def test_error_code_values(self): assert PathErrorCode.EMPTY_PATH.value == "EMPTY_PATH" assert PathErrorCode.IMMUTABLE_CONTAINER.value == "IMMUTABLE_CONTAINER" assert PathErrorCode.INVALID_PATH.value == "INVALID_PATH" + assert PathErrorCode.NON_NAVIGABLE_TYPE.value == "NON_NAVIGABLE_TYPE" class TestPathNormalizationEdgeCases: diff --git a/tests/test_set.py b/tests/test_set.py index 0cb0c9f..d5883d7 100644 --- a/tests/test_set.py +++ b/tests/test_set.py @@ -216,7 +216,7 @@ def test_set_at_invalid_type(self): d = {"a": 1} with pytest.raises(PathError) as exc_info: set_at(d, "a.b", 10) - assert exc_info.value.code == PathErrorCode.INVALID_PATH + assert exc_info.value.code == PathErrorCode.NON_NAVIGABLE_TYPE def test_set_negative_index_on_empty_list(self): """Can't use negative index on empty list.""" From 23e01dd017fee311cf577ee0c0bf6db9bad7c091 Mon Sep 17 00:00:00 2001 From: Siva Sai <26503640+ysskrishna@users.noreply.github.com> Date: Sat, 7 Feb 2026 19:02:38 +0530 Subject: [PATCH 04/17] Siva | add OPERATION_DISABLED --- nestedutils/access.py | 6 +++--- nestedutils/enums.py | 5 ++++- tests/test_delete.py | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/nestedutils/access.py b/nestedutils/access.py index 0514cd2..8b593a0 100644 --- a/nestedutils/access.py +++ b/nestedutils/access.py @@ -329,7 +329,7 @@ def set_at( else: raise PathError( f"Cannot set value in {type(current).__name__}", - PathErrorCode.INVALID_PATH + PathErrorCode.NON_NAVIGABLE_TYPE ) @@ -408,7 +408,7 @@ def delete_at( else: raise PathError( f"Cannot navigate through {type(current).__name__}", - PathErrorCode.INVALID_PATH + PathErrorCode.NON_NAVIGABLE_TYPE ) # Delete final key @@ -426,7 +426,7 @@ def delete_at( if not allow_list_mutation: raise PathError( "List deletion disabled. Set allow_list_mutation=True", - PathErrorCode.INVALID_PATH + PathErrorCode.OPERATION_DISABLED ) if not is_int_key(final_key): diff --git a/nestedutils/enums.py b/nestedutils/enums.py index 132b66b..7517fa0 100644 --- a/nestedutils/enums.py +++ b/nestedutils/enums.py @@ -39,4 +39,7 @@ class PathErrorCode(Enum): """Raised when path format is invalid (wrong type, exceeds max depth, etc.).""" NON_NAVIGABLE_TYPE = "NON_NAVIGABLE_TYPE" - """Raised when attempting to navigate into a non-container type (e.g., None, int, str).""" \ No newline at end of file + """Raised when attempting to navigate into a non-container type (e.g., None, int, str).""" + + OPERATION_DISABLED = "OPERATION_DISABLED" + """Raised when an operation is disabled by configuration (e.g., list mutation without allow_list_mutation=True).""" \ No newline at end of file diff --git a/tests/test_delete.py b/tests/test_delete.py index 8f918b6..49d52d2 100644 --- a/tests/test_delete.py +++ b/tests/test_delete.py @@ -43,7 +43,7 @@ def test_delete_list_index_disallowed(self): d = {"a": [1, 2, 3]} with pytest.raises(PathError) as exc_info: delete_at(d, "a.1") - assert exc_info.value.code == PathErrorCode.INVALID_PATH + assert exc_info.value.code == PathErrorCode.OPERATION_DISABLED def test_delete_list_index_allowed(self): """Delete list index with allow_list_mutation=True.""" From e5be0d500fd0ac94192704dfbe7b631ef8b23cd3 Mon Sep 17 00:00:00 2001 From: Siva Sai <26503640+ysskrishna@users.noreply.github.com> Date: Sat, 7 Feb 2026 19:10:11 +0530 Subject: [PATCH 05/17] Cleanup test cases --- tests/test_delete.py | 4 +- tests/test_edge_cases.py | 98 ++-------------------------------------- tests/test_exists.py | 14 ------ tests/test_get.py | 7 --- tests/test_set.py | 8 +--- 5 files changed, 8 insertions(+), 123 deletions(-) diff --git a/tests/test_delete.py b/tests/test_delete.py index 49d52d2..0d7cad1 100644 --- a/tests/test_delete.py +++ b/tests/test_delete.py @@ -212,8 +212,8 @@ def test_delete_after_set(self): assert d == {"a": {"b": {}}} -class TestDeleteInvalidPaths: - """Tests for invalid path types in delete_at.""" +class TestDeletePathFormats: + """Tests for different path formats in delete_at.""" def test_delete_path_as_list(self): """Delete using list form path.""" diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index 646a313..5836a77 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -1,5 +1,5 @@ import pytest -from nestedutils import get_at, set_at, delete_at, exists_at +from nestedutils import get_at, set_at, delete_at from nestedutils.exceptions import PathError, PathErrorCode @@ -52,17 +52,6 @@ def test_error_code_values(self): assert PathErrorCode.NON_NAVIGABLE_TYPE.value == "NON_NAVIGABLE_TYPE" -class TestPathNormalizationEdgeCases: - """Additional edge cases for path normalization.""" - - def test_path_list_with_negative_index(self): - """List form path with negative index.""" - d = {"a": [10, 20, 30]} - assert get_at(d, ["a", -1]) == 30 - set_at(d, ["a", -1], 999) - assert d["a"][2] == 999 - - class TestComplexIntegrationScenarios: """Complex integration scenarios combining multiple operations.""" @@ -92,24 +81,6 @@ def test_mixed_numeric_and_string_keys(self): assert d["a"][0]["b"][1]["c"] == 42 -class TestEmptyAndNoneValues: - """Tests for empty and None value handling.""" - - def test_get_none_vs_missing(self): - """Distinguish between None value and missing key.""" - d1 = {"a": None} - d2 = {} - assert get_at(d1, "a") is None - with pytest.raises(PathError): - get_at(d2, "a") - # With default, both return None - assert get_at(d1, "a", default="missing") is None - assert get_at(d2, "a", default="missing") == "missing" - # But one has the key, one doesn't - assert "a" in d1 - assert "a" not in d2 - - class TestLargeStructures: """Tests for large data structures.""" @@ -137,13 +108,7 @@ def test_list_path_with_string_numbers(self): d = {"a": [1, 2, 3]} assert get_at(d, ["a", "0"]) == 1 assert get_at(d, ["a", "1"]) == 2 - - def test_list_path_with_integers(self): - """List form path with actual integers.""" - d = {"a": [1, 2, 3]} - assert get_at(d, ["a", 0]) == 1 - assert get_at(d, ["a", 1]) == 2 - + def test_mixed_path_types(self): """Mixed path types in list form.""" d = {"a": {"0": [1, 2, 3]}} @@ -173,53 +138,8 @@ def test_keys_with_newlines_in_list_form(self): class TestNegativeIndexEdgeCases: - """Comprehensive edge cases for negative indices.""" - - def test_negative_index_boundary_conditions(self): - """Test negative indices at boundaries.""" - d = {"a": [10, 20, 30]} - # Valid negative indices - assert get_at(d, "a.-1") == 30 - assert get_at(d, "a.-2") == 20 - assert get_at(d, "a.-3") == 10 - # Just out of bounds - raises PathError - with pytest.raises(PathError): - get_at(d, "a.-4") - with pytest.raises(PathError): - get_at(d, "a.-5") - - def test_negative_index_single_element_list(self): - """Negative index on single element list.""" - d = {"a": [42]} - assert get_at(d, "a.-1") == 42 - with pytest.raises(PathError): - get_at(d, "a.-2") - # Can modify with negative index - set_at(d, "a.-1", 99) - assert d["a"] == [99] - - def test_negative_index_in_intermediate_path(self): - """Negative index used in intermediate path steps.""" - d = {"data": [[1, 2], [3, 4], [5, 6]]} - # Navigate using negative index, then access nested - assert get_at(d, "data.-1.0") == 5 - assert get_at(d, "data.-2.-1") == 4 - # Set using negative index in intermediate path - set_at(d, "data.-1.-1", 99) - assert d["data"][-1] == [5, 99] - - def test_negative_index_with_tuples(self): - """Negative index works with tuples (read-only).""" - d = {"a": (10, 20, 30)} - assert get_at(d, "a.-1") == 30 - assert get_at(d, "a.-2") == 20 - assert exists_at(d, "a.-1") is True - assert exists_at(d, "a.-4") is False - # Cannot modify tuples - with pytest.raises(PathError) as exc_info: - set_at(d, "a.-1", 99) - assert exc_info.value.code == PathErrorCode.IMMUTABLE_CONTAINER - + """Edge cases for negative indices not covered in operation-specific tests.""" + def test_negative_index_very_large_negative(self): """Very large negative numbers should be out of bounds.""" d = {"a": [1, 2, 3]} @@ -229,15 +149,7 @@ def test_negative_index_very_large_negative(self): get_at(d, "a.-999999") # With default, returns default assert get_at(d, "a.-1000", default="missing") == "missing" - - def test_negative_index_chaining(self): - """Chaining multiple negative index operations.""" - d = {"levels": [[[1, 2], [3, 4]], [[5, 6], [7, 8]]]} - # Navigate through multiple levels using negative indices - assert get_at(d, "levels.-1.-1.-1") == 8 - assert get_at(d, "levels.-1.-1.-2") == 7 - assert get_at(d, "levels.-2.-1.-1") == 4 - + def test_negative_index_after_list_mutation(self): """Negative index behavior after list mutations.""" d = {"items": [1, 2, 3, 4, 5]} diff --git a/tests/test_exists.py b/tests/test_exists.py index 04fe0ab..9dc50da 100644 --- a/tests/test_exists.py +++ b/tests/test_exists.py @@ -107,14 +107,6 @@ def test_exists_path_list_with_negative_index_nested(self): assert exists_at(d, ["items", -2, "name"]) is True assert exists_at(d, ["items", -5, "name"]) is False - def test_exists_unicode_keys(self): - """Check existence with unicode keys.""" - d = {} - d["你好"] = {"world": {"🌍": 42}} - assert exists_at(d, "你好.world.🌍") is True - assert exists_at(d, "你好.world.🌎") is False - assert exists_at(d, ["你好", "world", "🌍"]) is True - def test_exists_keys_with_dots_in_list_form(self): """List form allows keys with dots.""" d = {"a.b": {"c.d": 10}} @@ -157,12 +149,6 @@ def test_exists_empty_string_value(self): d = {"a": ""} assert exists_at(d, "a") is True - def test_exists_very_deep_nesting(self): - """Check existence in very deeply nested structure.""" - d = {"a": {"b": {"c": {"d": {"e": {"f": {"g": 42}}}}}}} - assert exists_at(d, "a.b.c.d.e.f.g") is True - assert exists_at(d, "a.b.c.d.e.f.h") is False - def test_exists_from_dict_with_numeric_keys(self): """Check existence in dict with numeric string keys.""" d = {"0": {"1": {"2": 5}}} diff --git a/tests/test_get.py b/tests/test_get.py index 6218ee1..41ced60 100644 --- a/tests/test_get.py +++ b/tests/test_get.py @@ -76,8 +76,6 @@ def test_get_negative_index_out_of_bounds(self): get_at(d, "items.-5.name") assert exc_info.value.code == PathErrorCode.INVALID_INDEX assert get_at(d, "items.-5.name", default="not found") == "not found" - with pytest.raises(PathError): - get_at(d, "items.-10") def test_get_negative_index_deeply_nested(self): """Get with negative index in deeply nested structure.""" @@ -254,11 +252,6 @@ def test_get_empty_string_value(self): d = {"a": ""} assert get_at(d, "a") == "" - def test_get_very_deep_nesting(self): - """Get from very deeply nested structure.""" - d = {"a": {"b": {"c": {"d": {"e": {"f": {"g": 42}}}}}}} - assert get_at(d, "a.b.c.d.e.f.g") == 42 - def test_get_from_dict_with_numeric_keys(self): """Get from dict with numeric string keys.""" d = {"0": {"1": {"2": 5}}} diff --git a/tests/test_set.py b/tests/test_set.py index d5883d7..869993d 100644 --- a/tests/test_set.py +++ b/tests/test_set.py @@ -80,7 +80,7 @@ def test_set_negative_index_intermediate_path(self): assert d["data"][-1][-1] == 99 assert d["data"][-1] == [5, 99] - def test_set_negative_index_extends_list(self): + def test_set_negative_index_modifies_single_element_list(self): """Negative index on short list should still work.""" d = {"a": [1]} set_at(d, "a.-1", 99) @@ -274,12 +274,6 @@ def test_keys_with_dots_in_list_form(self): set_at(d, ["a.b", "c.d"], 10, create=True) assert d == {"a.b": {"c.d": 10}} - def test_unicode_keys(self): - """Unicode in keys should work.""" - d = {} - set_at(d, "你好.world.🌍", 42, create=True) - assert d["你好"]["world"]["🌍"] == 42 - def test_path_list_with_integers(self): """List form path with integer keys.""" d = {} From 921ac8521a83a4557047853213346670605485ac Mon Sep 17 00:00:00 2001 From: Siva Sai <26503640+ysskrishna@users.noreply.github.com> Date: Sat, 7 Feb 2026 19:28:33 +0530 Subject: [PATCH 06/17] Siva | fix minor bug with list integer keys --- nestedutils/helpers.py | 83 +++++++++++++++++++++++++----------- tests/test_get.py | 34 ++++++++------- tests/test_normalize_path.py | 8 ++-- 3 files changed, 80 insertions(+), 45 deletions(-) diff --git a/nestedutils/helpers.py b/nestedutils/helpers.py index 70dd9cb..89461bb 100644 --- a/nestedutils/helpers.py +++ b/nestedutils/helpers.py @@ -4,27 +4,45 @@ from .constants import MAX_DEPTH, MAX_LIST_SIZE -def normalize_path(path: Union[str, List[Any]]) -> List[str]: - """Normalize path to list of strings and validate. +def normalize_path(path: Union[str, List[Any]]) -> List[Union[str, int]]: + """Normalize path to list of keys and validate. - Converts path to a list of string keys, validating that: + Converts path to a list of keys (strings or integers), validating that: - Path is not empty - No keys in the path are empty strings - Path depth does not exceed MAX_DEPTH + For list paths, integer types are preserved to support dictionary keys with integer keys. + For string paths (dot-notation), all keys are strings. + Args: path: Either a dot-notation string (e.g., "a.b.c") or a list of keys. Returns: - List of string keys representing the path. All elements are guaranteed to be strings. + List of keys representing the path. Elements may be strings or integers. + - String paths always return List[str] + - List paths preserve integer types: List[Union[str, int]] Raises: PathError: If path format is invalid, path is empty, contains empty keys, or exceeds maximum depth. """ if isinstance(path, list): - # Convert all list elements to strings - keys = [str(key) for key in path] + # Preserve integer types from list paths to support dict keys with integer keys + keys: List[Union[str, int]] = [] + for key in path: + if isinstance(key, int): + keys.append(key) + elif isinstance(key, str): + if key == "": + raise PathError("Path cannot contain empty keys", PathErrorCode.EMPTY_PATH) + keys.append(key) + else: + # Convert other types to strings (e.g., bool, float) + key_str = str(key) + if key_str == "": + raise PathError("Path cannot contain empty keys", PathErrorCode.EMPTY_PATH) + keys.append(key_str) elif isinstance(path, str): keys = path.split(".") else: @@ -42,38 +60,46 @@ def normalize_path(path: Union[str, List[Any]]) -> List[str]: PathErrorCode.INVALID_PATH ) - if any(key == "" for key in keys): + # Check for empty strings (integers can't be empty) + if any(key == "" for key in keys if isinstance(key, str)): raise PathError("Path cannot contain empty keys", PathErrorCode.EMPTY_PATH) return keys -def is_int_key(key: str) -> bool: +def is_int_key(key: Union[str, int]) -> bool: """Check if a key represents a valid integer index. - STRICT CHECKING: Only accepts string representations of integers. + Accepts both integer types and string representations of integers. Rejects: bool, float, complex, or any other type. Args: - key: String to check. + key: String or integer to check. Returns: - True if key can be parsed as an integer, False otherwise. + True if key is an integer or can be parsed as an integer, False otherwise. Examples: >>> is_int_key("0") True >>> is_int_key("-1") True + >>> is_int_key(0) + True + >>> is_int_key(-1) + True >>> is_int_key("abc") False >>> is_int_key("") False - >>> is_int_key(True) # Not a string + >>> is_int_key(True) # Not int or string False - >>> is_int_key(3.14) # Not a string + >>> is_int_key(3.14) # Not int or string False """ + if isinstance(key, int): + return True + if not isinstance(key, str): return False @@ -87,25 +113,28 @@ def is_int_key(key: str) -> bool: return False -def parse_int_key(key: str) -> int: - """Parse a string key into an integer index. +def parse_int_key(key: Union[str, int]) -> int: + """Parse a key into an integer index. Args: - key: String representation of an index. + key: String representation of an index or an integer. Returns: - Parsed integer index. + Integer index. Raises: - PathError: If key cannot be parsed as an integer. + PathError: If key is a string that cannot be parsed as an integer. """ + if isinstance(key, int): + return key + try: return int(key) except ValueError: raise PathError(f"Invalid list index: '{key}'", PathErrorCode.INVALID_INDEX) -def resolve_read_index(container: Union[list, tuple], key: str) -> Optional[int]: +def resolve_read_index(container: Union[list, tuple], key: Union[str, int]) -> Optional[int]: """Resolve index for read operations (get_at, exists_at). Read operations are graceful - out-of-bounds indices return None @@ -113,7 +142,7 @@ def resolve_read_index(container: Union[list, tuple], key: str) -> Optional[int] Args: container: The list or tuple to index into. - key: String representation of the index. + key: String representation of the index or an integer. Returns: Resolved positive index, or None if out of bounds. @@ -132,7 +161,7 @@ def resolve_read_index(container: Union[list, tuple], key: str) -> Optional[int] return None -def resolve_write_index(container: list, key: str) -> int: +def resolve_write_index(container: list, key: Union[str, int]) -> int: """Resolve index for write operations (set_at). Write operations have strict semantics to prevent sparse lists: @@ -143,7 +172,7 @@ def resolve_write_index(container: list, key: str) -> int: Args: container: The list to index into. - key: String representation of the index. + key: String representation of the index or an integer. Returns: Resolved positive index. @@ -156,6 +185,8 @@ def resolve_write_index(container: list, key: str) -> int: >>> lst = [10, 20, 30] >>> resolve_write_index(lst, "3") # Append 3 + >>> resolve_write_index(lst, 3) # Append (integer) + 3 >>> resolve_write_index(lst, "1") # Modify existing 1 >>> resolve_write_index(lst, "-1") # Modify last @@ -196,15 +227,15 @@ def resolve_write_index(container: list, key: str) -> int: return idx -def create_intermediate_container(next_key: str) -> Union[dict, list]: +def create_intermediate_container(next_key: Union[str, int]) -> Union[dict, list]: """Create intermediate container based on next key type. Logic: - - If next_key is numeric → create list + - If next_key is numeric (int or numeric string) → create list - Otherwise → create dict Args: - next_key: The next key in the path. + next_key: The next key in the path (string or integer). Returns: Empty list or dict. @@ -212,6 +243,8 @@ def create_intermediate_container(next_key: str) -> Union[dict, list]: Examples: >>> create_intermediate_container("0") [] + >>> create_intermediate_container(0) + [] >>> create_intermediate_container("name") {} >>> create_intermediate_container("-1") diff --git a/tests/test_get.py b/tests/test_get.py index 41ced60..8481fb4 100644 --- a/tests/test_get.py +++ b/tests/test_get.py @@ -155,23 +155,25 @@ def test_keys_with_dots_in_list_form(self): assert get_at(d, ["a.b", "c.d"]) == 10 def test_complex_keys_in_list_form(self): - """List form handles any key type: special characters, dots, integers (converted to strings).""" + """List form handles any key type: special characters, dots, integers (preserved or converted).""" # Create data structure with complex keys - # Note: normalize_path converts all list elements to strings, so integer keys become string keys data = { "user-info": { "first.name": { - "123": "found it!" # String key since normalize_path converts to strings + "123": "string_key_value", # String key + 123: "int_key_value" # Integer key } } } # Verify each level works independently - assert get_at(data, ["user-info"]) == {"first.name": {"123": "found it!"}} - assert get_at(data, ["user-info", "first.name"]) == {"123": "found it!"} + assert get_at(data, ["user-info"]) == {"first.name": {"123": "string_key_value", 123: "int_key_value"}} + assert get_at(data, ["user-info", "first.name"]) == {"123": "string_key_value", 123: "int_key_value"} - # List form works - integer 123 in path is converted to string "123" - assert get_at(data, ["user-info", "first.name", 123]) == "found it!" + # Integer in path accesses integer key (preserved) + assert get_at(data, ["user-info", "first.name", 123]) == "int_key_value" + # String in path accesses string key + assert get_at(data, ["user-info", "first.name", "123"]) == "string_key_value" # Test with default for missing path assert get_at(data, ["user-info", "first.name", 999], default="not found") == "not found" @@ -258,25 +260,25 @@ def test_get_from_dict_with_numeric_keys(self): assert get_at(d, "0.1.2") == 5 def test_get_dict_with_integer_vs_string_keys(self): - """Test that normalize_path converts all list elements to strings. + """Test that integer keys in paths are preserved and can access integer dict keys. - Since normalize_path() converts all list elements to strings to ensure - List[str] return type, integer keys in list paths are converted to strings. + normalize_path() now preserves integer types from list paths, allowing + dictionaries with integer keys to be accessed using integer values in paths. """ # Dictionary with both integer key and string key data = {0: "int_value", "0": "string_value"} - # Both integer and string in path are converted to string "0" - # So both access the string key "0" - assert get_at(data, [0]) == "string_value" # Converted to ["0"] - assert get_at(data, ["0"]) == "string_value" # Already string + # Integer in path accesses integer key + assert get_at(data, [0]) == "int_value" # Preserves int, accesses int key + # String in path accesses string key + assert get_at(data, ["0"]) == "string_value" # String accesses string key # Verify direct access still distinguishes assert data[0] == "int_value" assert data["0"] == "string_value" - # List paths can only access string keys now (all converted to strings) - assert get_at(data, [0]) == get_at(data, ["0"]) # Both become "0" + # List paths now distinguish between integer and string keys + assert get_at(data, [0]) != get_at(data, ["0"]) # Different keys, different values def test_get_mixed_types(self): """Get from structure with mixed types.""" diff --git a/tests/test_normalize_path.py b/tests/test_normalize_path.py index e50b913..222a744 100644 --- a/tests/test_normalize_path.py +++ b/tests/test_normalize_path.py @@ -23,10 +23,10 @@ class TestNormalizePathValid: (["a", "b"], ["a", "b"]), (["a", "b", "c"], ["a", "b", "c"]), - # List paths - mixed types (converted to strings) - (["user", 0, "name"], ["user", "0", "name"]), - ([0, 1, 2], ["0", "1", "2"]), - (["items", -1], ["items", "-1"]), + # List paths - integers preserved + (["user", 0, "name"], ["user", 0, "name"]), + ([0, 1, 2], [0, 1, 2]), + (["items", -1], ["items", -1]), ]) def test_valid_paths(self, path, expected): """Test that valid paths are normalized correctly.""" From 5532048660fff5328a8fdadf7f813d0af05d539c Mon Sep 17 00:00:00 2001 From: Siva Sai <26503640+ysskrishna@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:07:26 +0530 Subject: [PATCH 07/17] Siva | cleanup documentation --- README.md | 63 +++++++++++++++++++++++----------------- docs/api-reference.md | 1 - docs/demo.md | 20 +++++-------- nestedutils/access.py | 2 +- tests/test_edge_cases.py | 4 ++- 5 files changed, 47 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 0b0ee36..1f99898 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,7 @@ user_name = get_at(data, "users.0.profile.name") - **Simple Path Syntax**: Use dot-notation strings (`"a.b.c"`) or lists (`["a", "b", "c"]`) to navigate nested structures - **Mixed Data Types**: Seamlessly work with dictionaries, lists, and tuples (read-only for tuples) - **List Index Support**: Access list elements using numeric indices, including negative indices -- **Auto-creation**: Automatically create missing intermediate containers when setting values -- **Flexible Fill Strategies**: Control how missing containers are created with different fill strategies +- **Auto-creation**: Automatically create missing intermediate containers when setting values (with `create=True`) - **Type Safety**: Comprehensive error handling with descriptive error messages and error codes - **Safety Limits**: Built-in protection against excessive nesting (max depth: 100) and oversized lists (max index: 10,000) - **Zero Dependencies**: Pure Python implementation with no external dependencies @@ -64,10 +63,10 @@ from nestedutils import get_at, set_at, delete_at, exists_at data = {} # Set values using dot-notation -set_at(data, "user.name", "John") -set_at(data, "user.age", 30) -set_at(data, "user.hobbies.0", "reading") -set_at(data, "user.hobbies.1", "coding") +set_at(data, "user.name", "John", create=True) +set_at(data, "user.age", 30, create=True) +set_at(data, "user.hobbies.0", "reading", create=True) +set_at(data, "user.hobbies.1", "coding", create=True) # Access values name = get_at(data, "user.name") # "John" @@ -84,7 +83,7 @@ delete_at(data, "user.age") ## API Reference -### `get_at(data, path, default=None)` +### `get_at(data, path, *, default=None)` Retrieve a value from a nested data structure. @@ -92,58 +91,67 @@ Retrieve a value from a nested data structure. - `data`: The data structure to navigate (dict, list, tuple, or nested combinations) - `path`: Path to the value (string with dot notation or list of keys/indices) -- `default`: Value to return if path doesn't exist (default: `None`) +- `default`: Value to return if path doesn't exist (keyword-only parameter, default: `None`) -**Returns:** The value at the path, or `default` if not found +**Returns:** The value at the path, or `default` if provided and path doesn't exist + +**Raises:** `PathError` if the path doesn't exist and `default` is not provided + +**Note:** By default, `get_at` raises `PathError` for missing paths. Use the `default` parameter for optional/nullable access. **Examples:** ```python data = {"a": {"b": {"c": 5}}} get_at(data, "a.b.c") # 5 -get_at(data, "a.b.d", default=99) # 99 +get_at(data, "a.b.d") # Raises PathError (path doesn't exist) +get_at(data, "a.b.d", default=99) # 99 (returns default) data = {"items": [{"name": "apple"}, {"name": "banana"}]} get_at(data, "items.1.name") # "banana" get_at(data, "items.-1.name") # "banana" (negative index) ``` -### `set_at(data, path, value, fill_strategy="auto")` +### `set_at(data, path, value, *, create=False)` -Set a value in a nested data structure, creating intermediate containers as needed. +Set a value in a nested data structure, optionally creating intermediate containers as needed. **Parameters:** - `data`: The data structure to modify (must be mutable: dict or list) - `path`: Path where to set the value (string with dot notation or list of keys/indices) - `value`: The value to set -- `fill_strategy`: How to fill missing containers (default: `"auto"`) - - `"auto"`: Intelligently creates `{}` for dict keys, `[]` for list indices, and `None` for sparse list gaps - - `"none"`: Fills missing list items with `None` - - `"dict"`: Always creates dictionaries - - `"list"`: Always creates lists +- `create`: If `True`, automatically creates missing intermediate containers (default: `False`) -**Note:** Positive indices can extend lists (filling gaps as needed), but negative indices can only modify existing elements. +**Note:** +- By default (`create=False`), `set_at` raises `PathError` if any intermediate key is missing +- With `create=True`, missing containers are automatically created: `{}` for dict keys, `[]` for list indices +- Positive indices can append to lists (index == len(list)) but cannot create gaps (index > len(list)) +- Negative indices can only modify existing elements **Examples:** ```python +# create=True - auto-create missing containers data = {} -set_at(data, "user.profile.name", "Alice") +set_at(data, "user.profile.name", "Alice", create=True) # Creates: {"user": {"profile": {"name": "Alice"}}} data = {} -set_at(data, "items.0.name", "Item 1") +set_at(data, "items.0.name", "Item 1", create=True) # Creates: {"items": [{"name": "Item 1"}]} +# Sequential list appending (no gaps allowed) data = {} -set_at(data, "items.5", "Item 6", fill_strategy="none") -# Creates: {"items": [None, None, None, None, None, "Item 6"]} +set_at(data, "items.0", "first", create=True) # Creates list with first item +set_at(data, "items.1", "second", create=True) # Appends second item +# Creates: {"items": ["first", "second"]} +# Sparse lists are NOT allowed - this raises PathError data = [1, 2, 3] -set_at(data, "5", 99) # Extends list with None gaps -# Creates: [1, 2, 3, None, None, 99] +set_at(data, "5", 99, create=True) # Raises PathError: cannot create gap +# Negative indices - modify existing only data = [1, 2, 3] set_at(data, "-1", 100) # Updates existing last element # Creates: [1, 2, 100] @@ -221,7 +229,8 @@ except PathError as e: - `MISSING_KEY`: Key doesn't exist in dictionary - `EMPTY_PATH`: Path is empty - `IMMUTABLE_CONTAINER`: Attempted to modify a tuple -- `INVALID_FILL_STRATEGY`: Invalid fill strategy value +- `NON_NAVIGABLE_TYPE`: Attempted to navigate into a non-container type +- `OPERATION_DISABLED`: Operation is disabled by configuration (e.g., list deletion without `allow_list_mutation=True`) ## Advanced Usage @@ -231,8 +240,8 @@ List paths are useful when keys contain dots: ```python data = {} -set_at(data, ["user.name", "first"], "John") -set_at(data, ["user.name", "last"], "Doe") +set_at(data, ["user.name", "first"], "John", create=True) +set_at(data, ["user.name", "last"], "Doe", create=True) # Creates: {"user.name": {"first": "John", "last": "Doe"}} ``` diff --git a/docs/api-reference.md b/docs/api-reference.md index e7bccda..e0f50fc 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -48,7 +48,6 @@ keywords: heading_level: 3 members: - PathErrorCode - - FillStrategy members_order: source show_if_no_docstring: true diff --git a/docs/demo.md b/docs/demo.md index 751b0ee..2180681 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -106,15 +106,10 @@ Try the `nestedutils` library directly in your browser! This page uses [Pyodide] placeholder="Value to set" style="flex: 1; min-width: 150px; padding: 8px; border: 1px solid #ccc; border-radius: 4px;" /> - +