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;" /> - - auto - none - dict - list - + + + Create missing containers + - Fill Strategy: auto (smart defaults), none (fill gaps with None), - dict (always create dicts), list (always create lists) + Create missing containers: When checked, automatically creates missing intermediate containers (dicts for string keys, lists for numeric keys). @@ -707,7 +701,7 @@ Try the `nestedutils` library directly in your browser! This page uses [Pyodide] document.getElementById("set-btn").addEventListener("click", async () => { const path = document.getElementById("set-path").value.trim(); const value = document.getElementById("set-value").value.trim(); - const strategy = document.getElementById("set-strategy").value; + const create = document.getElementById("set-create").checked; const resultDiv = document.getElementById("set-result"); if (!path || value === "") { @@ -738,7 +732,7 @@ Try the `nestedutils` library directly in your browser! This page uses [Pyodide] } } - pyodide.runPython(`set_at(data, ${JSON.stringify(path)}, ${JSON.stringify(parsedValue)}, fill_strategy=${JSON.stringify(strategy)})`); + pyodide.runPython(`set_at(data, ${JSON.stringify(path)}, ${JSON.stringify(parsedValue)}, create=${create ? 'True' : 'False'})`); updateDataDisplay(true); resultDiv.innerHTML = `✓ Value set successfully`; } catch (error) { diff --git a/nestedutils/access.py b/nestedutils/access.py index 8b593a0..3591be2 100644 --- a/nestedutils/access.py +++ b/nestedutils/access.py @@ -452,5 +452,5 @@ def delete_at( else: raise PathError( f"Cannot delete from {type(current).__name__}", - PathErrorCode.INVALID_PATH + PathErrorCode.NON_NAVIGABLE_TYPE ) \ No newline at end of file diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index 5836a77..9a4c3a6 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -39,8 +39,9 @@ def test_all_error_codes_exist(self): PathErrorCode.IMMUTABLE_CONTAINER, PathErrorCode.INVALID_PATH, PathErrorCode.NON_NAVIGABLE_TYPE, + PathErrorCode.OPERATION_DISABLED, ] - assert len(codes) == 6 + assert len(codes) == 7 def test_error_code_values(self): """Verify error code string values.""" @@ -50,6 +51,7 @@ def test_error_code_values(self): assert PathErrorCode.IMMUTABLE_CONTAINER.value == "IMMUTABLE_CONTAINER" assert PathErrorCode.INVALID_PATH.value == "INVALID_PATH" assert PathErrorCode.NON_NAVIGABLE_TYPE.value == "NON_NAVIGABLE_TYPE" + assert PathErrorCode.OPERATION_DISABLED.value == "OPERATION_DISABLED" class TestComplexIntegrationScenarios: From 9063746a9128d0ab642773b017bca89dea90985a Mon Sep 17 00:00:00 2001 From: Siva Sai <26503640+ysskrishna@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:16:04 +0530 Subject: [PATCH 08/17] Siva | improve documentation and test cases --- README.md | 8 ++++++ nestedutils/access.py | 8 +++--- tests/test_delete.py | 2 +- tests/test_edge_cases.py | 60 +++++++++++++++++++++++++++++++++++++++- tests/test_get.py | 2 +- tests/test_set.py | 2 +- 6 files changed, 74 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 1f99898..021af96 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,12 @@ user_name = get_at(data, "users.0.profile.name") - **Configuration Management**: Easily read and modify deeply nested settings in configuration dictionaries. - **Data Transformation**: Rapidly remap data from one complex structure to another using `get_at` and `set_at`. +## Terminology + +- **Path**: A navigation string or list that specifies a location in nested data (e.g., `"user.profile.name"` or `["user", "profile", "name"]`) +- **Key**: An individual dictionary key used to access a value (e.g., `"name"`, `"profile"`) +- **Index**: A numeric position in a list or tuple (e.g., `0`, `-1` for last element) + ## Installation ```bash @@ -193,6 +199,8 @@ Delete a value from a nested data structure. - `path`: Path to the value to delete - `allow_list_mutation`: If `True`, allows deletion from lists (default: `False`) +**Note:** List deletion is disabled by default to prevent accidental index shifting that could break subsequent code. When you delete an element from a list, all following indices shift down, which can cause unexpected behavior if other parts of your code reference those indices. + **Returns:** The deleted value **Raises:** `PathError` if the path doesn't exist or deletion is not allowed diff --git a/nestedutils/access.py b/nestedutils/access.py index 3591be2..dc6ff70 100644 --- a/nestedutils/access.py +++ b/nestedutils/access.py @@ -65,7 +65,7 @@ def get_at(data: Any, path: Union[str, List[Any]], *, default: Any = _MISSING) - if default is not _MISSING: return default raise PathError( - f"Key '{key}' not found in path", + f"Key '{key}' not found", PathErrorCode.MISSING_KEY ) current = current[key] @@ -145,7 +145,7 @@ def exists_at(data: Any, path: Union[str, List[Any]]) -> bool: ``` """ try: - get_at(data, path) # Uses strict mode internally + get_at(data, path) # No default provided, so raises on missing path return True except PathError as e: # Return False for "not found" errors or navigation into non-navigable types @@ -385,7 +385,7 @@ def delete_at( if isinstance(current, dict): if key not in current: raise PathError( - f"Key not found: '{key}'", + f"Key '{key}' not found", PathErrorCode.MISSING_KEY ) current = current[key] @@ -417,7 +417,7 @@ def delete_at( if isinstance(current, dict): if final_key not in current: raise PathError( - f"Key not found: '{final_key}'", + f"Key '{final_key}' not found", PathErrorCode.MISSING_KEY ) return current.pop(final_key) diff --git a/tests/test_delete.py b/tests/test_delete.py index 0d7cad1..2c66299 100644 --- a/tests/test_delete.py +++ b/tests/test_delete.py @@ -38,7 +38,7 @@ def test_delete_from_nested_list_dict(self): class TestDeleteListOperations: """Tests for deleting from lists.""" - def test_delete_list_index_disallowed(self): + def test_delete_list_requires_allow_list_mutation_flag(self): """Delete list index without allow_list_mutation should fail.""" d = {"a": [1, 2, 3]} with pytest.raises(PathError) as exc_info: diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index 9a4c3a6..34b6850 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -102,7 +102,7 @@ def test_many_keys_in_dict(self): assert get_at(d, f"key{i}") == i -class TestTypeCoercion: +class TestListPathTypeHandling: """Tests for type coercion and conversion.""" def test_list_path_with_string_numbers(self): @@ -166,3 +166,61 @@ def test_negative_index_after_list_mutation(self): with pytest.raises(PathError): get_at(d, "items.-5") + +class TestMissingEdgeCases: + """Additional edge cases for comprehensive coverage.""" + + def test_get_empty_path_raises(self): + """Empty path should raise EMPTY_PATH error.""" + with pytest.raises(PathError) as exc: + get_at({}, "") + assert exc.value.code == PathErrorCode.EMPTY_PATH + + def test_set_empty_path_raises(self): + """Empty path should raise EMPTY_PATH error for set_at.""" + with pytest.raises(PathError) as exc: + set_at({}, "", 1) + assert exc.value.code == PathErrorCode.EMPTY_PATH + + def test_delete_empty_path_raises(self): + """Empty path should raise EMPTY_PATH error for delete_at.""" + with pytest.raises(PathError) as exc: + delete_at({}, "") + assert exc.value.code == PathErrorCode.EMPTY_PATH + + def test_set_non_container_root_raises(self): + """Setting on non-container root should raise NON_NAVIGABLE_TYPE.""" + with pytest.raises(PathError) as exc: + set_at(42, "a", 1) + assert exc.value.code == PathErrorCode.NON_NAVIGABLE_TYPE + + def test_get_non_container_root_raises(self): + """Getting from non-container root should raise NON_NAVIGABLE_TYPE.""" + with pytest.raises(PathError) as exc: + get_at(42, "a") + assert exc.value.code == PathErrorCode.NON_NAVIGABLE_TYPE + + def test_delete_non_container_root_raises(self): + """Deleting from non-container root should raise NON_NAVIGABLE_TYPE.""" + with pytest.raises(PathError) as exc: + delete_at(42, "a") + assert exc.value.code == PathErrorCode.NON_NAVIGABLE_TYPE + + def test_get_from_set_raises(self): + """Accessing set elements should raise NON_NAVIGABLE_TYPE.""" + with pytest.raises(PathError) as exc: + get_at({"a": {1, 2, 3}}, "a.0") + assert exc.value.code == PathErrorCode.NON_NAVIGABLE_TYPE + + def test_get_from_frozenset_raises(self): + """Accessing frozenset elements should raise NON_NAVIGABLE_TYPE.""" + with pytest.raises(PathError) as exc: + get_at({"a": frozenset([1, 2, 3])}, "a.0") + assert exc.value.code == PathErrorCode.NON_NAVIGABLE_TYPE + + def test_set_into_string_raises(self): + """Setting into a string should raise NON_NAVIGABLE_TYPE.""" + with pytest.raises(PathError) as exc: + set_at({"a": "hello"}, "a.0", "x") + assert exc.value.code == PathErrorCode.NON_NAVIGABLE_TYPE + diff --git a/tests/test_get.py b/tests/test_get.py index 8481fb4..2bdcb6b 100644 --- a/tests/test_get.py +++ b/tests/test_get.py @@ -154,7 +154,7 @@ def test_keys_with_dots_in_list_form(self): d = {"a.b": {"c.d": 10}} assert get_at(d, ["a.b", "c.d"]) == 10 - def test_complex_keys_in_list_form(self): + def test_integer_vs_string_key_distinction(self): """List form handles any key type: special characters, dots, integers (preserved or converted).""" # Create data structure with complex keys data = { diff --git a/tests/test_set.py b/tests/test_set.py index 869993d..6a55dfc 100644 --- a/tests/test_set.py +++ b/tests/test_set.py @@ -234,7 +234,7 @@ def test_set_negative_index_on_empty_dict(self): set_at(d, "a.-1", 5, create=True) assert exc_info.value.code == PathErrorCode.INVALID_INDEX - def test_numeric_key_on_existing_dict(self): + def test_set_numeric_string_creates_dict_key_not_list(self): """Setting numeric key on existing dict creates string key.""" d = {"a": {"b": 1}} set_at(d, "a.0", 5) From 46805a066e998b19db74cd3c250fd60947e02264 Mon Sep 17 00:00:00 2001 From: Siva Sai <26503640+ysskrishna@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:19:19 +0530 Subject: [PATCH 09/17] Siva | fix test case --- nestedutils/__init__.py | 6 +++--- nestedutils/access.py | 8 ++++---- nestedutils/exceptions.py | 2 +- nestedutils/helpers.py | 6 +++--- tests/test_delete.py | 6 +++--- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/nestedutils/__init__.py b/nestedutils/__init__.py index 921c432..8db1f1e 100644 --- a/nestedutils/__init__.py +++ b/nestedutils/__init__.py @@ -1,5 +1,5 @@ -from .access import get_at, set_at, delete_at, exists_at -from .exceptions import PathError -from .enums import PathErrorCode +from nestedutils.access import get_at, set_at, delete_at, exists_at +from nestedutils.exceptions import PathError +from nestedutils.enums import PathErrorCode __all__ = ["get_at", "set_at", "delete_at", "exists_at", "PathError", "PathErrorCode"] \ No newline at end of file diff --git a/nestedutils/access.py b/nestedutils/access.py index dc6ff70..d4c7e32 100644 --- a/nestedutils/access.py +++ b/nestedutils/access.py @@ -1,8 +1,8 @@ from typing import Any, List, Union -from .exceptions import PathError -from .enums import PathErrorCode -from .helpers import normalize_path, is_int_key, resolve_write_index, resolve_read_index -from .helpers import create_intermediate_container +from nestedutils.exceptions import PathError +from nestedutils.enums import PathErrorCode +from nestedutils.helpers import normalize_path, is_int_key, resolve_write_index, resolve_read_index +from nestedutils.helpers import create_intermediate_container _MISSING = object() diff --git a/nestedutils/exceptions.py b/nestedutils/exceptions.py index dc7058d..a5f5cd9 100644 --- a/nestedutils/exceptions.py +++ b/nestedutils/exceptions.py @@ -6,7 +6,7 @@ """ from typing import Optional -from .enums import PathErrorCode +from nestedutils.enums import PathErrorCode class PathError(Exception): diff --git a/nestedutils/helpers.py b/nestedutils/helpers.py index 89461bb..c07f77d 100644 --- a/nestedutils/helpers.py +++ b/nestedutils/helpers.py @@ -1,7 +1,7 @@ from typing import Any, List, Union, Optional -from .exceptions import PathError -from .enums import PathErrorCode -from .constants import MAX_DEPTH, MAX_LIST_SIZE +from nestedutils.exceptions import PathError +from nestedutils.enums import PathErrorCode +from nestedutils.constants import MAX_DEPTH, MAX_LIST_SIZE def normalize_path(path: Union[str, List[Any]]) -> List[Union[str, int]]: diff --git a/tests/test_delete.py b/tests/test_delete.py index 2c66299..0554390 100644 --- a/tests/test_delete.py +++ b/tests/test_delete.py @@ -148,14 +148,14 @@ def test_delete_path_invalid_type(self): d = {"a": 5} with pytest.raises(PathError) as exc_info: delete_at(d, "a.b") - assert exc_info.value.code == PathErrorCode.INVALID_PATH - + assert exc_info.value.code == PathErrorCode.NON_NAVIGABLE_TYPE + def test_delete_from_none(self): """Delete from None should fail.""" d = {"a": None} with pytest.raises(PathError) as exc_info: delete_at(d, "a.b") - assert exc_info.value.code == PathErrorCode.INVALID_PATH + assert exc_info.value.code == PathErrorCode.NON_NAVIGABLE_TYPE def test_delete_from_tuple(self): """Delete from tuple should fail (immutable).""" From 269032fddec5821a044c543da9faa15c59372dcd Mon Sep 17 00:00:00 2001 From: Siva Sai <26503640+ysskrishna@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:20:23 +0530 Subject: [PATCH 10/17] Siva | fix fragile test assertion --- tests/test_set.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_set.py b/tests/test_set.py index 6a55dfc..be2762b 100644 --- a/tests/test_set.py +++ b/tests/test_set.py @@ -43,7 +43,6 @@ def test_set_no_sparse_lists(self): 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.""" From 4d454d85d7cf9361e33f6704e2268a0bac4c432c Mon Sep 17 00:00:00 2001 From: Siva Sai <26503640+ysskrishna@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:39:50 +0530 Subject: [PATCH 11/17] Siva | refactor code --- nestedutils/access.py | 250 ++++---------------------- nestedutils/helpers.py | 393 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 426 insertions(+), 217 deletions(-) diff --git a/nestedutils/access.py b/nestedutils/access.py index d4c7e32..d0df2fa 100644 --- a/nestedutils/access.py +++ b/nestedutils/access.py @@ -1,14 +1,18 @@ from typing import Any, List, Union from nestedutils.exceptions import PathError from nestedutils.enums import PathErrorCode -from nestedutils.helpers import normalize_path, is_int_key, resolve_write_index, resolve_read_index -from nestedutils.helpers import create_intermediate_container +from nestedutils.helpers import ( + normalize_path, + navigate_one_step, + navigate_to_parent, + set_final_value, + navigate_to_parent_for_delete, + delete_from_container, + MISSING, +) -_MISSING = object() - - -def get_at(data: Any, path: Union[str, List[Any]], *, default: Any = _MISSING) -> Any: +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 @@ -60,43 +64,14 @@ def get_at(data: Any, path: Union[str, List[Any]], *, default: Any = _MISSING) - current = data for key in keys: - if isinstance(current, dict): - if key not in current: - if default is not _MISSING: - return default - raise PathError( - f"Key '{key}' not found", - 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.NON_NAVIGABLE_TYPE - ) + current = navigate_one_step( + current, + key, + default=default, + raise_on_missing=(default is MISSING) + ) + # If default was used, navigate_one_step returns default + # If navigation failed and default is MISSING, it raises PathError return current @@ -230,107 +205,16 @@ def set_at( ``` """ keys = normalize_path(path) - current = data - - # Navigate intermediate keys - for i, key in enumerate(keys[:-1]): - next_key = keys[i + 1] - - if isinstance(current, dict): - 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 for list, got '{key}'", - PathErrorCode.INVALID_INDEX - ) - - 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 container)", - PathErrorCode.IMMUTABLE_CONTAINER - ) - - else: - raise PathError( - f"Cannot navigate into {type(current).__name__}", - PathErrorCode.NON_NAVIGABLE_TYPE - ) - - # Set final value - final_key = keys[-1] - - if isinstance(current, dict): - current[final_key] = value - - elif isinstance(current, list): - if not is_int_key(final_key): - raise PathError( - f"Expected numeric index for list, got '{final_key}'", - PathErrorCode.INVALID_INDEX - ) - - idx = resolve_write_index(current, final_key) - - # 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 container)", - PathErrorCode.IMMUTABLE_CONTAINER - ) + if len(keys) == 1: + # Single key - set directly on root + set_final_value(data, keys[0], value, create=create) else: - raise PathError( - f"Cannot set value in {type(current).__name__}", - PathErrorCode.NON_NAVIGABLE_TYPE - ) + # Navigate to parent, then set final value + intermediate_keys = keys[:-1] + final_key = keys[-1] + parent = navigate_to_parent(data, intermediate_keys, final_key, create=create) + set_final_value(parent, final_key, value, create=create) def delete_at( @@ -378,79 +262,13 @@ def delete_at( ``` """ keys = normalize_path(path) - current = data - - # Navigate to parent - for key in keys[:-1]: - if isinstance(current, dict): - if key not in current: - raise PathError( - f"Key '{key}' not found", - PathErrorCode.MISSING_KEY - ) - current = current[key] - - elif isinstance(current, (list, tuple)): - if not is_int_key(key): - raise PathError( - f"Expected numeric index, got '{key}'", - PathErrorCode.INVALID_INDEX - ) - - 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: - raise PathError( - f"Cannot navigate through {type(current).__name__}", - PathErrorCode.NON_NAVIGABLE_TYPE - ) - - # Delete final key - final_key = keys[-1] - - if isinstance(current, dict): - if final_key not in current: - raise PathError( - f"Key '{final_key}' not found", - PathErrorCode.MISSING_KEY - ) - return current.pop(final_key) - - elif isinstance(current, list): - if not allow_list_mutation: - raise PathError( - "List deletion disabled. Set allow_list_mutation=True", - PathErrorCode.OPERATION_DISABLED - ) - - if not is_int_key(final_key): - raise PathError( - f"Expected numeric index, got '{final_key}'", - PathErrorCode.INVALID_INDEX - ) - - 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): - raise PathError( - "Cannot delete from tuple (immutable)", - PathErrorCode.IMMUTABLE_CONTAINER - ) + if len(keys) == 1: + # Single key - delete directly from root + return delete_from_container(data, keys[0], allow_list_mutation=allow_list_mutation) else: - raise PathError( - f"Cannot delete from {type(current).__name__}", - PathErrorCode.NON_NAVIGABLE_TYPE - ) \ No newline at end of file + # Navigate to parent, then delete final key + intermediate_keys = keys[:-1] + final_key = keys[-1] + parent = navigate_to_parent_for_delete(data, intermediate_keys) + return delete_from_container(parent, final_key, allow_list_mutation=allow_list_mutation) \ No newline at end of file diff --git a/nestedutils/helpers.py b/nestedutils/helpers.py index c07f77d..3f61968 100644 --- a/nestedutils/helpers.py +++ b/nestedutils/helpers.py @@ -250,4 +250,395 @@ def create_intermediate_container(next_key: Union[str, int]) -> Union[dict, list >>> create_intermediate_container("-1") [] """ - return [] if is_int_key(next_key) else {} \ No newline at end of file + return [] if is_int_key(next_key) else {} + + +# Navigation helpers for access.py + +MISSING = object() + + +def navigate_dict_key( + current: dict, + key: Union[str, int], + *, + default: Any = MISSING, + raise_on_missing: bool = True +) -> Any: + """Navigate into a dictionary using a key. + + Args: + current: The dictionary to navigate into. + key: The key to access. + default: Value to return if key is missing (when not MISSING). + raise_on_missing: Whether to raise PathError on missing key. + + Returns: + The value at the key, or default if provided and key is missing. + + Raises: + PathError: If key is missing and raise_on_missing is True and default is MISSING. + """ + if key not in current: + if default is not MISSING: + return default + if raise_on_missing: + raise PathError( + f"Key '{key}' not found", + PathErrorCode.MISSING_KEY + ) + return None + return current[key] + + +def navigate_sequence_index( + current: Union[list, tuple], + key: Union[str, int], + *, + default: Any = MISSING, + raise_on_missing: bool = True +) -> Any: + """Navigate into a list or tuple using an index. + + Args: + current: The list or tuple to navigate into. + key: The index to access. + default: Value to return if index is out of bounds (when not MISSING). + raise_on_missing: Whether to raise PathError on out-of-bounds index. + + Returns: + The value at the index, or default if provided and index is out of bounds. + + Raises: + PathError: If key is not a valid integer, or index is out of bounds and + raise_on_missing is True and default is MISSING. + """ + if not is_int_key(key): + if default is not MISSING: + return default + if raise_on_missing: + raise PathError( + f"Expected numeric index, got '{key}'", + PathErrorCode.INVALID_INDEX + ) + return None + + idx = resolve_read_index(current, key) + if idx is None: + if default is not MISSING: + return default + if raise_on_missing: + raise PathError( + f"Index '{key}' out of bounds in path", + PathErrorCode.INVALID_INDEX + ) + return None + + return current[idx] + + +def navigate_one_step( + current: Any, + key: Union[str, int], + *, + default: Any = MISSING, + raise_on_missing: bool = True +) -> Any: + """Navigate one step into a nested structure. + + Handles dict, list, tuple, and other types. + + Args: + current: The current value in the nested structure. + key: The key or index to navigate with. + default: Value to return if navigation fails (when not MISSING). + raise_on_missing: Whether to raise PathError on navigation failure. + + Returns: + The next value in the navigation path, or default if provided and navigation fails. + + Raises: + PathError: If navigation fails and raise_on_missing is True and default is MISSING. + """ + if isinstance(current, dict): + return navigate_dict_key(current, key, default=default, raise_on_missing=raise_on_missing) + + elif isinstance(current, (list, tuple)): + return navigate_sequence_index(current, key, default=default, raise_on_missing=raise_on_missing) + + else: + if default is not MISSING: + return default + if raise_on_missing: + raise PathError( + f"Cannot navigate into {type(current).__name__} at '{key}'", + PathErrorCode.NON_NAVIGABLE_TYPE + ) + return None + + +def navigate_to_parent( + data: Any, + intermediate_keys: List[Union[str, int]], + final_key: Union[str, int], + *, + create: bool = False +) -> Any: + """Navigate to the parent container of the final key. + + For intermediate keys, creates containers if create=True. When create=True, + this function will also replace None values with appropriate containers (dict + or list) based on the next key type, allowing navigation through None values. + + Args: + data: The root data structure. + intermediate_keys: List of intermediate keys to navigate through. + final_key: The final key (used to determine container type for last intermediate). + create: If True, automatically create missing intermediate containers and + replace None values with containers. + + Returns: + The parent container of the final key. + + Raises: + PathError: If path doesn't exist and create=False, or if attempting + to modify tuple, or other navigation errors. + """ + current = data + + # Navigate intermediate keys + for i, key in enumerate(intermediate_keys): + next_key = intermediate_keys[i + 1] if i + 1 < len(intermediate_keys) else final_key + + if isinstance(current, dict): + 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 for list, got '{key}'", + PathErrorCode.INVALID_INDEX + ) + + 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 container)", + PathErrorCode.IMMUTABLE_CONTAINER + ) + + else: + raise PathError( + f"Cannot navigate into {type(current).__name__}", + PathErrorCode.NON_NAVIGABLE_TYPE + ) + + return current + + +def set_final_value( + parent: Union[dict, list], + key: Union[str, int], + value: Any, + *, + create: bool = False +) -> None: + """Set the final value at the given key in parent container. + + Handles dict assignment and list append/modify logic. + + Args: + parent: The parent container (dict or list). + key: The key or index to set. + value: The value to set. + create: If True, allow appending to lists. + + Raises: + PathError: If setting fails (e.g., out of bounds, immutable container). + """ + if isinstance(parent, dict): + parent[key] = value + + elif isinstance(parent, list): + if not is_int_key(key): + raise PathError( + f"Expected numeric index for list, got '{key}'", + PathErrorCode.INVALID_INDEX + ) + + idx = resolve_write_index(parent, key) + + # Extend list if appending + if idx == len(parent): + if not create: + raise PathError( + f"Index {idx} does not exist. Use create=True to append.", + PathErrorCode.INVALID_INDEX + ) + parent.append(value) + else: + parent[idx] = value + + elif isinstance(parent, tuple): + raise PathError( + "Cannot modify tuple (immutable container)", + PathErrorCode.IMMUTABLE_CONTAINER + ) + + else: + raise PathError( + f"Cannot set value in {type(parent).__name__}", + PathErrorCode.NON_NAVIGABLE_TYPE + ) + + +def navigate_to_parent_for_delete( + data: Any, + keys: List[Union[str, int]] +) -> Any: + """Navigate to the parent container of the final key for deletion. + + Raises PathError if any intermediate key is missing. + + Args: + data: The root data structure. + keys: List of keys to navigate through (excluding final key). + + Returns: + The parent container of the final key. + + Raises: + PathError: If any intermediate key is missing or navigation fails. + """ + current = data + + for key in keys: + if isinstance(current, dict): + if key not in current: + raise PathError( + f"Key '{key}' not found", + PathErrorCode.MISSING_KEY + ) + current = current[key] + + elif isinstance(current, (list, tuple)): + if not is_int_key(key): + raise PathError( + f"Expected numeric index, got '{key}'", + PathErrorCode.INVALID_INDEX + ) + + 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: + raise PathError( + f"Cannot navigate through {type(current).__name__}", + PathErrorCode.NON_NAVIGABLE_TYPE + ) + + return current + + +def delete_from_container( + parent: Union[dict, list], + key: Union[str, int], + *, + allow_list_mutation: bool = False +) -> Any: + """Delete and return value from parent container. + + Handles dict.pop() and list.pop() with validation. + + Args: + parent: The parent container (dict or list). + key: The key or index to delete. + allow_list_mutation: If True, allows deletion from lists. + + Returns: + The deleted value. + + Raises: + PathError: If deletion fails (e.g., key not found, immutable container, + list mutation disabled). + """ + if isinstance(parent, dict): + if key not in parent: + raise PathError( + f"Key '{key}' not found", + PathErrorCode.MISSING_KEY + ) + return parent.pop(key) + + elif isinstance(parent, list): + if not allow_list_mutation: + raise PathError( + "List deletion disabled. Set allow_list_mutation=True", + PathErrorCode.OPERATION_DISABLED + ) + + if not is_int_key(key): + raise PathError( + f"Expected numeric index, got '{key}'", + PathErrorCode.INVALID_INDEX + ) + + idx = resolve_read_index(parent, key) + if idx is None: + raise PathError( + f"Index '{key}' out of bounds", + PathErrorCode.INVALID_INDEX + ) + return parent.pop(idx) + + elif isinstance(parent, tuple): + raise PathError( + "Cannot delete from tuple (immutable)", + PathErrorCode.IMMUTABLE_CONTAINER + ) + + else: + raise PathError( + f"Cannot delete from {type(parent).__name__}", + PathErrorCode.NON_NAVIGABLE_TYPE + ) \ No newline at end of file From d9ece5006326739e831ae3161e64285d3cf118ac Mon Sep 17 00:00:00 2001 From: Siva Sai <26503640+ysskrishna@users.noreply.github.com> Date: Sun, 8 Feb 2026 09:16:54 +0530 Subject: [PATCH 12/17] Siva | minor cleanup of comments --- nestedutils/access.py | 8 ++------ nestedutils/helpers.py | 2 -- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/nestedutils/access.py b/nestedutils/access.py index d0df2fa..f2bf946 100644 --- a/nestedutils/access.py +++ b/nestedutils/access.py @@ -17,10 +17,8 @@ def get_at(data: Any, path: Union[str, List[Any]], *, default: Any = MISSING) -> Navigates through nested dictionaries, lists, and tuples using a path specified as either 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. + does not exist. Use the `default` parameter to return a value instead of raising. + Supports negative indexing for lists and tuples. Args: data: The data structure to navigate (dict, list, tuple, or nested combinations). @@ -70,8 +68,6 @@ def get_at(data: Any, path: Union[str, List[Any]], *, default: Any = MISSING) -> default=default, raise_on_missing=(default is MISSING) ) - # If default was used, navigate_one_step returns default - # If navigation failed and default is MISSING, it raises PathError return current diff --git a/nestedutils/helpers.py b/nestedutils/helpers.py index 3f61968..8da8e49 100644 --- a/nestedutils/helpers.py +++ b/nestedutils/helpers.py @@ -253,8 +253,6 @@ def create_intermediate_container(next_key: Union[str, int]) -> Union[dict, list return [] if is_int_key(next_key) else {} -# Navigation helpers for access.py - MISSING = object() From 577804c5d33337e685f8b5eb02f702e56545c8a7 Mon Sep 17 00:00:00 2001 From: Siva Sai <26503640+ysskrishna@users.noreply.github.com> Date: Sun, 8 Feb 2026 09:38:30 +0530 Subject: [PATCH 13/17] Siva | add instrospection functions --- nestedutils/__init__.py | 18 ++++- nestedutils/introspection.py | 126 +++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 nestedutils/introspection.py diff --git a/nestedutils/__init__.py b/nestedutils/__init__.py index 8db1f1e..c515c4b 100644 --- a/nestedutils/__init__.py +++ b/nestedutils/__init__.py @@ -1,5 +1,21 @@ from nestedutils.access import get_at, set_at, delete_at, exists_at +from nestedutils.introspection import get_depth, count_leaves, get_all_paths from nestedutils.exceptions import PathError from nestedutils.enums import PathErrorCode -__all__ = ["get_at", "set_at", "delete_at", "exists_at", "PathError", "PathErrorCode"] \ No newline at end of file +__all__ = [ + # Access + "get_at", + "set_at", + "delete_at", + "exists_at", + + # Introspection + "get_depth", + "count_leaves", + "get_all_paths", + + # Misc + "PathError", + "PathErrorCode", +] \ No newline at end of file diff --git a/nestedutils/introspection.py b/nestedutils/introspection.py new file mode 100644 index 0000000..5aa3fc9 --- /dev/null +++ b/nestedutils/introspection.py @@ -0,0 +1,126 @@ +"""Introspection utilities for nested data structures. + +This module provides functions to inspect and analyze nested data structures +without modifying them. All functions are pure and support dict, list, and tuple. +Other container types (set, frozenset, deque, etc.) are treated as leaf values. +""" +from typing import Any, List, Union + + +def get_depth(data: Any) -> int: + """Return the maximum nesting depth of a nested structure. + + Supports dict, list, and tuple. Other types are treated as leaves (depth 0). + + Args: + data: Any nested structure (dict, list, tuple, or primitive). + + Returns: + Integer depth. Primitives return 0, empty containers return 1. + + Examples: + >>> get_depth(42) + 0 + >>> get_depth({}) + 1 + >>> get_depth({"a": 1}) + 1 + >>> get_depth({"a": {"b": 1}}) + 2 + >>> get_depth({"a": {"b": {"c": 1}}}) + 3 + >>> get_depth([1, [2, [3]]]) + 3 + """ + if isinstance(data, dict): + if not data: + return 1 + return 1 + max(get_depth(v) for v in data.values()) + + elif isinstance(data, (list, tuple)): + if not data: + return 1 + return 1 + max(get_depth(item) for item in data) + + return 0 + + +def count_leaves(data: Any) -> int: + """Count the total number of leaf values in a nested structure. + + A leaf is any value that is not a dict, list, or tuple. + Empty containers count as 0 leaves. + + Supports dict, list, and tuple. Other container types (set, frozenset, etc.) + are treated as single leaf values. + + Args: + data: Any nested structure. + + Returns: + Integer count of leaf values. + + Examples: + >>> count_leaves(42) + 1 + >>> count_leaves({}) + 0 + >>> count_leaves({"a": 1, "b": 2}) + 2 + >>> count_leaves({"a": {"b": 1, "c": 2}, "d": 3}) + 3 + >>> count_leaves([1, 2, [3, 4]]) + 4 + """ + if isinstance(data, dict): + return sum(count_leaves(v) for v in data.values()) + + elif isinstance(data, (list, tuple)): + return sum(count_leaves(item) for item in data) + + return 1 + + +def get_all_paths(data: Any) -> List[List[Union[str, int]]]: + """Return all paths to leaf values in a nested structure. + + Supports dict, list, and tuple. Other container types are treated as leaves. + + Args: + data: Any nested structure. + + Returns: + List of paths, where each path is a list of keys/indices. + + Examples: + >>> get_all_paths({"a": 1, "b": 2}) + [["a"], ["b"]] + >>> get_all_paths({"a": {"b": 1, "c": 2}}) + [["a", "b"], ["a", "c"]] + >>> get_all_paths({"users": [{"name": "Alice"}, {"name": "Bob"}]}) + [["users", 0, "name"], ["users", 1, "name"]] + >>> get_all_paths({}) + [] + >>> get_all_paths(42) + [[]] + """ + def recurse(current: Any, prefix: List[Union[str, int]]) -> List[List[Union[str, int]]]: + if isinstance(current, dict): + if not current: + return [] + paths = [] + for key, value in current.items(): + paths.extend(recurse(value, prefix + [key])) + return paths + + elif isinstance(current, (list, tuple)): + if not current: + return [] + paths = [] + for idx, item in enumerate(current): + paths.extend(recurse(item, prefix + [idx])) + return paths + + return [prefix] + + return recurse(data, []) From 0432331b56175b19f466ec17338f8db54c066693 Mon Sep 17 00:00:00 2001 From: Siva Sai <26503640+ysskrishna@users.noreply.github.com> Date: Sun, 8 Feb 2026 09:42:49 +0530 Subject: [PATCH 14/17] Siva | add test cases and update documentation --- README.md | 76 +++++++++++- docs/api-reference.md | 15 ++- tests/test_introspection.py | 229 ++++++++++++++++++++++++++++++++++++ 3 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 tests/test_introspection.py diff --git a/README.md b/README.md index 021af96..8f67bf1 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ user_name = get_at(data, "users.0.profile.name") - **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 (with `create=True`) +- **Introspection**: Analyze nested structures with `get_depth`, `count_leaves`, and `get_all_paths` - **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 @@ -63,7 +64,7 @@ pip install nestedutils ## Quick Start ```python -from nestedutils import get_at, set_at, delete_at, exists_at +from nestedutils import get_at, set_at, delete_at, exists_at, get_depth, count_leaves, get_all_paths # Create a nested structure data = {} @@ -216,6 +217,79 @@ delete_at(data, "items.1", allow_list_mutation=True) # Returns 2 # data becomes {"items": [1, 3]} ``` +### `get_depth(data)` + +Get the maximum nesting depth of a data structure. + +**Parameters:** + +- `data`: Any nested structure (dict, list, tuple, or primitive) + +**Returns:** Integer depth. Primitives return 0, empty containers return 1. + +**Note:** Only dict, list, and tuple are traversed. Other container types (set, frozenset, etc.) are treated as leaf values. + +**Examples:** + +```python +get_depth(42) # 0 (primitive) +get_depth({}) # 1 (empty container) +get_depth({"a": 1}) # 1 (flat dict) +get_depth({"a": {"b": 1}}) # 2 (nested) +get_depth({"a": {"b": {"c": 1}}}) # 3 (deeper nesting) +get_depth([1, [2, [3]]]) # 3 (nested lists) +``` + +### `count_leaves(data)` + +Count the total number of leaf values (non-container values) in a nested structure. + +**Parameters:** + +- `data`: Any nested structure + +**Returns:** Integer count of leaf values. Empty containers return 0. + +**Note:** Only dict, list, and tuple are traversed. Other container types (set, frozenset, etc.) count as a single leaf. + +**Examples:** + +```python +count_leaves(42) # 1 (primitive is a leaf) +count_leaves({}) # 0 (empty container) +count_leaves({"a": 1, "b": 2}) # 2 (two leaf values) +count_leaves({"a": {"b": 1, "c": 2}}) # 2 (nested, still 2 leaves) +count_leaves([1, 2, [3, 4]]) # 4 (four leaf values) +``` + +### `get_all_paths(data)` + +Get all paths to leaf values in a nested structure. + +**Parameters:** + +- `data`: Any nested structure + +**Returns:** List of paths, where each path is a list of keys (strings) and indices (integers). + +**Note:** Only dict, list, and tuple are traversed. Other container types are treated as leaves. + +**Examples:** + +```python +get_all_paths({"a": 1, "b": 2}) +# [["a"], ["b"]] + +get_all_paths({"a": {"b": 1, "c": 2}}) +# [["a", "b"], ["a", "c"]] + +get_all_paths({"users": [{"name": "Alice"}, {"name": "Bob"}]}) +# [["users", 0, "name"], ["users", 1, "name"]] + +get_all_paths({}) # [] (no leaves) +get_all_paths(42) # [[]] (primitive has empty path) +``` + ## Error Handling The library uses `PathError` exceptions with error codes for different failure scenarios: diff --git a/docs/api-reference.md b/docs/api-reference.md index e0f50fc..42a0da0 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1,7 +1,7 @@ --- title: API Reference -description: "Complete API reference for nestedutils. Documentation for get_at, set_at, delete_at, and exists_at functions with parameters, return values, examples, and error handling." +description: "Complete API reference for nestedutils. Documentation for get_at, set_at, delete_at, exists_at, get_depth, count_leaves, and get_all_paths functions with parameters, return values, examples, and error handling." keywords: - nestedutils API @@ -10,6 +10,9 @@ keywords: - set_at - delete_at - exists_at + - get_depth + - count_leaves + - get_all_paths - function documentation - nested data functions - Python API @@ -31,6 +34,16 @@ keywords: - exists_at heading_level: 3 +::: nestedutils.introspection + options: + show_root_heading: true + show_root_toc_entry: false + members: + - get_depth + - count_leaves + - get_all_paths + heading_level: 3 + ::: nestedutils.constants options: show_root_heading: true diff --git a/tests/test_introspection.py b/tests/test_introspection.py new file mode 100644 index 0000000..5608a5a --- /dev/null +++ b/tests/test_introspection.py @@ -0,0 +1,229 @@ +import pytest +from nestedutils import get_depth, count_leaves, get_all_paths + + +class TestGetDepth: + """Tests for get_depth function.""" + + def test_primitive_values(self): + """Primitives have depth 0.""" + assert get_depth(42) == 0 + assert get_depth("string") == 0 + assert get_depth(3.14) == 0 + assert get_depth(True) == 0 + assert get_depth(None) == 0 + + def test_empty_containers(self): + """Empty containers have depth 1.""" + assert get_depth({}) == 1 + assert get_depth([]) == 1 + assert get_depth(()) == 1 + + def test_flat_dict(self): + """Flat dict with primitives has depth 1.""" + assert get_depth({"a": 1}) == 1 + assert get_depth({"a": 1, "b": 2, "c": 3}) == 1 + + def test_flat_list(self): + """Flat list with primitives has depth 1.""" + assert get_depth([1, 2, 3]) == 1 + assert get_depth([1]) == 1 + + def test_nested_dict(self): + """Nested dicts increase depth.""" + assert get_depth({"a": {"b": 1}}) == 2 + assert get_depth({"a": {"b": {"c": 1}}}) == 3 + assert get_depth({"a": {"b": {"c": {"d": 1}}}}) == 4 + + def test_nested_list(self): + """Nested lists increase depth.""" + assert get_depth([[1]]) == 2 + assert get_depth([[[1]]]) == 3 + assert get_depth([1, [2, [3]]]) == 3 + + def test_mixed_nesting(self): + """Mixed dict and list nesting.""" + assert get_depth({"a": [1, 2]}) == 2 + assert get_depth([{"a": 1}]) == 2 + assert get_depth({"a": [{"b": 1}]}) == 3 + assert get_depth({"users": [{"profile": {"name": "Alice"}}]}) == 4 + + def test_uneven_depth(self): + """Returns maximum depth when branches have different depths.""" + data = { + "shallow": 1, + "deep": {"nested": {"value": 1}} + } + assert get_depth(data) == 3 + + def test_tuple_support(self): + """Tuples are treated like lists.""" + assert get_depth((1, 2, 3)) == 1 + assert get_depth({"a": (1, 2)}) == 2 + assert get_depth(({"a": 1}, {"b": 2})) == 2 + + def test_set_treated_as_leaf(self): + """Sets are treated as leaf values (depth 0).""" + assert get_depth({1, 2, 3}) == 0 + assert get_depth({"a": {1, 2, 3}}) == 1 + + +class TestCountLeaves: + """Tests for count_leaves function.""" + + def test_primitive_values(self): + """Primitives count as 1 leaf.""" + assert count_leaves(42) == 1 + assert count_leaves("string") == 1 + assert count_leaves(3.14) == 1 + assert count_leaves(True) == 1 + assert count_leaves(None) == 1 + + def test_empty_containers(self): + """Empty containers have 0 leaves.""" + assert count_leaves({}) == 0 + assert count_leaves([]) == 0 + assert count_leaves(()) == 0 + + def test_flat_dict(self): + """Flat dict counts each value as a leaf.""" + assert count_leaves({"a": 1}) == 1 + assert count_leaves({"a": 1, "b": 2}) == 2 + assert count_leaves({"a": 1, "b": 2, "c": 3}) == 3 + + def test_flat_list(self): + """Flat list counts each element as a leaf.""" + assert count_leaves([1]) == 1 + assert count_leaves([1, 2, 3]) == 3 + assert count_leaves([1, 2, 3, 4, 5]) == 5 + + def test_nested_dict(self): + """Nested dicts still count only leaf values.""" + assert count_leaves({"a": {"b": 1}}) == 1 + assert count_leaves({"a": {"b": 1, "c": 2}}) == 2 + assert count_leaves({"a": {"b": 1}, "d": 2}) == 2 + + def test_nested_list(self): + """Nested lists still count only leaf values.""" + assert count_leaves([[1]]) == 1 + assert count_leaves([[1, 2], [3]]) == 3 + assert count_leaves([1, [2, [3, 4]]]) == 4 + + def test_mixed_nesting(self): + """Mixed dict and list nesting.""" + data = { + "a": {"b": 1, "c": 2}, + "d": [3, 4, 5], + "e": 6 + } + assert count_leaves(data) == 6 + + def test_complex_structure(self): + """Complex nested structure.""" + data = { + "users": [ + {"name": "Alice", "age": 30}, + {"name": "Bob", "age": 25} + ], + "meta": {"count": 2} + } + assert count_leaves(data) == 5 # Alice, 30, Bob, 25, 2 + + def test_tuple_support(self): + """Tuples are counted like lists.""" + assert count_leaves((1, 2, 3)) == 3 + assert count_leaves({"a": (1, 2)}) == 2 + + def test_set_treated_as_leaf(self): + """Sets are treated as single leaf values.""" + assert count_leaves({1, 2, 3}) == 1 + assert count_leaves({"a": {1, 2, 3}}) == 1 + + +class TestGetAllPaths: + """Tests for get_all_paths function.""" + + def test_primitive_value(self): + """Primitive returns empty path.""" + assert get_all_paths(42) == [[]] + assert get_all_paths("string") == [[]] + assert get_all_paths(None) == [[]] + + def test_empty_containers(self): + """Empty containers return no paths.""" + assert get_all_paths({}) == [] + assert get_all_paths([]) == [] + assert get_all_paths(()) == [] + + def test_flat_dict(self): + """Flat dict returns single-key paths.""" + result = get_all_paths({"a": 1, "b": 2}) + assert ["a"] in result + assert ["b"] in result + assert len(result) == 2 + + def test_flat_list(self): + """Flat list returns index paths.""" + assert get_all_paths([1, 2, 3]) == [[0], [1], [2]] + + def test_nested_dict(self): + """Nested dicts produce nested paths.""" + result = get_all_paths({"a": {"b": 1, "c": 2}}) + assert ["a", "b"] in result + assert ["a", "c"] in result + assert len(result) == 2 + + def test_nested_list(self): + """Nested lists produce nested index paths.""" + assert get_all_paths([[1, 2], [3]]) == [[0, 0], [0, 1], [1, 0]] + + def test_mixed_dict_list(self): + """Mixed dict and list nesting.""" + data = {"users": [{"name": "Alice"}, {"name": "Bob"}]} + result = get_all_paths(data) + assert ["users", 0, "name"] in result + assert ["users", 1, "name"] in result + assert len(result) == 2 + + def test_complex_structure(self): + """Complex nested structure.""" + data = { + "a": { + "b": 1, + "c": [2, 3] + }, + "d": 4 + } + result = get_all_paths(data) + assert ["a", "b"] in result + assert ["a", "c", 0] in result + assert ["a", "c", 1] in result + assert ["d"] in result + assert len(result) == 4 + + def test_tuple_support(self): + """Tuples are handled like lists.""" + data = {"items": (1, 2)} + result = get_all_paths(data) + assert ["items", 0] in result + assert ["items", 1] in result + + def test_set_treated_as_leaf(self): + """Sets are treated as leaf values.""" + data = {"a": {1, 2, 3}} + result = get_all_paths(data) + assert result == [["a"]] + + def test_deeply_nested(self): + """Deeply nested structure.""" + data = {"a": {"b": {"c": {"d": {"e": 1}}}}} + assert get_all_paths(data) == [["a", "b", "c", "d", "e"]] + + def test_path_types(self): + """Paths contain strings for dict keys and ints for list indices.""" + data = {"key": [1, 2]} + result = get_all_paths(data) + assert result == [["key", 0], ["key", 1]] + # Verify types + assert isinstance(result[0][0], str) + assert isinstance(result[0][1], int) From f7f61db19700746df6230353dcaf37077a84f7bb Mon Sep 17 00:00:00 2001 From: Siva Sai <26503640+ysskrishna@users.noreply.github.com> Date: Sun, 8 Feb 2026 10:49:47 +0530 Subject: [PATCH 15/17] Siva | improve readme footer section --- README.md | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 8f67bf1..f43ff28 100644 --- a/README.md +++ b/README.md @@ -369,35 +369,31 @@ The library includes built-in safety limits to prevent excessive resource usage: These limits help protect against accidental memory exhaustion or performance issues. If you hit these limits, you'll receive a `PathError` with a clear message. -## Links - -- **PyPI**: [pypi.org/project/nestedutils](https://pypi.org/project/nestedutils/) -- **Documentation**: [ysskrishna.github.io/nestedutils](https://ysskrishna.github.io/nestedutils/) -- **Interactive Demo**: [ysskrishna.github.io/nestedutils/demo/](https://ysskrishna.github.io/nestedutils/demo/) -- **Repository**: [github.com/ysskrishna/nestedutils.git](https://github.com/ysskrishna/nestedutils.git) -- **Issues**: [github.com/ysskrishna/nestedutils/issues](https://github.com/ysskrishna/nestedutils/issues) - ## Contributing Contributions are welcome! Please read our [Contributing Guide](https://github.com/ysskrishna/nestedutils/blob/main/CONTRIBUTING.md) for details on our code of conduct, development setup, and the process for submitting pull requests. ## Support -If you find this library useful, please consider: +If you find this library helpful: -- ⭐ **Starring** the repository on GitHub to help others discover it. -- 💖 **Sponsoring** to support ongoing maintenance and development. - -[Become a Sponsor on GitHub](https://github.com/sponsors/ysskrishna) | [Support on Patreon](https://patreon.com/ysskrishna) +- ⭐ Star the repository +- 🐛 Report issues +- 🔀 Submit pull requests +- 💝 [Sponsor on GitHub](https://github.com/sponsors/ysskrishna) ## License -MIT License - see [LICENSE](https://github.com/ysskrishna/nestedutils/blob/main/LICENSE) file for details. - +MIT © [Y. Siva Sai Krishna](https://github.com/ysskrishna) - see [LICENSE](https://github.com/ysskrishna/nestedutils/blob/main/LICENSE) file for details. -## Author -**Y. Siva Sai Krishna** +--- -- GitHub: [@ysskrishna](https://github.com/ysskrishna) -- LinkedIn: [ysskrishna](https://linkedin.com/in/ysskrishna) + + Author's GitHub • + Author's LinkedIn • + Report Issues • + Package on PyPI • + Package Documentation • + Package Demo + From 19bef0ea0859fd362f51893c5487f5bf8329192e Mon Sep 17 00:00:00 2001 From: Siva Sai <26503640+ysskrishna@users.noreply.github.com> Date: Sun, 8 Feb 2026 10:55:11 +0530 Subject: [PATCH 16/17] Siva | use tables in readme --- README.md | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f43ff28..53b4a14 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,11 @@ user_name = get_at(data, "users.0.profile.name") ## Terminology -- **Path**: A navigation string or list that specifies a location in nested data (e.g., `"user.profile.name"` or `["user", "profile", "name"]`) -- **Key**: An individual dictionary key used to access a value (e.g., `"name"`, `"profile"`) -- **Index**: A numeric position in a list or tuple (e.g., `0`, `-1` for last element) +| Term | Definition | +|------|------------| +| **Path** | A navigation string or list that specifies a location in nested data (e.g., `"user.profile.name"` or `["user", "profile", "name"]`) | +| **Key** | An individual dictionary key used to access a value (e.g., `"name"`, `"profile"`) | +| **Index** | A numeric position in a list or tuple (e.g., `0`, `-1` for last element) | ## Installation @@ -306,13 +308,15 @@ except PathError as e: **Error Codes:** -- `INVALID_PATH`: Invalid path format or type -- `INVALID_INDEX`: Invalid list index -- `MISSING_KEY`: Key doesn't exist in dictionary -- `EMPTY_PATH`: Path is empty -- `IMMUTABLE_CONTAINER`: Attempted to modify a tuple -- `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`) +| Error Code | Description | +|------------|-------------| +| `INVALID_PATH` | Invalid path format or type | +| `INVALID_INDEX` | Invalid list index | +| `MISSING_KEY` | Key doesn't exist in dictionary | +| `EMPTY_PATH` | Path is empty | +| `IMMUTABLE_CONTAINER` | Attempted to modify a tuple | +| `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 @@ -363,8 +367,10 @@ set_at(data, "a.b.c", 10) The library includes built-in safety limits to prevent excessive resource usage: -- **Maximum Path Depth**: 100 levels (prevents deeply nested paths that could cause stack issues) -- **Maximum List Index**: 10,000 (prevents creating extremely large sparse lists) +| Limit | Value | Description | +|-------|-------|-------------| +| **Maximum Path Depth** | 100 levels | Prevents deeply nested paths that could cause stack issues | +| **Maximum List Index** | 10,000 | Prevents creating extremely large sparse lists | These limits help protect against accidental memory exhaustion or performance issues. If you hit these limits, you'll receive a `PathError` with a clear message. From 54e5eee6944e3e1ed4f8e444ffb519188a785e6a Mon Sep 17 00:00:00 2001 From: Siva Sai <26503640+ysskrishna@users.noreply.github.com> Date: Sun, 8 Feb 2026 11:21:06 +0530 Subject: [PATCH 17/17] Siva | prepare for v2.0.0 release --- CHANGELOG.md | 33 ++++- README.md | 4 + docs/migration-v1-to-v2.md | 12 ++ migration-v1-to-v2.md | 243 +++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + pyproject.toml | 2 +- uv.lock | 2 +- 7 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 docs/migration-v1-to-v2.md create mode 100644 migration-v1-to-v2.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 79845fe..a7e0fc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.0] - 2025-02-06 + +### Breaking Changes + +- **`get_at` now raises `PathError` by default** for missing paths instead of returning `None` silently. Use the `default` parameter for optional/nullable access: `get_at(data, "path", default=None)` +- **`get_at` and `set_at` parameters are now keyword-only** - The `default` and `create` parameters must be passed as keyword arguments (e.g., `get_at(data, "path", default=None)`, not `get_at(data, "path", None)`) +- **`set_at` parameter change** - The `fill_strategy` parameter has been replaced with a simpler `create` boolean parameter. Replace `fill_strategy=FillStrategy.AUTO` with `create=True` +- **No more sparse lists** - `set_at` no longer allows creating lists with gaps. Lists must be built sequentially (index 0, then 1, then 2, etc.). Attempting to set at index > len(list) raises `PathError` +- **Removed `FillStrategy` enum** - Use `create=True/False` instead +- **Implicit `None` returns from `get_at`** - Now raises `PathError` by default instead of returning `None` silently + +### Added + +- **Introspection module** with new functions for analyzing nested structures: + - `get_depth(data)` - Returns maximum nesting depth + - `count_leaves(data)` - Counts total leaf values + - `get_all_paths(data)` - Returns all paths to leaf values +- **`default` parameter for `get_at`** - Explicit way to handle missing paths: `get_at(data, "path", default="fallback")` +- **`create` parameter for `set_at`** - Simple boolean to control auto-creation of intermediate containers +- **New error codes**: + - `OPERATION_DISABLED` - For operations blocked by configuration (e.g., list deletion without `allow_list_mutation=True`) + - `NON_NAVIGABLE_TYPE` - For attempts to navigate into non-container types (e.g., int, str, set) + +### Changed + +- Improved error messages with more context about what went wrong and how to fix it +- Refactored internal helpers for better maintainability and testability +- Enhanced path validation with clearer error codes + +For detailed migration instructions, see the [Migration Guide](https://ysskrishna.github.io/nestedutils/migration-v1-to-v2/). + ## [1.1.7] ### Added @@ -13,7 +44,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Radio button selection for three example datasets (User Profile, E-commerce Data, API Response) and custom JSON input - Consolidated path validation tests into `test_normalize_path.py` with new test cases for complex keys and None value handling - ### Fixed - Fixed `normalize_path()` converting all list path keys to strings, now preserves integer and other key types. @@ -133,6 +163,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Immutable container protection (tuples cannot be modified) - Safe list deletion (requires explicit `allow_list_mutation=True` flag) +[2.0.0]: https://github.com/ysskrishna/nestedutils/compare/v1.1.7...v2.0.0 [1.1.7]: https://github.com/ysskrishna/nestedutils/compare/v1.1.6...v1.1.7 [1.1.6]: https://github.com/ysskrishna/nestedutils/compare/v1.1.5...v1.1.6 [1.1.5]: https://github.com/ysskrishna/nestedutils/compare/v1.1.4...v1.1.5 diff --git a/README.md b/README.md index 53b4a14..56f0bae 100644 --- a/README.md +++ b/README.md @@ -375,6 +375,10 @@ The library includes built-in safety limits to prevent excessive resource usage: These limits help protect against accidental memory exhaustion or performance issues. If you hit these limits, you'll receive a `PathError` with a clear message. +## Migration from v1.x to v2.0 + +Version 2.0 introduces breaking changes to make the library safer and more predictable. If you're upgrading from v1.x, please see the [Migration Guide](https://ysskrishna.github.io/nestedutils/migration-v1-to-v2/) for detailed upgrade instructions. + ## Contributing Contributions are welcome! Please read our [Contributing Guide](https://github.com/ysskrishna/nestedutils/blob/main/CONTRIBUTING.md) for details on our code of conduct, development setup, and the process for submitting pull requests. diff --git a/docs/migration-v1-to-v2.md b/docs/migration-v1-to-v2.md new file mode 100644 index 0000000..0c94c56 --- /dev/null +++ b/docs/migration-v1-to-v2.md @@ -0,0 +1,12 @@ +--- +title: Migration Guide - v1 to v2 +description: "Guide for migrating from nestedutils v1.x to v2.0. Covers breaking changes, API updates, and code migration examples." +keywords: + - nestedutils + - migration + - upgrade guide + - v2 + - breaking changes +--- + +--8<-- "migration-v1-to-v2.md" diff --git a/migration-v1-to-v2.md b/migration-v1-to-v2.md new file mode 100644 index 0000000..99579e4 --- /dev/null +++ b/migration-v1-to-v2.md @@ -0,0 +1,243 @@ +# Migration Guide: v1.x to v2.0 + +This guide helps you upgrade your code from nestedutils v1.x to v2.0. Version 2.0 introduces breaking changes that make the library safer and more predictable. + +## Quick Summary of Breaking Changes + +| Change | v1.x Behavior | v2.0 Behavior | +|--------|---------------|---------------| +| `get_at` missing path | Returns `None` silently | Raises `PathError` | +| `get_at` default parameter | Could be positional | Must be keyword-only (`default=...`) | +| `set_at` auto-create | `fill_strategy` parameter | `create=False` parameter | +| `set_at` create parameter | N/A (didn't exist) | Must be keyword-only (`create=...`) | +| Sparse lists | Allowed with `None` fill | Not allowed (strict sequential) | +| `FillStrategy` enum | Available | Removed | + +--- + +## 1. `get_at` Now Raises by Default + +### The Change + +In v1.x, `get_at` silently returned `None` when a path didn't exist. In v2.0, it raises `PathError` by default, making missing data explicit rather than hidden. + +### v1.x Code + +```python +from nestedutils import get_at + +data = {"user": {"name": "Alice"}} + +# v1.x: Returns None silently - bugs can hide here! +email = get_at(data, "user.email") # None +age = get_at(data, "user.profile.age") # None + +# v1.x: default could be passed positionally +value = get_at(data, "missing.path", None) # None (positional argument) +``` + +### v2.0 Migration + +**Option A: Use `default` parameter for optional values** + +```python +from nestedutils import get_at + +data = {"user": {"name": "Alice"}} + +# Explicit default - clear intent +email = get_at(data, "user.email", default=None) # None +email = get_at(data, "user.email", default="unknown@example.com") # fallback value +``` + +**Option B: Use `exists_at` to check first** + +```python +from nestedutils import get_at, exists_at + +if exists_at(data, "user.email"): + email = get_at(data, "user.email") +else: + email = "default@example.com" +``` + +**Option C: Use try/except for error handling** + +```python +from nestedutils import get_at, PathError + +try: + email = get_at(data, "user.email") +except PathError: + email = "default@example.com" +``` + +### Why This Change? + +Silent `None` returns masked bugs. Consider: + +```python +# v1.x - Bug hidden: typo in path returns None, not an error +user_name = get_at(data, "usr.name") # None (typo: "usr" vs "user") + +# v2.0 - Bug exposed immediately +user_name = get_at(data, "usr.name") # Raises PathError! +``` + +--- + +## 2. `set_at` Parameter Changes + +### The Change + +The `fill_strategy` parameter has been replaced with a simpler `create` boolean parameter. Sparse list creation is no longer supported. **Important**: Both `get_at`'s `default` and `set_at`'s `create` are now keyword-only parameters (must use `default=...` and `create=...`, not positional arguments). + +### v1.x Code + +```python +from nestedutils import set_at, FillStrategy + +data = {} + +# v1.x: Various fill strategies +set_at(data, "user.name", "Alice", fill_strategy=FillStrategy.AUTO) +set_at(data, "items.0", "first", fill_strategy=FillStrategy.AUTO) +set_at(data, "items.5", "sixth", fill_strategy=FillStrategy.NONE) # Sparse list! + +# v1.x: fill_strategy could be passed as string positionally +set_at(data, "user.name", "Alice", "auto") # Positional argument +``` + +### v2.0 Migration + +```python +from nestedutils import set_at + +data = {} + +# v2.0: Simple create=True for auto-creation +set_at(data, "user.name", "Alice", create=True) +set_at(data, "items.0", "first", create=True) + +# Sparse lists are NO LONGER ALLOWED +# set_at(data, "items.5", "sixth", create=True) # Raises PathError! + +# Must build sequentially +set_at(data, "items.1", "second", create=True) # OK - appends +``` + +### FillStrategy Migration Table + +| v1.x `fill_strategy` | v2.0 Equivalent | +|----------------------|-----------------| +| `FillStrategy.AUTO` | `create=True` | +| `FillStrategy.NONE` | Not supported (no sparse lists) | +| `FillStrategy.DICT` | `create=True` (inferred from key type) | +| `FillStrategy.LIST` | `create=True` (inferred from key type) | +| Not specified | `create=False` (default, raises on missing) | + +### Why This Change? + +- Sparse lists (`[None, None, None, "value"]`) caused confusion and bugs +- The `FillStrategy` enum added complexity without proportional benefit +- `create=True/False` is more intuitive and covers most use cases + +--- + +## 3. No More Sparse Lists + +### The Change + +v1.x allowed creating lists with gaps filled by `None`. v2.0 enforces sequential list building only. + +### v1.x Code + +```python +from nestedutils import set_at + +data = {} +set_at(data, "items.5", "value", fill_strategy=FillStrategy.NONE) +# Result: {"items": [None, None, None, None, None, "value"]} +``` + +### v2.0 Migration + +```python +from nestedutils import set_at + +data = {} + +# Build lists sequentially +set_at(data, "items.0", "first", create=True) +set_at(data, "items.1", "second", create=True) +set_at(data, "items.2", "third", create=True) +# Result: {"items": ["first", "second", "third"]} + +# Or use plain Python for sparse/pre-sized lists +data = {"items": [None] * 6} +set_at(data, "items.5", "value") # OK - index exists +``` + +### Why This Change? + +Sparse lists often indicate logic errors. If you truly need sparse data, a dict with integer keys is more appropriate: + +```python +# Instead of sparse list +data = {"items": {5: "value", 10: "another"}} +``` + +--- + +## 4. FillStrategy Enum Removed + +### The Change + +The `FillStrategy` enum has been removed from the public API. + +### v1.x Code + +```python +from nestedutils import FillStrategy + +strategy = FillStrategy.AUTO +``` + +### v2.0 Migration + +Remove all `FillStrategy` imports and usages. Use `create=True/False` instead. + +```python +# v2.0: No FillStrategy import needed +from nestedutils import set_at + +set_at(data, "path", value, create=True) +``` + +--- + +## 5. New Introspection Functions + +v2.0 adds new introspection functions that don't exist in v1.x: + +```python +from nestedutils import get_depth, count_leaves, get_all_paths + +data = {"a": {"b": 1, "c": 2}, "d": [3, 4]} + +get_depth(data) # 2 +count_leaves(data) # 4 +get_all_paths(data) # [["a", "b"], ["a", "c"], ["d", 0], ["d", 1]] +``` + +--- + +## Migration Checklist + +- [ ] **Search for `get_at` calls without `default`** - Add `default=None` if silent failure is intended +- [ ] **Update positional `default` arguments** - Change `get_at(data, "path", None)` to `get_at(data, "path", default=None)` +- [ ] **Remove `FillStrategy` imports** - Replace with `create=True/False` +- [ ] **Remove `fill_strategy` parameters** - Replace with `create=True` +- [ ] **Update positional `fill_strategy` arguments** - Change `set_at(data, "path", value, "auto")` to `set_at(data, "path", value, create=True)` +- [ ] **Check for sparse list creation** - Refactor to sequential building or use dicts +- [ ] **Run your test suite** - v2.0's stricter behavior will expose hidden bugs \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 53c86f4..7de55d2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,6 +13,7 @@ nav: - Home: index.md - Interactive Demo: demo.md - API Reference: api-reference.md + - Migration Guide (v1 → v2): migration-v1-to-v2.md - Contributing: CONTRIBUTING.md - Changelog: CHANGELOG.md - License: LICENSE.md diff --git a/pyproject.toml b/pyproject.toml index f76c9ff..48ca05d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nestedutils" -version = "1.1.7" +version = "2.0.0" description = "The lightweight Python library for safe, simple, dot-notation access to nested dicts and lists. Effortlessly get, set, and delete values deep in your complex JSON, API responses, and config files without verbose error-checking or handling KeyError exceptions." readme = "README.md" requires-python = ">=3.8" diff --git a/uv.lock b/uv.lock index c141aa8..e081e31 100644 --- a/uv.lock +++ b/uv.lock @@ -837,7 +837,7 @@ wheels = [ [[package]] name = "nestedutils" -version = "1.1.7" +version = "2.0.0" source = { editable = "." } [package.dev-dependencies]
- Fill Strategy: auto (smart defaults), none (fill gaps with None), - dict (always create dicts), list (always create lists) + Create missing containers: When checked, automatically creates missing intermediate containers (dicts for string keys, lists for numeric keys).
auto
none
dict
list
+ Author's GitHub • + Author's LinkedIn • + Report Issues • + Package on PyPI • + Package Documentation • + Package Demo +