Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add typing / drop Python < 3.8 support #30

Merged
merged 7 commits into from
Jan 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
include VERSION
17 changes: 9 additions & 8 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
.venv: pyproject.toml
python -m virtualenv .venv
python3 -m venv .venv

.venv/deps: .venv pyproject.toml setup.cfg
.venv/bin/python -m pip install . build pytest twine
.venv/deps: .venv pyproject.toml
.venv/bin/python -m pip install .[dev]
touch .venv/deps

build: .venv/deps
rm -rf ./dist/
.venv/bin/python -m build .
.venv/bin/python -m build

# only works with python 3+
lint: .venv/deps
.venv/bin/python -m pip install black==22.3.0
.venv/bin/python -m black --check .
.venv/bin/validate-pyproject pyproject.toml
.venv/bin/black --check deepmerge
.venv/bin/mypy deepmerge

test: .venv/deps
.venv/bin/python -m pytest deepmerge
.venv/bin/pytest deepmerge

ready-pr: test lint
ready-pr: test lint
30 changes: 26 additions & 4 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,28 @@
deepmerge
=========

.. image:: https://img.shields.io/pypi/v/deepmerge.svg
:target: https://pypi.org/project/deepmerge/

.. image:: https://img.shields.io/pypi/status/deepmerge.svg
:target: https://pypi.org/project/deepmerge/

.. image:: https://img.shields.io/pypi/pyversions/pillar.svg
:target: https://github.com/toumorokoshi/deepmerge

.. image:: https://img.shields.io/github/license/toumorokoshi/deepmerge.svg
:target: https://github.com/toumorokoshi/deepmerge

.. image:: https://github.com/toumorokoshi/deepmerge/actions/workflows/python-package.yaml/badge.svg
:target: https://github.com/toumorokoshi/deepmerge/actions/workflows/python-package.yaml

A tools to handle merging of
nested data structures in python.
A tool to handle merging of nested data structures in Python.

------------
Installation
------------

deepmerge is available on `pypi <https://pypi.python.org/>`_:
deepmerge is available on `pypi <https://pypi.org/project/deepmerge/>`_:

.. code-block:: bash

Expand Down Expand Up @@ -67,4 +78,15 @@ Example

You can also pass in your own merge functions, instead of a string.

For more information, see the `docs <https://deepmerge.readthedocs.io/en/latest/>`_
For more information, see the `docs <https://deepmerge.readthedocs.io/en/latest/>`_

------------------
Supported Versions
------------------

deepmerge is supported on Python 3.8+.

For older Python versions the last supported version of deepmerge is listed
below:

- 3.7 : 1.1.1
12 changes: 6 additions & 6 deletions deepmerge/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from __future__ import annotations

from .merger import Merger
from .strategy.core import STRATEGY_END # noqa

# some standard mergers available

DEFAULT_TYPE_SPECIFIC_MERGE_STRATEGIES = [
DEFAULT_TYPE_SPECIFIC_MERGE_STRATEGIES: list[tuple[type, str]] = [
(list, "append"),
(dict, "merge"),
(set, "union"),
Expand All @@ -13,22 +15,20 @@
# in the case of type mismatches,
# the value from the second object
# will override the previous one.
always_merger = Merger(
DEFAULT_TYPE_SPECIFIC_MERGE_STRATEGIES, ["override"], ["override"]
)
always_merger: Merger = Merger(DEFAULT_TYPE_SPECIFIC_MERGE_STRATEGIES, ["override"], ["override"])

# this merge strategies attempts
# to merge (append for list, unify for dicts)
# if possible, but raises an exception
# in the case of type conflicts.
merge_or_raise = Merger(DEFAULT_TYPE_SPECIFIC_MERGE_STRATEGIES, [], [])
merge_or_raise: Merger = Merger(DEFAULT_TYPE_SPECIFIC_MERGE_STRATEGIES, [], [])

# a conservative merge tactic:
# for data structures with a specific
# strategy, keep the existing value.
# similar to always_merger but instead
# keeps existing values when faced
# with a type conflict.
conservative_merger = Merger(
conservative_merger: Merger = Merger(
DEFAULT_TYPE_SPECIFIC_MERGE_STRATEGIES, ["use_existing"], ["use_existing"]
)
4 changes: 0 additions & 4 deletions deepmerge/compat.py

This file was deleted.

35 changes: 26 additions & 9 deletions deepmerge/exception.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
from __future__ import annotations

from typing import Any

import deepmerge.merger


class DeepMergeException(Exception):
pass
"Base class for all `deepmerge` Exceptions"


class StrategyNotFound(DeepMergeException):
pass
"Exception for when a strategy cannot be located"


class InvalidMerge(DeepMergeException):
def __init__(self, strategy_list_name, merge_args, merge_kwargs):
super(InvalidMerge, self).__init__(
"no more strategies found for {0} and arguments {1}, {2}".format(
strategy_list_name, merge_args, merge_kwargs
)
"Exception for when unable to complete a merge operation"

def __init__(
self,
strategy_list_name: str,
config: deepmerge.merger.Merger,
path: list,
base: Any,
nxt: Any,
) -> None:
super().__init__(
f"Could not merge using {strategy_list_name!r} [{config=}, {path=}, {base=}, {nxt=}]"
)
self.strategy_list_name = strategy_list_name
self.merge_args = merge_args
self.merge_kwargs = merge_kwargs
self.config = config
self.path = path
self.base = base
self.nxt = nxt
return
21 changes: 12 additions & 9 deletions deepmerge/extended_set.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from typing import Sequence, Any


class ExtendedSet(set):
"""
ExtendedSet is an extension of set, which allows for usage
Expand All @@ -9,17 +12,17 @@ class ExtendedSet(set):
- unhashable types
"""

def __init__(self, elements):
self._values_by_hash = {self._hash(e): e for e in elements}
def __init__(self, elements: Sequence) -> None:
self._values_by_hash = {self._hash_element(e): e for e in elements}

def _insert(self, element):
self._values_by_hash[self._hash(element)] = element
def _insert(self, element: Any) -> None:
self._values_by_hash[self._hash_element(element)] = element
return

def _hash(self, element):
def _hash_element(self, element: Any) -> int:
nhairs marked this conversation as resolved.
Show resolved Hide resolved
if getattr(element, "__hash__") is not None:
return hash(element)
else:
return hash(str(element))
return hash(str(element))

def __contains__(self, obj):
return self._hash(obj) in self._values_by_hash
def __contains__(self, obj: Any) -> bool:
return self._hash_element(obj) in self._values_by_hash
68 changes: 42 additions & 26 deletions deepmerge/merger.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,60 @@
from .strategy.list import ListStrategies
from .strategy.dict import DictStrategies
from .strategy.set import SetStrategies
from .strategy.type_conflict import TypeConflictStrategies
from .strategy.fallback import FallbackStrategies
from __future__ import annotations

from typing import Any, Sequence, Callable

class Merger(object):
from . import strategy as s


class Merger:
"""
Merges objects based on provided strategies

:param type_strategies, List[Tuple]: a list of (Type, Strategy) pairs
that should be used against incoming types. For example: (dict, "override").
"""

PROVIDED_TYPE_STRATEGIES = {
list: ListStrategies,
dict: DictStrategies,
set: SetStrategies,
PROVIDED_TYPE_STRATEGIES: dict[type, type[s.StrategyList]] = {
list: s.ListStrategies,
dict: s.DictStrategies,
set: s.SetStrategies,
}

def __init__(self, type_strategies, fallback_strategies, type_conflict_strategies):
self._fallback_strategy = FallbackStrategies(fallback_strategies)
def __init__(
self,
type_strategies: Sequence[tuple[type, s.StrategyCallable | s.StrategyListInitable]],
fallback_strategies: s.StrategyListInitable,
type_conflict_strategies: s.StrategyListInitable,
) -> None:
self._fallback_strategy = s.FallbackStrategies(fallback_strategies)
self._type_conflict_strategy = s.TypeConflictStrategies(type_conflict_strategies)

expanded_type_strategies = []
self._type_strategies: list[tuple[type, s.StrategyCallable]] = []
for typ, strategy in type_strategies:
if typ in self.PROVIDED_TYPE_STRATEGIES:
strategy = self.PROVIDED_TYPE_STRATEGIES[typ](strategy)
expanded_type_strategies.append((typ, strategy))
self._type_strategies = expanded_type_strategies

self._type_conflict_strategy = TypeConflictStrategies(type_conflict_strategies)

def merge(self, base, nxt):
# Customise a StrategyList instance for this type
self._type_strategies.append((typ, self.PROVIDED_TYPE_STRATEGIES[typ](strategy)))
elif callable(strategy):
self._type_strategies.append((typ, strategy))
else:
raise ValueError(f"Cannot handle ({typ}, {strategy})")
return

def merge(self, base: Any, nxt: Any) -> Any:
return self.value_strategy([], base, nxt)

def type_conflict_strategy(self, *args):
return self._type_conflict_strategy(self, *args)
def type_conflict_strategy(self, path: list, base: Any, nxt: Any) -> Any:
return self._type_conflict_strategy(self, path, base, nxt)

def value_strategy(self, path, base, nxt):
def value_strategy(self, path: list, base: Any, nxt: Any) -> Any:
# Check for strategy based on type of base, next
for typ, strategy in self._type_strategies:
if isinstance(base, typ) and isinstance(nxt, typ):
# We have a strategy for this type
return strategy(self, path, base, nxt)
if not (isinstance(base, type(nxt)) or isinstance(nxt, type(base))):
return self.type_conflict_strategy(path, base, nxt)
return self._fallback_strategy(self, path, base, nxt)

if isinstance(base, type(nxt)) or isinstance(nxt, type(base)):
# no known strategy but base, next are similar types
return self._fallback_strategy(self, path, base, nxt)

# No known strategy and base, next are different types.
return self.type_conflict_strategy(path, base, nxt)
Empty file added deepmerge/py.typed
Empty file.
6 changes: 6 additions & 0 deletions deepmerge/strategy/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from .core import StrategyList, StrategyCallable, StrategyListInitable
from .dict import DictStrategies
from .fallback import FallbackStrategies
from .list import ListStrategies
from .set import SetStrategies
from .type_conflict import TypeConflictStrategies
41 changes: 27 additions & 14 deletions deepmerge/strategy/core.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,33 @@
from __future__ import annotations

import sys
from typing import Callable, Any

if sys.version_info >= (3, 10):
from typing import TypeAlias
else:
from typing_extensions import TypeAlias

import deepmerge.merger
from ..exception import StrategyNotFound, InvalidMerge
from ..compat import string_type

STRATEGY_END = object()

# Note: We use string annotations here to prevent circular import caused by Merger
StrategyCallable: TypeAlias = "Callable[[deepmerge.merger.Merger, list, Any, Any], Any]"
StrategyListInitable: TypeAlias = "str | StrategyCallable | list[str | StrategyCallable]"

class StrategyList(object):

NAME = None
class StrategyList:
NAME: str

def __init__(self, strategy_list):
def __init__(self, strategy_list: StrategyListInitable) -> None:
if not isinstance(strategy_list, list):
strategy_list = [strategy_list]
self._strategies = [self._expand_strategy(s) for s in strategy_list]
self._strategies: list[StrategyCallable] = [self._expand_strategy(s) for s in strategy_list]

@classmethod
def _expand_strategy(cls, strategy):
def _expand_strategy(cls, strategy: str | StrategyCallable) -> StrategyCallable:
"""
:param strategy: string or function

Expand All @@ -23,16 +36,16 @@ def _expand_strategy(cls, strategy):

Otherwise, return the value, implicitly assuming it's a function.
"""
if isinstance(strategy, string_type):
method_name = "strategy_{0}".format(strategy)
if not hasattr(cls, method_name):
raise StrategyNotFound(strategy)
return getattr(cls, method_name)
if isinstance(strategy, str):
method_name = f"strategy_{strategy}"
if hasattr(cls, method_name):
return getattr(cls, method_name)
raise StrategyNotFound(strategy)
return strategy

def __call__(self, *args, **kwargs):
def __call__(self, config: deepmerge.merger.Merger, path: list, base: Any, nxt: Any) -> Any:
for s in self._strategies:
ret_val = s(*args, **kwargs)
ret_val = s(config, path, base, nxt)
if ret_val is not STRATEGY_END:
return ret_val
raise InvalidMerge(self.NAME, args, kwargs)
raise InvalidMerge(self.NAME, config, path, base, nxt)
9 changes: 7 additions & 2 deletions deepmerge/strategy/dict.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from __future__ import annotations

import deepmerge.merger
from .core import StrategyList


Expand All @@ -10,7 +13,7 @@ class DictStrategies(StrategyList):
NAME = "dict"

@staticmethod
def strategy_merge(config, path, base, nxt):
def strategy_merge(config: deepmerge.merger.Merger, path: list, base: dict, nxt: dict) -> dict:
"""
for keys that do not exists,
use them directly. if the key exists
Expand All @@ -24,7 +27,9 @@ def strategy_merge(config, path, base, nxt):
return base

@staticmethod
def strategy_override(config, path, base, nxt):
def strategy_override(
config: deepmerge.merger.Merger, path: list, base: dict, nxt: dict
) -> dict:
"""
move all keys in nxt into base, overriding
conflicts.
Expand Down
Loading
Loading