Skip to content

Add support for tuples in dataclass keys #5

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
24 changes: 20 additions & 4 deletions enty/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from .utils import is_scalar_type, camel_to_snake

from dataclasses import fields, asdict, astuple
from typing import Any, List, Optional, Union, Type, Callable
from typing import Any, List, Optional, Union, Type, Callable, get_origin, get_args
from types import MappingProxyType

ENTITY_DEFAULTS = {
Expand Down Expand Up @@ -188,7 +188,10 @@ def init_from_path(cls, path: Union[str, Path]) -> Any:
raise ValueError(f"Expected 2 parts from path '{path}', got {len(parts)}: {parts}")

identity, fields_str = parts
if identity != cls.identity:
if cls.identity not in identity: # TODO: this will correctly not raise an error if the file identity contains prefixes or suffixes, but
# it will not extract the prefixes and suffixes if they are present. Consider making more general
# to be able to extract prefixes and suffixes (and any other metadata from the filename) and update
# the config dict of the new object
raise ValueError(f"File identity '{identity}' != class identity '{cls.identity}'")

field_values = fields_str.split(cls.field_sep)
Expand All @@ -211,6 +214,13 @@ def init_from_path(cls, path: Union[str, Path]) -> Any:
# We assume a direct constructor call ftype(val) or something more nuanced if needed
logger.debug(f"[{cls.class_name}._read_path] Parsing field: {fname}, Value: {val}, Type: {ftype}")
try:
if val.startswith("tup"): # TODO: Can we detect iterables beyond specific tags in the value?
val = val[4:].split("_")
subtype = get_args(ftype)
if subtype:
val = map(subtype[0], val) # TODO: Right now, we assume that there will be only one subtype in the argument, so we can extract the first item. In the future, we may need to generalize this to accept multiple object types
ftype = get_origin(ftype) # TODO: We could explicitly assign tuple to ftype, but using get_origin will generalize better if we extend beyond only tuples in the future.
# Also note: If there are no subarguments, then get_origin will return None, so we only need to call this if get_args returns a list with entries
parsed_values[fname] = ftype(val)
except (ValueError, TypeError) as e:
raise ValueError(f"Error parsing field '{fname}' with value '{val}': {e}")
Expand Down Expand Up @@ -355,7 +365,13 @@ def get_filename(self, ext=None, prefix=None, suffix=None, ident_sep=None, field
str
The constructed filename.
"""
vals = [str(getattr(self.key, f.name)) for f in fields(self.key)]
vals = []
for f in fields(self.key):
attr = getattr(self.key, f.name)
if isinstance(attr, tuple):
attr = "tup_" + "_".join(map(str, attr)) # TODO: Currently, this generates a different filename if the tuple is ordered differently. We may want to consider sorting the tuple -- it has its pros and cons
str_repr = str(attr)
vals.append(str_repr)
identity = _compute_identity(self, prefix=prefix, suffix=suffix)
ident_sep = ident_sep if ident_sep is not None else self.ident_sep
field_sep = field_sep if field_sep is not None else self.field_sep
Expand All @@ -369,7 +385,7 @@ def get_filename(self, ext=None, prefix=None, suffix=None, ident_sep=None, field
logger.debug(f"[{self.class_name}.get_filename] Constructed filename: {name}")
return name

def get_path(self, base_dir=None, sub_dir=None, src_dir=None, make_dir=None, ext=None, prefix=None, suffix=None, ident_sep=None, field_sep=None):
def get_path(self, ext=None, base_dir=None, sub_dir=None, src_dir=None, make_dir=None, prefix=None, suffix=None, ident_sep=None, field_sep=None):
"""
Construct a filesystem path for this instance based on its class identity
and its field values.
Expand Down
11 changes: 9 additions & 2 deletions enty/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,15 @@
from collections.abc import Iterable


def is_scalar_type(value):
return not isinstance(value, (Iterable,)) or isinstance(value, (str, bytes))
def is_scalar_type(value, allow_tuple=True):
# allow all scalar types -- this includes all non-Iterables as well as str and byte objects
if not isinstance(value, (Iterable,)) or isinstance(value, (str, bytes)):
return True
# also allow tuple objects as long as they are composed of scalar objects
# note: in the recursion step, set allow_tuple=False so that we don't allow nested tuples.
if allow_tuple and isinstance(value, (tuple,)):
return all(is_scalar_type(entry, allow_tuple=False) for entry in value)
return False


def camel_to_snake(camel: Union[str, None]) -> str:
Expand Down