Skip to content

Commit

Permalink
feat: add typing / drop Python < 3.8 support (#30)
Browse files Browse the repository at this point in the history
since python2 and many versions of 3.7 are end-of-life, and type hints would be be valuable for the developer experience, remove support for these EOL versions of Python while adding type hints.

Fixes: #27 

At a high level the following changes have been made:

- Update code to use new features available from 3.8 (e.g. `f"{foo=}` notation).
- Type annotations
  - note: Because we use `from __future__ import annotations` we can use "new style" annotations (e.g. `str | list[int] | None` instead of `Optional[Union[str, List[int]]]`) which are normally only available in higher versions of python. However it does mean that these annotations cannot be resolved from their string representation (all type annotations are stored as strings which is why this works) into their concrete classes unless you are running a supported version of Python.
- Completely move to `pyrproject.toml`
- Move all development dependencies to `pyproject.toml`, make `make .venv` more portable
  - `virtualenv` isn't always available so use `-m venv .venv` which is just as effective, and not all systems have `python(2)` installed or `python3-is-python` so explicitly use `python3`
- Add some new badges to README
  • Loading branch information
nhairs authored Jan 29, 2024
1 parent d1c417e commit 3bb38cb
Show file tree
Hide file tree
Showing 21 changed files with 276 additions and 137 deletions.
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:
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

0 comments on commit 3bb38cb

Please sign in to comment.