Repository: pepkit/peppy

Issue:
Remove attmap dependency
As in other projects, we should remove attmap here.

Discussion:

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 1351854..9e0b34e 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,17 @@
 
 This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format.
 
+## [0.40.0] -- 2023-XX-XX
+
+**This version introduced backwards-incompatible changes.**
+
+### Changed
+- Replaced attmap with MutableMapping. (which resulted in the removal of the attribute functionality previously available in attmap)
+- Replaced OrderedDict with dict.
+- Deprecated support for Python versions <= 3.7.
+
+_Due to the changes mentioned above, a few item functionalities may be disabled. For example, the `name` and `description` properties can now be accessed and modified using attribute functionality_
+
 ## [0.35.7] -- 2023-07-19
 ### Fixed 
 - incorrect setting of sample and subsample indexes using from_dict function (#452)
@@ -17,6 +28,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
 ### Changed
 - Reduced the number of items returned in the to_dict(extended=True) method to 3, with the name and description now stored in the config key.
 
+
 ## [0.35.5] -- 2023-03-27
 ### Fixed
 - A [bug](https://github.com/pepkit/peppy/issues/435) with custom sample ids
@@ -33,8 +45,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
 
 ## [0.35.2] -- 2022-09-13
 ### Fixed
-
 - Returning `NaN` value within `to_dict` method was fixed and method now returns `None` instead
+
 ## [0.35.1] -- 2022-09-07
 ### Changed
 - Organization of test files. Separated unittests from smoketests.
diff --git a/docs/hello-world.md b/docs/hello-world.md
index d6cf503..b8162e8 100644
--- a/docs/hello-world.md
+++ b/docs/hello-world.md
@@ -40,7 +40,7 @@ project = peppy.Project("example_basic/project_config.yaml") # instantiate in-me
 samples = project.samples # grab the list of Sample objects defined in this Project
 
 # Find the input file for the first sample in the project
-samples[0].file
+samples[0]["file"]
 ```
 
 That's it! You've got `peppy` running on an example project.
diff --git a/peppy/_version.py b/peppy/_version.py
index c83377e..738a934 100644
--- a/peppy/_version.py
+++ b/peppy/_version.py
@@ -1,1 +1,1 @@
-__version__ = "0.35.7"
+__version__ = "0.40.0a1"
diff --git a/peppy/parsers.py b/peppy/parsers.py
index 7276a6d..961d6e1 100644
--- a/peppy/parsers.py
+++ b/peppy/parsers.py
@@ -1,7 +1,9 @@
+import os
 from typing import Any, Dict, List
-from .exceptions import InvalidSampleTableFileException
+
 import pandas as pd
-import os
+
+from .exceptions import InvalidSampleTableFileException
 
 
 class TableParser:
diff --git a/peppy/project.py b/peppy/project.py
index 665d4b2..27c5e05 100644
--- a/peppy/project.py
+++ b/peppy/project.py
@@ -1,23 +1,20 @@
 """
 Build a Project object.
 """
-import math
-import os, sys
-from collections.abc import Mapping
+import os
+import sys
+from collections.abc import Mapping, MutableMapping
 from contextlib import suppress
 from logging import getLogger
 from typing import Dict, Iterable, List, Tuple, Union, Literal, NoReturn
 
 import numpy as np
 import pandas as pd
-from attmap import PathExAttMap
 from pandas.core.common import flatten
-from rich.progress import track
 from rich.console import Console
+from rich.progress import track
 from ubiquerg import is_url
 
-from peppy.sample import Sample
-
 from .const import (
     ACTIVE_AMENDMENTS_KEY,
     AMENDMENTS_KEY,
@@ -78,7 +75,7 @@ _LOGGER = getLogger(PKG_NAME)
 
 
 @copy
-class Project(PathExAttMap):
+class Project(MutableMapping):
     """
     A class to model a Project (collection of samples and metadata).
 
@@ -114,6 +111,7 @@ class Project(PathExAttMap):
                 self.__class__.__name__, " from file {}".format(cfg) if cfg else ""
             )
         )
+        self._project_data = {}
         super(Project, self).__init__()
         is_cfg = is_cfg_or_anno(cfg)
         if is_cfg is None:
@@ -156,9 +154,6 @@ class Project(PathExAttMap):
             or [SAMPLE_NAME_ATTR, SUBSAMPLE_NAME_ATTR]
         )
 
-        self.name = self.infer_name()
-        self.description = self.get_description()
-
         if not defer_samples_creation:
             self.create_samples(modify=False if self[SAMPLE_TABLE_FILE_KEY] else True)
         self._sample_table = self._get_table_from_samples(
@@ -215,10 +210,10 @@ class Project(PathExAttMap):
                     for sub_a in pep_dictionary[SUBSAMPLE_RAW_LIST_KEY]
                 ]
         if NAME_KEY in self[CONFIG_KEY]:
-            self[NAME_KEY] = self[CONFIG_KEY][NAME_KEY]
+            self.name = self[CONFIG_KEY][NAME_KEY]
 
         if DESC_KEY in self[CONFIG_KEY]:
-            self[DESC_KEY] = self[CONFIG_KEY][DESC_KEY]
+            self.description = self[CONFIG_KEY][DESC_KEY]
 
         self._set_indexes(self[CONFIG_KEY])
 
@@ -252,15 +247,15 @@ class Project(PathExAttMap):
                 ]
             else:
                 sub_df = None
-            self[CONFIG_KEY][NAME_KEY] = self[NAME_KEY]
-            self[CONFIG_KEY][DESC_KEY] = self[DESC_KEY]
+            self[CONFIG_KEY][NAME_KEY] = self.name
+            self[CONFIG_KEY][DESC_KEY] = self.description
             p_dict = {
                 SAMPLE_RAW_DICT_KEY: self[SAMPLE_DF_KEY].to_dict(orient=orient),
-                CONFIG_KEY: self[CONFIG_KEY].to_dict(expand=expand),
+                CONFIG_KEY: dict(self[CONFIG_KEY]),
                 SUBSAMPLE_RAW_LIST_KEY: sub_df,
             }
         else:
-            p_dict = self.config.to_dict(expand=expand)
+            p_dict = self.config
             p_dict["_samples"] = [s.to_dict() for s in self.samples]
 
         return p_dict
@@ -283,9 +278,13 @@ class Project(PathExAttMap):
         """
         Clear all object attributes and initialize again
         """
-        cfg_path = self[CONFIG_FILE_KEY] if CONFIG_FILE_KEY in self else None
-        for attr in self.keys():
-            del self[attr]
+        if hasattr(self, "config_file"):
+            cfg_path = getattr(self, "config_file")
+        else:
+            cfg_path = None
+        obj_attributes = self.__dict__.copy().keys()
+        for attr in obj_attributes:
+            delattr(self, attr)
         self.__init__(cfg=cfg_path)
 
     def _get_table_from_samples(self, index, initial=False):
@@ -301,7 +300,10 @@ class Project(PathExAttMap):
             # (there is no chance of manual sample edits)
             # and no sample_modifiers section is defined in the config,
             # then we can simply reuse the previously read anno sheet.
-            df = self[SAMPLE_DF_KEY] if hasattr(self, SAMPLE_DF_KEY) else pd.DataFrame()
+            if SAMPLE_DF_KEY in self:
+                df = self[SAMPLE_DF_KEY]
+            else:
+                df = pd.DataFrame()
         else:
             df = pd.DataFrame.from_dict([s.to_dict() for s in self.samples])
         index = [index] if isinstance(index, str) else index
@@ -328,7 +330,7 @@ class Project(PathExAttMap):
         :raises KeyError: if config file lacks required section(s)
         """
         if CONFIG_KEY not in self:
-            self[CONFIG_KEY] = PathExAttMap()
+            self[CONFIG_KEY] = {}
         if not os.path.exists(cfg_path) and not is_url(cfg_path):
             raise OSError(f"Project config file path does not exist: {cfg_path}")
         config = load_yaml(cfg_path)
@@ -339,7 +341,7 @@ class Project(PathExAttMap):
             config, type(config)
         )
 
-        _LOGGER.debug("Raw ({}) config data: {}".format(cfg_path, config))
+        _LOGGER.debug(f"Raw ({cfg_path}) config data: {config}")
 
         self._set_indexes(config)
         # recursively import configs
@@ -363,7 +365,7 @@ class Project(PathExAttMap):
                         "External Project configuration does not" " exist: {}".format(i)
                     )
 
-        self[CONFIG_KEY].add_entries(config)
+        self[CONFIG_KEY].update(**config)
         # Parse yaml into the project.config attributes
         _LOGGER.debug("Adding attributes: {}".format(", ".join(config)))
         # Overwrite any config entries with entries in the amendments
@@ -384,7 +386,7 @@ class Project(PathExAttMap):
                             amendment, c[PROJ_MODS_KEY][AMENDMENTS_KEY]
                         )
                     _LOGGER.debug("Updating with: {}".format(amends))
-                    self[CONFIG_KEY].add_entries(amends)
+                    self[CONFIG_KEY].update(**amends)
                     _LOGGER.info("Using amendments: {}".format(amendment))
                 else:
                     raise MissingAmendmentError(amendment)
@@ -448,13 +450,14 @@ class Project(PathExAttMap):
         Perform any sample modifications defined in the config.
         """
         if self._modifier_exists():
+            # check for unrecognizable modification keys
             mod_diff = set(self[CONFIG_KEY][SAMPLE_MODS_KEY].keys()) - set(
                 SAMPLE_MODIFIERS
             )
             if len(mod_diff) > 0:
                 _LOGGER.warning(
-                    "Config '{}' section contains unrecognized "
-                    "subsections: {}".format(SAMPLE_MODS_KEY, mod_diff)
+                    f"Config '{SAMPLE_MODS_KEY}' section contains unrecognized "
+                    f"subsections: {mod_diff}"
                 )
         self.attr_remove()
         self.attr_constants()
@@ -496,7 +499,7 @@ class Project(PathExAttMap):
 
         if self._modifier_exists(REMOVE_KEY):
             to_remove = self[CONFIG_KEY][SAMPLE_MODS_KEY][REMOVE_KEY]
-            _LOGGER.debug("Removing attributes: {}".format(to_remove))
+            _LOGGER.debug(f"Removing attributes: {to_remove}")
             for s in track(
                 self.samples,
                 description="Removing sample attributes",
@@ -531,7 +534,7 @@ class Project(PathExAttMap):
         """
         if self._modifier_exists(DUPLICATED_KEY):
             synonyms = self[CONFIG_KEY][SAMPLE_MODS_KEY][DUPLICATED_KEY]
-            _LOGGER.debug("Applying synonyms: {}".format(synonyms))
+            _LOGGER.debug(f"Applying synonyms: {synonyms}")
             for sample in track(
                 self.samples,
                 description="Applying synonymous sample attributes",
@@ -540,7 +543,7 @@ class Project(PathExAttMap):
             ):
                 for attr, new in synonyms.items():
                     if attr in sample:
-                        setattr(sample, new, getattr(sample, attr))
+                        sample[new] = sample[attr]
                     else:
                         _LOGGER.warning(
                             f"The sample attribute to duplicate not found: {attr}"
@@ -571,12 +574,12 @@ class Project(PathExAttMap):
                 )
                 if self.st_index != SAMPLE_NAME_ATTR:
                     try:
-                        custom_sample_name = getattr(sample, self.st_index)
-                    except AttributeError:
+                        custom_sample_name = sample[self.st_index]
+                    except KeyError:
                         raise InvalidSampleTableFileException(
                             f"Specified {CFG_SAMPLE_TABLE_KEY} index ({self.st_index}) does not exist"
                         )
-                    setattr(sample, SAMPLE_NAME_ATTR, custom_sample_name)
+                    sample[SAMPLE_NAME_ATTR] = custom_sample_name
                     _LOGGER.warning(
                         message
                         + f"using specified {CFG_SAMPLE_TABLE_KEY} index ({self.st_index}) instead. "
@@ -592,14 +595,15 @@ class Project(PathExAttMap):
         :raises IllegalStateException: if both duplicated samples are detected and subsample_table is
             specified in the config
         """
-        sample_names_list = [getattr(s, self.st_index) for s in self.samples]
+        sample_names_list = [s[self.st_index] for s in self.samples]
         duplicated_sample_ids = self._get_duplicated_sample_ids(sample_names_list)
 
         if not duplicated_sample_ids:
             return
 
         _LOGGER.info(
-            f"Found {len(duplicated_sample_ids)} samples with non-unique names: {duplicated_sample_ids}. Attempting to auto-merge."
+            f"Found {len(duplicated_sample_ids)} samples with non-unique names: {duplicated_sample_ids}. "
+            f"Attempting to auto-merge."
         )
         if SUBSAMPLE_DF_KEY in self and self[SUBSAMPLE_DF_KEY] is not None:
             raise IllegalStateException(
@@ -679,7 +683,7 @@ class Project(PathExAttMap):
         for attr in sample_attributes:
             attribute_values = []
             for sample in duplicated_samples:
-                attribute_value_for_sample = getattr(sample, attr, "")
+                attribute_value_for_sample = sample.get(attr, "")
                 attribute_values.append(attribute_value_for_sample)
 
             merged_attributes[attr] = list(flatten(attribute_values))
@@ -750,12 +754,10 @@ class Project(PathExAttMap):
 
                     for attname, attval in rowdata.items():
                         if attname == sample_colname or not attval:
-                            _LOGGER.debug("Skipping KV: {}={}".format(attname, attval))
+                            _LOGGER.debug(f"Skipping KV: {attname}={attval}")
                             continue
                         _LOGGER.debug(
-                            "merge: sample '{}'; '{}'='{}'".format(
-                                sample[self.st_index], attname, attval
-                            )
+                            f"merge: sample '{sample[self.st_index]}'; '{attname}'='{attval}'"
                         )
                         merged_attrs[attname] = _select_new_attval(
                             merged_attrs, attname, attval
@@ -765,7 +767,7 @@ class Project(PathExAttMap):
                 merged_attrs.pop(sample_colname, None)
 
                 _LOGGER.debug(
-                    "Updating Sample {}: {}".format(sample[self.st_index], merged_attrs)
+                    f"Updating Sample {sample[self.st_index]}: {merged_attrs}"
                 )
                 sample.update(merged_attrs)
 
@@ -782,17 +784,13 @@ class Project(PathExAttMap):
         implications = self[CONFIG_KEY][SAMPLE_MODS_KEY][IMPLIED_KEY]
         if not isinstance(implications, list):
             raise InvalidConfigFileException(
-                "{}.{} has to be a list of key-value pairs".format(
-                    SAMPLE_MODS_KEY, IMPLIED_KEY
-                )
+                f"{SAMPLE_MODS_KEY}.{IMPLIED_KEY} has to be a list of key-value pairs"
             )
-        _LOGGER.debug("Sample attribute implications: {}".format(implications))
+        _LOGGER.debug(f"Sample attribute implications: {implications}")
         for implication in implications:
             if not all([key in implication for key in IMPLIED_COND_KEYS]):
                 raise InvalidConfigFileException(
-                    "{}.{} section is invalid: {}".format(
-                        SAMPLE_MODS_KEY, IMPLIED_KEY, implication
-                    )
+                    f"{SAMPLE_MODS_KEY}.{IMPLIED_KEY} section is invalid: {implication}"
                 )
         for sample in track(
             self.samples,
@@ -803,24 +801,19 @@ class Project(PathExAttMap):
             for implication in implications:
                 implier_attrs = list(implication[IMPLIED_IF_KEY].keys())
                 implied_attrs = list(implication[IMPLIED_THEN_KEY].keys())
-                _LOGGER.debug(
-                    "Setting Sample attributes implied by '{}'".format(implier_attrs)
-                )
+                _LOGGER.debug(f"Setting Sample attributes implied by '{implier_attrs}'")
                 for implier_attr in implier_attrs:
                     implier_val = implication[IMPLIED_IF_KEY][implier_attr]
                     if implier_attr not in sample:
                         _LOGGER.debug(
-                            "Sample lacks implier attr ({}), "
-                            "skipping:".format(implier_attr)
+                            f"Sample lacks implier attr ({implier_attr}), skipping:"
                         )
                         break
                     sample_val = sample[implier_attr]
                     if sample_val not in implier_val:
                         _LOGGER.debug(
                             "Sample attr value does not match any of implier "
-                            "requirements ({} not in {}), skipping".format(
-                                sample_val, implier_val
-                            )
+                            f"requirements ({sample_val} not in {implier_val}), skipping"
                         )
                         break
                 else:
@@ -828,9 +821,7 @@ class Project(PathExAttMap):
                     for implied_attr in implied_attrs:
                         imp_val = implication[IMPLIED_THEN_KEY][implied_attr]
                         _LOGGER.debug(
-                            "Setting implied attr: '{}={}'".format(
-                                implied_attr, imp_val
-                            )
+                            f"Setting implied attr: '{implied_attr}={imp_val}'"
                         )
                         sample.__setitem__(implied_attr, imp_val)
 
@@ -851,29 +842,26 @@ class Project(PathExAttMap):
             console=Console(file=sys.stderr),
         ):
             for attr in derivations:
-                if not hasattr(sample, attr):
-                    _LOGGER.debug("sample lacks '{}' attribute".format(attr))
+                if not attr in sample:
+                    _LOGGER.debug(f"sample lacks '{attr}' attribute")
                     continue
                 elif attr in sample._derived_cols_done:
-                    _LOGGER.debug("'{}' has been derived".format(attr))
+                    _LOGGER.debug(f"'{attr}' has been derived")
                     continue
                 _LOGGER.debug(
-                    "Deriving '{}' attribute for '{}'".format(
-                        attr, sample[self.st_index]
-                    )
+                    f"Deriving '{attr}' attribute for '{sample[self.st_index]}'"
                 )
 
                 # Set {atr}_key, so the original source can also be retrieved
-                setattr(sample, ATTR_KEY_PREFIX + attr, getattr(sample, attr))
+                sample[ATTR_KEY_PREFIX + attr] = sample[attr]
 
                 derived_attr = sample.derive_attribute(ds, attr)
                 if derived_attr:
                     _LOGGER.debug("Setting '{}' to '{}'".format(attr, derived_attr))
-                    setattr(sample, attr, derived_attr)
+                    sample[attr] = derived_attr
                 else:
                     _LOGGER.debug(
-                        "Not setting null/empty value for data source"
-                        " '{}': {}".format(attr, type(derived_attr))
+                        f"Not setting null/empty value for data source '{attr}': {type(derived_attr)}"
                     )
                 sample._derived_cols_done.append(attr)
 
@@ -977,14 +965,14 @@ class Project(PathExAttMap):
         """
         if CONFIG_KEY not in self:
             return
-        if hasattr(self[CONFIG_KEY], "name"):
-            if " " in self[CONFIG_KEY].name:
+        if NAME_KEY in self[CONFIG_KEY]:
+            if " " in self[CONFIG_KEY][NAME_KEY]:
                 raise InvalidConfigFileException(
                     "Specified Project name ({}) contains whitespace".format(
-                        self[CONFIG_KEY].name
+                        self[CONFIG_KEY][NAME_KEY]
                     )
                 )
-            return self[CONFIG_KEY].name.replace(" ", "_")
+            return self[CONFIG_KEY][NAME_KEY].replace(" ", "_")
         if not self[CONFIG_FILE_KEY]:
             raise NotImplementedError(
                 "Project name inference isn't supported "
@@ -1008,7 +996,7 @@ class Project(PathExAttMap):
         """
         if CONFIG_KEY not in self:
             return
-        if hasattr(self[CONFIG_KEY], DESC_KEY):
+        if DESC_KEY in self[CONFIG_KEY]:
             desc_str = str(self[CONFIG_KEY][DESC_KEY])
             if not isinstance(desc_str, str):
                 try:
@@ -1040,7 +1028,7 @@ class Project(PathExAttMap):
             num_samples = 0
         if num_samples > 0:
             msg = f"{msg}\n{num_samples} samples"
-            sample_names = [getattr(s, self.st_index) for s in self.samples]
+            sample_names = [s[self.st_index] for s in self.samples]
             repr_names = sample_names[:MAX_PROJECT_SAMPLES_REPR]
             context = (
                 f" (showing first {MAX_PROJECT_SAMPLES_REPR})"
@@ -1081,7 +1069,7 @@ class Project(PathExAttMap):
         :return Iterable[str]: a list of available amendment names
         """
         try:
-            return self[CONFIG_KEY][PROJ_MODS_KEY][AMENDMENTS_KEY].keys()
+            return list(self[CONFIG_KEY][PROJ_MODS_KEY][AMENDMENTS_KEY].keys())
         except Exception as e:
             _LOGGER.debug(
                 "Could not retrieve available amendments: {}".format(
@@ -1098,7 +1086,7 @@ class Project(PathExAttMap):
         :return Mapping: config. May be formatted to comply with the most
             recent version specifications
         """
-        return self[CONFIG_KEY] if CONFIG_KEY in self else PathExAttMap()
+        return self[CONFIG_KEY] if CONFIG_KEY in self else {}
 
     @property
     def config_file(self):
@@ -1366,6 +1354,49 @@ class Project(PathExAttMap):
         """
         return [s for s in self.samples if s[self.st_index] in sample_names]
 
+    @property
+    def description(self):
+        return self.get_description()
+
+    @description.setter
+    def description(self, value):
+        self[CONFIG_KEY][DESC_KEY] = str(value)
+
+    @property
+    def name(self):
+        return self.infer_name()
+
+    @name.setter
+    def name(self, value):
+        self[CONFIG_KEY][NAME_KEY] = str(value)
+
+    def __setitem__(self, key, value):
+        self._project_data[key] = value
+
+    def __getitem__(self, item):
+        """
+        Fetch the value of given key.
+
+        :param hashable item: key for which to fetch value
+        :return object: value mapped to given key, if available
+        :raise KeyError: if the requested key is unmapped.
+        """
+        return self._project_data[item]
+
+    def __iter__(self):
+        return iter(self._project_data)
+
+    def __len__(self):
+        return len(self._project_data)
+
+    def __delitem__(self, key):
+        value = self[key]
+        del self._project_data[key]
+        self.pop(value, None)
+
+    def __repr__(self):
+        return str(self)
+
 
 def infer_delimiter(filepath):
     """
diff --git a/peppy/sample.py b/peppy/sample.py
index bd65dc1..8e3c276 100644
--- a/peppy/sample.py
+++ b/peppy/sample.py
@@ -1,6 +1,5 @@
 import glob
 import os
-from collections import OrderedDict
 from collections.abc import Mapping
 from copy import copy as cp
 from logging import getLogger
@@ -8,7 +7,7 @@ from string import Formatter
 
 import pandas as pd
 import yaml
-from attmap import AttMap, PathExAttMap
+from pandas import Series, isnull
 
 from .const import (
     CONFIG_FILE_KEY,
@@ -21,6 +20,7 @@ from .const import (
 )
 from .exceptions import InvalidSampleTableFileException
 from .utils import copy, grab_project_data
+from .simple_attr_map import SimpleAttMap
 
 _LOGGER = getLogger(PKG_NAME)
 
@@ -31,7 +31,7 @@ class SafeDict(dict):
 
 
 @copy
-class Sample(PathExAttMap):
+class Sample(SimpleAttMap):
     """
     Class to model Samples based on a pandas Series.
 
@@ -41,8 +41,8 @@ class Sample(PathExAttMap):
     def __init__(self, series, prj=None):
         super(Sample, self).__init__()
 
-        data = OrderedDict(series)
-        _LOGGER.debug("Sample data: {}".format(data))
+        data = dict(series)
+        _LOGGER.debug("Sample data: {data}")
 
         # Attach Project reference
         try:
@@ -50,12 +50,11 @@ class Sample(PathExAttMap):
         except (AttributeError, KeyError):
             data_proj = None
 
-        self.add_entries(data)
+        self.update(**data)
 
         if data_proj and PRJ_REF not in self:
             self[PRJ_REF] = data_proj
 
-        typefam = PathExAttMap
         if PRJ_REF in self and prj:
             _LOGGER.warning(
                 "Project data provided both in data and as separate"
@@ -68,13 +67,10 @@ class Sample(PathExAttMap):
             self[PRJ_REF] = None
             _LOGGER.debug("No project reference for sample")
         else:
-            prefix = "Project reference on a sample must be an instance of {}".format(
-                typefam.__name__
-            )
+            prefix = "Project reference on a sample must be an instance of dict"
+
             if not isinstance(self[PRJ_REF], Mapping):
-                raise TypeError(
-                    prefix + "; got {}".format(type(self[PRJ_REF]).__name__)
-                )
+                raise TypeError(f"{prefix}; got {type(self[PRJ_REF]).__name__}")
         self._derived_cols_done = []
         self._attributes = list(series.keys())
 
@@ -88,7 +84,7 @@ class Sample(PathExAttMap):
             originally provided via the sample sheet (i.e., the a map-like
             representation of the instance, excluding derived items)
         """
-        return OrderedDict([[k, getattr(self, k)] for k in self._attributes])
+        return dict([[k, self[k]] for k in self._attributes])
 
     def to_dict(self, add_prj_ref=False):
         """
@@ -108,18 +104,10 @@ class Sample(PathExAttMap):
             :param str name: name of the object to represent.
             :param Iterable[str] to_skip: names of attributes to ignore.
             """
-            from pandas import Series, isnull
-
             if name:
-                _LOGGER.log(5, "Converting to dict: {}".format(name))
+                _LOGGER.log(5, "Converting to dict: {name}")
             if isinstance(obj, list):
                 return [_obj2dict(i) for i in obj]
-            if isinstance(obj, AttMap):
-                return {
-                    k: _obj2dict(v, name=k)
-                    for k, v in obj.items()
-                    if not k.startswith("_")
-                }
             elif isinstance(obj, Mapping):
                 return {
                     k: _obj2dict(v, name=k)
@@ -269,7 +257,7 @@ class Sample(PathExAttMap):
             return None
         sn = self[SAMPLE_NAME_ATTR] if SAMPLE_NAME_ATTR in self else "this sample"
         try:
-            source_key = getattr(self, attr_name)
+            source_key = self[attr_name]
         except AttributeError:
             reason = (
                 "'{attr}': to locate sample's derived attribute source, "
@@ -282,31 +270,24 @@ class Sample(PathExAttMap):
 
         try:
             regex = data_sources[source_key]
-            _LOGGER.debug("Data sources: {}".format(data_sources))
+            _LOGGER.debug("Data sources: {data_sources}")
         except KeyError:
             _LOGGER.debug(
-                "{}: config lacks entry for {} key: "
-                "'{}' in column '{}'; known: {}".format(
-                    sn, DERIVED_SOURCES_KEY, source_key, attr_name, data_sources.keys()
-                )
+                f"{sn}: config lacks entry for {DERIVED_SOURCES_KEY} key: "
+                f"'{source_key}' in column '{attr_name}'; known: {data_sources.keys()}"
             )
             return ""
         deriv_exc_base = (
-            "In sample '{sn}' cannot correctly parse derived "
-            "attribute source: {r}.".format(sn=sn, r=regex)
+            f"In sample '{sn}' cannot correctly parse derived "
+            f"attribute source: {regex}."
         )
         try:
             vals = _format_regex(regex, dict(self.items()))
             _LOGGER.debug("Formatted regex: {}".format(vals))
         except KeyError as ke:
-            _LOGGER.warning(
-                deriv_exc_base + " Can't access {ke} attribute".format(ke=str(ke))
-            )
+            _LOGGER.warning(f"{deriv_exc_base} Can't access {str(ke)} attribute")
         except Exception as e:
-            _LOGGER.warning(
-                deriv_exc_base
-                + " Caught exception: {e}".format(e=getattr(e, "message", repr(e)))
-            )
+            _LOGGER.warning(f"{deriv_exc_base} Caught exception: {str(e)}")
         else:
             return _glob_regex(vals)
         return None
@@ -320,18 +301,6 @@ class Sample(PathExAttMap):
         """
         return self[PRJ_REF]
 
-    def __setattr__(self, key, value):
-        self._try_touch_samples()
-        super(Sample, self).__setattr__(key, value)
-
-    def __delattr__(self, item):
-        self._try_touch_samples()
-        super(Sample, self).__delattr__(item)
-
-    def __setitem__(self, key, value):
-        self._try_touch_samples()
-        super(Sample, self).__setitem__(key, value)
-
     # The __reduce__ function provides an interface for
     # correct object serialization with the pickle module.
     def __reduce__(self):
@@ -343,6 +312,9 @@ class Sample(PathExAttMap):
             iter({PRJ_REF: self[PRJ_REF]}.items()),
         )
 
+    def __len__(self):
+        return len(self.to_dict())
+
     def __str__(self, max_attr=10):
         """Representation in interpreter."""
         if len(self) == 0:
@@ -363,15 +335,24 @@ class Sample(PathExAttMap):
         attrs = ""
         counter = 0
         for k, v in pub_attrs.items():
-            attrs += "\n{}{}".format(
-                (k + ":").ljust(maxlen), v if not isinstance(v, list) else ", ".join(v)
-            )
+            key_to_show = (k + ":").ljust(maxlen)
+            if not isinstance(v, list):
+                val_to_show = v
+            else:
+                try:
+                    val_to_show = ", ".join([i for i in v if v is not None])
+                except TypeError:
+                    val_to_show = "None"
+            attrs += f"\n{key_to_show}{val_to_show}"
             if counter == max_attr:
                 attrs += "\n\n...".ljust(maxlen) + f"(showing first {max_attr})"
                 break
             counter += 1
         return head + "\n" + attrs
 
+    def __repr__(self):
+        return str(self)
+
     def _excl_from_eq(self, k):
         """Exclude the Project reference from object comparison."""
         return k == PRJ_REF or super(Sample, self)._excl_from_eq(k)
diff --git a/peppy/simple_attr_map.py b/peppy/simple_attr_map.py
new file mode 100644
index 0000000..9da52c2
--- /dev/null
+++ b/peppy/simple_attr_map.py
@@ -0,0 +1,44 @@
+from collections.abc import MutableMapping
+
+
+class SimpleAttMap(MutableMapping):
+    """
+    Simplified the AttrMap class, which enables storing key-value pairs in
+    a dictionary-like structure.
+    It allows assigning and accessing the values both through attributes and items.
+    In most cases used as SuperClass.
+    """
+
+    def __init__(self):
+        super(SimpleAttMap, self).__init__()
+        super(SimpleAttMap, self).__setattr__("_mapped_attr", {})
+
+    def __delitem__(self, key):
+        value = self[key]
+        del self._mapped_attr[key]
+        self.pop(value, None)
+
+    def __setitem__(self, item, value):
+        self._try_touch_samples()
+        self._mapped_attr[item] = value
+
+    def __getitem__(self, item):
+        return self._mapped_attr[item]
+
+    def __iter__(self):
+        return iter(self._mapped_attr)
+
+    def __len__(self):
+        return len(self._mapped_attr)
+
+    def __contains__(self, key):
+        return key in list(self.keys())
+
+    def __delattr__(self, key):
+        del self[key]
+
+    def __setattr__(self, item, value):
+        self._mapped_attr[item] = value
+
+    def __getattr__(self, item):
+        return self._mapped_attr[item]
diff --git a/peppy/utils.py b/peppy/utils.py
index bc2a183..123a2a0 100644
--- a/peppy/utils.py
+++ b/peppy/utils.py
@@ -8,9 +8,8 @@ from urllib.request import urlopen
 import yaml
 from ubiquerg import expandpath, is_url
 
-from .exceptions import *
-
 from .const import CONFIG_KEY, SAMPLE_TABLE_INDEX_KEY, SUBSAMPLE_TABLE_INDEX_KEY
+from .exceptions import *
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -75,7 +74,6 @@ def grab_project_data(prj):
         return prj[CONFIG_KEY].to_dict()
     except KeyError:
         raise KeyError("Project lacks section '{}'".format(CONFIG_KEY))
-    return data
 
 
 def make_list(arg, obj_class):
diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt
index 9436a3f..737868a 100644
--- a/requirements/requirements-all.txt
+++ b/requirements/requirements-all.txt
@@ -1,5 +1,5 @@
-attmap>=0.13.2
 pandas>=0.24.2
 pyyaml
 rich>=10.3.0
 ubiquerg>=0.6.2
+yacman>=0.9.0
diff --git a/setup.py b/setup.py
index 75528d6..ac84c38 100644
--- a/setup.py
+++ b/setup.py
@@ -1,5 +1,6 @@
-import sys
 import os
+import sys
+
 from setuptools import find_packages, setup
 
 PACKAGE_NAME = "peppy"
@@ -47,16 +48,15 @@ setup(
     classifiers=[
         "Development Status :: 4 - Beta",
         "License :: OSI Approved :: BSD License",
-        "Programming Language :: Python :: 3.6",
-        "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: 3.10",
+        "Programming Language :: Python :: 3.11",
         "Topic :: Scientific/Engineering :: Bio-Informatics",
     ],
     keywords="project, metadata, bioinformatics, sequencing, ngs, workflow",
     url="https://github.com/pepkit/peppy/",
-    author="Michal Stolarczyk, Nathan Sheffield, Vince Reuter, Andre Rendeiro",
+    author="Michal Stolarczyk, Nathan Sheffield, Vince Reuter, Andre Rendeiro, Oleksandr Khoroshevskyi",
     license="BSD2",
     include_package_data=True,
     tests_require=(["pytest"]),


Test patch:
diff --git a/.github/workflows/run-pytest.yml b/.github/workflows/run-pytest.yml
index 8918af5..11bc86f 100644
--- a/.github/workflows/run-pytest.yml
+++ b/.github/workflows/run-pytest.yml
@@ -4,14 +4,14 @@ on:
   push:
     branches: [dev]
   pull_request:
-    branches: [master]
+    branches: [master, dev]
 
 jobs:
   pytest:
     runs-on: ${{ matrix.os }}
     strategy:
       matrix:
-        python-version: ["3.8", "3.9", "3.10"]
+        python-version: ["3.8", "3.11"]
         os: [ubuntu-20.04]
 
     steps:
diff --git a/tests/conftest.py b/tests/conftest.py
index de98c0e..90e93a9 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,10 +1,10 @@
 """ Configuration for modules with independent tests of models. """
 
+import json
 import os
 
 import pandas as pd
 import pytest
-import json
 
 __author__ = "Michal Stolarczyk"
 __email__ = "michal.stolarczyk@nih.gov"
diff --git a/tests/smoketests/test_Project.py b/tests/smoketests/test_Project.py
index eda4610..197ba9b 100644
--- a/tests/smoketests/test_Project.py
+++ b/tests/smoketests/test_Project.py
@@ -44,7 +44,7 @@ class TestSampleModifiers:
         """
         p = Project(cfg=example_pep_cfg_path)
         assert all(["file_path" in s for s in p.samples])
-        assert all(["file_path" in s["_derived_cols_done"] for s in p.samples])
+        assert all(["file_path" in s._derived_cols_done for s in p.samples])
 
     @pytest.mark.parametrize("example_pep_cfg_path", ["remove"], indirect=True)
     def test_remove(self, example_pep_cfg_path):
diff --git a/tests/smoketests/test_Sample.py b/tests/smoketests/test_Sample.py
index 58de9a4..1fdf89f 100644
--- a/tests/smoketests/test_Sample.py
+++ b/tests/smoketests/test_Sample.py
@@ -27,7 +27,7 @@ EXAMPLE_TYPES = [
 ]
 
 
-class SampleTests:
+class TestSample:
     @pytest.mark.parametrize("example_pep_cfg_path", ["basic"], indirect=True)
     def test_serialization(self, example_pep_cfg_path):
         """
@@ -37,9 +37,9 @@ class SampleTests:
         fn = os.path.join(td, "serialized_sample.yaml")
         p = Project(cfg=example_pep_cfg_path)
         sample = p.samples[0]
-        sample.set = set(["set"])
-        sample.dict = dict({"dict": "dict"})
-        sample.list = list(["list"])
+        sample["set"] = set("set")
+        sample["dict"] = dict({"dict": "dict"})
+        sample["list"] = list(["list"])
         sample.to_yaml(fn)
         with open(fn, "r") as f:
             contents = f.read()
@@ -56,7 +56,7 @@ class SampleTests:
         for sample in p.samples:
             str_repr = sample.__str__(max_attr=100)
             assert example_pep_cfg_path in str_repr
-            assert "Sample '{}'".format(sample.sample_name) in str_repr
+            assert "Sample '{}'".format(sample["sample_name"]) in str_repr
 
     @pytest.mark.parametrize("example_pep_cfg_path", ["basic"], indirect=True)
     def test_sheet_dict_excludes_private_attrs(self, example_pep_cfg_path):
diff --git a/tests/test_Project.py b/tests/test_Project.py
index 751c4ef..7a4f33c 100644
--- a/tests/test_Project.py
+++ b/tests/test_Project.py
@@ -65,9 +65,8 @@ def _cmp_all_samples_attr(p1, p2, attr):
     :param attr: attribute name to compare
     :type attr: str
     """
-
-    assert [getattr(s, attr, None) for s in p1.samples] == [
-        getattr(s, attr, None) for s in p2.samples
+    assert [s1.get(attr, "") for s1 in p1.samples] == [
+        s2.get(attr, "") for s2 in p2.samples
     ]
 
 
@@ -245,7 +244,7 @@ class TestProjectConstructor:
         """
         p = Project(cfg=example_pep_cfg_path, defer_samples_creation=defer)
         assert isinstance(p, Project)
-        assert "description" in p and p.description is None
+        assert p.description is None
 
     @pytest.mark.parametrize("defer", [False, True])
     @pytest.mark.parametrize("desc", ["desc1", "desc 2 <test> 123$!@#;11", 11, None])
@@ -264,7 +263,7 @@ class TestProjectConstructor:
             dump(data, f)
         p = Project(cfg=temp_path_cfg, defer_samples_creation=defer)
         assert isinstance(p, Project)
-        assert "description" in p and p.description == str(desc)
+        assert p.description == str(desc)
 
     @pytest.mark.parametrize(
         "example_pep_cfg_noname_path", ["project_config.yaml"], indirect=True
@@ -390,7 +389,7 @@ class TestProjectConstructor:
         self, example_pep_cfg_path, expected_attribute
     ):
         p = Project(example_pep_cfg_path, sample_table_index="sample")
-        assert all([hasattr(sample, expected_attribute) for sample in p.samples])
+        assert all([expected_attribute in sample for sample in p.samples])
 
 
 class TestProjectManipulationTests:
@@ -543,8 +542,11 @@ class TestPostInitSampleCreation:
         p2 = Project(cfg=example_pep_cfg_path)
         assert p1 == p2
 
-    @pytest.mark.parametrize("example_pep_cfg_path", ["append"], indirect=True)
-    @pytest.mark.parametrize("example_pep_csv_path", ["derive"], indirect=True)
+    @pytest.mark.parametrize(
+        "example_pep_cfg_path, example_pep_csv_path",
+        [["append", "derive"]],
+        indirect=True,
+    )
     def test_unequality(self, example_pep_cfg_path, example_pep_csv_path):
         """
         Test equality function of two projects
@@ -569,13 +571,99 @@ class TestPostInitSampleCreation:
         p2 = Project().from_dict(p1_dict)
         assert p1 == p2
 
-    @pytest.mark.parametrize("config_with_pandas_obj", ["append"], indirect=True)
-    @pytest.mark.parametrize("example_pep_csv_path", ["append"], indirect=True)
+    @pytest.mark.parametrize(
+        "config_with_pandas_obj, example_pep_csv_path",
+        [
+            ["append", "append"],
+            ["derive", "derive"],
+            ["subtable1", "subtable1"],
+        ],
+        indirect=True,
+    )
     def test_from_pandas(self, config_with_pandas_obj, example_pep_csv_path):
         """
         Test initializing project from dict
         """
         p1 = Project().from_pandas(config_with_pandas_obj)
         p2 = Project(example_pep_csv_path)
-
         assert p1 == p2
+
+    @pytest.mark.parametrize(
+        "config_with_pandas_obj, example_pep_csv_path",
+        [
+            ["append", "append"],
+            ["derive", "derive"],
+            ["subtable1", "subtable1"],
+        ],
+        indirect=True,
+    )
+    def test_from_pandas_unequal(self, config_with_pandas_obj, example_pep_csv_path):
+        """
+        Test initializing project from pandas changing one of the samples
+        and checking inequality
+        """
+        p1 = Project().from_pandas(config_with_pandas_obj)
+
+        del p1.samples[0].sample_name
+        p2 = Project(example_pep_csv_path)
+        assert p1 != p2
+
+    @pytest.mark.parametrize(
+        "example_pep_cfg_path",
+        ["append"],
+        indirect=True,
+    )
+    def test_description_setter(self, example_pep_cfg_path):
+        new_description = "new_description1"
+        p = Project(cfg=example_pep_cfg_path)
+        p.description = new_description
+
+        assert p.description == new_description
+        assert p.to_dict(extended=True)["_config"]["description"] == new_description
+
+    @pytest.mark.parametrize(
+        "example_pep_cfg_path",
+        ["append"],
+        indirect=True,
+    )
+    def test_name_setter(self, example_pep_cfg_path):
+        new_name = "new_name1"
+        p = Project(cfg=example_pep_cfg_path)
+        p.name = new_name
+
+        assert p.name == new_name
+        assert p.to_dict(extended=True)["_config"]["name"] == new_name
+
+
+class TestSampleAttrMap:
+    @pytest.mark.parametrize("example_pep_cfg_path", ["append"], indirect=True)
+    def test_sample_getattr(self, example_pep_cfg_path):
+        """
+        Verify that the getattr works
+        """
+        p = Project(cfg=example_pep_cfg_path)
+        p1 = Project(cfg=example_pep_cfg_path)
+
+        for s1, s2 in zip(p.samples, p1.samples):
+            assert s1.sample_name == s1["sample_name"]
+            assert s2.organism == s2["organism"]
+
+    @pytest.mark.parametrize("example_pep_cfg_path", ["append"], indirect=True)
+    def test_sample_getattr(self, example_pep_cfg_path):
+        """
+        Verify that the setattr works
+        """
+        p = Project(cfg=example_pep_cfg_path)
+        new_name = "bingo"
+        p.samples[0].sample_name = new_name
+
+        df = p.samples[0].to_dict()
+        assert df["sample_name"] == new_name
+
+    @pytest.mark.parametrize("example_pep_cfg_path", ["append"], indirect=True)
+    def test_sample_len(self, example_pep_cfg_path):
+        """
+        Verify that the len works
+        """
+        p = Project(cfg=example_pep_cfg_path)
+        assert len(p.samples[0]) == 4