Repository: palazzem/pysettings

Issue:
Unable to clone/copy/deepcopy settings
### Overview

Whenever `settings.clone()` or `copy.copy(settings)` or `copy.deepcopy(settings)` are used, the action raises the following exception:
```
pysettings.exceptions.OptionNotAvailable: the option is not present in the current config
```

We should permit such actions, especially to simplify settings changes during testing.

Comments:

Patch:
diff --git a/README.md b/README.md
index cfb6c5a..3f4e408 100644
--- a/README.md
+++ b/README.md
@@ -105,6 +105,54 @@ settings.description = "Dry run mode!"
 settings.is_valid()  # raises ConfigNotValid exception
 ```
 
+### Test your Settings (pytest)
+
+If you need to change some of your settings during tests, you can use the following snippet
+to restore the previous settings after each test:
+
+```python
+# tests/fixtures/settings.py
+from pysettings.base import BaseSettings
+from pysettings.options import Option
+from pysettings.validators import is_https_url
+
+# Class definition
+class TestSettings(BaseSettings):
+    url = Option(validators=[is_https_url])
+    description = Option()
+
+# Use settings in your application
+settings = TestSettings()
+settings.url = "https://example.com"
+settings.description = "A shiny Website!"
+settings.is_valid()
+
+# tests/conftest.py
+import copy
+import pytest
+
+from .fixtures import settings as config
+
+
+@pytest.fixture
+def settings():
+    previous_config = copy.deepcopy(config.settings)
+    yield config.settings
+    config.settings = previous_config
+
+# tests/test_settings.py
+def test_settings_changes_1(settings):
+    assert settings.description == "A shiny Website!"
+    settings.description = "Test 1"
+    assert settings.description == "Test 1"
+
+
+def test_settings_changes_2(settings):
+    assert settings.description == "A shiny Website!"
+    settings.description = "Test 2"
+    assert settings.description == "Test 2"
+```
+
 ## Development
 
 We accept external contributions even though the project is mostly designed for personal
diff --git a/pysettings/base.py b/pysettings/base.py
index 3fe864d..9a00916 100644
--- a/pysettings/base.py
+++ b/pysettings/base.py
@@ -1,3 +1,5 @@
+import copy
+
 from .options import Option
 from .exceptions import OptionNotAvailable, ConfigNotValid
 
@@ -25,7 +27,11 @@ class BaseSettings(object):
         for attr, value in self.__class__.__dict__.items():
             option = self._get_option(attr)
             if isinstance(option, Option):
+                # Store the attribute name in the options list and copy the
+                # Option object to avoid sharing the same instance across
+                # settings instances
                 self._options.append(attr)
+                object.__setattr__(self, attr, copy.deepcopy(option))
 
     def __setattr__(self, name, value):
         """Config attributes must be of type Option. This setattr() ensures that the
@@ -68,12 +74,18 @@ class BaseSettings(object):
         find the attribute. In that case it means the given attribute is not
         a configuration option that was defined as class attributes.
 
+        Internal attributes are still accessible.
+
         Args:
             name: the attribute name to retrieve.
         Raise:
             OptionNotAvailable: the configuration option is not present.
         """
-        raise OptionNotAvailable("the option is not present in the current config")
+        if name.startswith("__") and name.endswith("__"):
+            # Providing access to internal attributes is required for pickle and copy
+            return object.__getattribute__(self, name)
+        else:
+            raise OptionNotAvailable("the option is not present in the current config")
 
     def _get_option(self, name):
         """Retrieve the Option instance instead of proxying the call to retrieve


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..57e8978
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,11 @@
+import copy
+import pytest
+
+from .fixtures import settings as config
+
+
+@pytest.fixture
+def settings():
+    previous_config = copy.deepcopy(config.settings)
+    yield config.settings
+    config.settings = previous_config
diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/fixtures/settings.py b/tests/fixtures/settings.py
new file mode 100644
index 0000000..75761c9
--- /dev/null
+++ b/tests/fixtures/settings.py
@@ -0,0 +1,16 @@
+from pysettings.base import BaseSettings
+from pysettings.options import Option
+from pysettings.validators import is_https_url
+
+
+# Class definition
+class TestSettings(BaseSettings):
+    url = Option(validators=[is_https_url])
+    description = Option()
+
+
+# Use settings in your application
+settings = TestSettings()
+settings.url = "https://example.com"
+settings.description = "A shiny Website!"
+settings.is_valid()
diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py
new file mode 100644
index 0000000..1e15763
--- /dev/null
+++ b/tests/test_fixtures.py
@@ -0,0 +1,10 @@
+def test_settings_changes_1(settings):
+    assert settings.description == "A shiny Website!"
+    settings.description = "Test 1"
+    assert settings.description == "Test 1"
+
+
+def test_settings_changes_2(settings):
+    assert settings.description == "A shiny Website!"
+    settings.description = "Test 2"
+    assert settings.description == "Test 2"
diff --git a/tests/test_settings.py b/tests/test_settings.py
index d00d0f9..b672d3f 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -1,4 +1,5 @@
 import pytest
+import copy
 
 from pysettings.base import BaseSettings
 from pysettings.options import Option
@@ -41,6 +42,18 @@ def test_config_get_value():
     assert config.home == "test"
 
 
+def test_config_instance():
+    """Should return a new instance of the config and not use the Class options"""
+
+    class SettingsTest(BaseSettings):
+        home = Option(default="klass")
+
+    config = SettingsTest()
+    config.home = "instance"
+    assert SettingsTest.home.value == "klass"
+    assert config.home == "instance"
+
+
 def test_config_set_value_not_available():
     """Should raise an exception if the option is not present"""
 
@@ -132,3 +145,33 @@ def test_config_is_not_valid_exception(mocker):
 
     with pytest.raises(ConfigNotValid):
         assert config.is_valid()
+
+
+def test_config_deepcopy():
+    """Should clone the configuration when deepcopy() is called"""
+
+    class SettingsTest(BaseSettings):
+        home = Option(default="original")
+
+    config = SettingsTest()
+    clone_config = copy.deepcopy(config)
+    clone_config.home = "clone"
+    # Should be different Settings
+    assert config != clone_config
+    assert config.home == "original"
+    assert clone_config.home == "clone"
+
+
+def test_config_copy():
+    """Should clone the configuration when copy() is called"""
+
+    class SettingsTest(BaseSettings):
+        home = Option(default="original")
+
+    config = SettingsTest()
+    clone_config = copy.copy(config)
+    clone_config.home = "shallow-copy"
+    # Should be different Settings but same Option (shallow copy)
+    assert config != clone_config
+    assert config.home == "shallow-copy"
+    assert clone_config.home == "shallow-copy"