Skip to content

Commit

Permalink
Add true attribute access of all leves/tables in the TOML document.
Browse files Browse the repository at this point in the history
  • Loading branch information
Marius Træet Gilberg committed Jan 28, 2024
1 parent 768e817 commit 981a1fd
Show file tree
Hide file tree
Showing 9 changed files with 234 additions and 55 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Testing
testing/

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,17 @@ default_config = {
settings = Configuration("config", default_config, "app_config")
# Creates an `app_config.toml` file in the `config` folder at the current working directory.

# Access and update configuration values
print(settings.app.ip) # Output: '0.0.0.0'
settings.app.ip = "1.2.3.4"
print(settings.app.ip) # Output: '1.2.3.4'

# Access nested configuration values
print(settings.mysql.databases.prod) # Output: 'db1'
settings.mysql.databases.prod = 'new_value'
settings.update()
print(settings.mysql.databases.prod) # Output: 'new_value'

# Access and update configuration values
print(settings.app_ip) # Output: '0.0.0.0'
settings.update_config({"app_ip": "1.2.3.4"})
Expand Down
25 changes: 23 additions & 2 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,26 @@ and this project adheres to [Semantic Versioning].

- /

## [1.1.0] - 2024-01-28

## New Release: v1.1.0 - True Nested Configuration Support with Attribute Access

This release introduces a significant new feature: Nested Configuration Support with Attribute Access.

### What's New

**Nested Configuration Support with Attribute Access:** In previous versions, accessing and updating nested configuration values required dictionary-style access. With this release, we've made it easier and more intuitive to work with nested configuration values. Now, you can access and update these values using attribute-style access, similar to how you would interact with properties of an object in JavaScript.

Here's an example:

```python
# Access nested configuration values
print(settings.mysql.databases.prod) # Output: 'db1'
settings.mysql.databases.prod = 'new_value'
settings.update()
print(settings.mysql.databases.prod) # Output: 'new_value'
```

## [1.0.0] - 2023-08-27

- initial release
Expand All @@ -18,5 +38,6 @@ and this project adheres to [Semantic Versioning].
[semantic versioning]: https://semver.org/spec/v2.0.0.html

<!-- Versions -->
[unreleased]: https://github.com/gilbn/simple-toml-configurator/compare/v0.0.2...HEAD
[1.0.0]: https://github.com/gilbn/simple-toml-configurator/releases/tag/v0.0.1
[unreleased]: https://github.com/gilbn/simple-toml-configurator/compare/1.1.0...HEAD
[1.1.0]: https://github.com/gilbn/simple-toml-configurator/releases/tag/1.1.0
[1.0.0]: https://github.com/gilbn/simple-toml-configurator/releases/tag/1.0.0
2 changes: 1 addition & 1 deletion docs/flask-custom-example.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The `Configuration` class can be extended and customized to cater to application
├── __init__.py
├── app.py
├── utils.py
└── extenstions
└── extensions
└── config.py
```

Expand Down
2 changes: 1 addition & 1 deletion docs/flask-simple-example.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
```
├── __init__.py
├── app.py
└── extenstions
└── extensions
└── config.py
```

Expand Down
68 changes: 52 additions & 16 deletions docs/usage-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ default_config = {
"host": "",
"port": 5000,
"upload_folder": "uploads"
},
"mysql": {
"databases": {
"prod": "db1",
"dev": "db2"
}
}
}

Expand All @@ -22,36 +28,56 @@ settings = Configuration()
settings.init_config("config", default_config, "app_config")
```

### Accessing Configuration Values

You can access configuration values as attributes of the `settings` instance. This attribute-based access makes it straightforward to retrieve settings.
#### Load defaults from a TOML file

```python
# Access configuration values
ip_address = settings.app_ip
port_number = settings.app_port
upload_folder = settings.app_upload_folder
from simple_toml_configurator import Configuration
import tomlkit
import os
from pathlib import Path

default_file_path = Path(os.path.join(os.getcwd(), "default.toml"))
defaults = tomlkit.loads(file_path.read_text())
settings = Configuration("config", defaults, "app_config")
```

### Accessing Configuration Values

You can access configuration values as attributes of the `Configuration` instance. This attribute-based access makes it straightforward to retrieve settings.
There are two main ways to access configuration values:

1. Attribute access:
- This is the default access method. ex: `settings.app.ip`

2. Table prefix access:
- Add the table name as a prefix to the key name. ex: `settings.app_ip` instead of `settings.app.ip`. **This only works for the first level of nesting.**

!!! info Attribute access
If the table contains a nested table, you can access the nested table using the same syntax. ex: `settings.mysql.databases.prod`

!!! note
This works for any level of nesting.

### Updating Configuration Settings

Updating configuration settings is seamless with the Simple TOML Configurator. Use the `update_config` method to modify values while ensuring consistency across instances.
Use the `update_config` or `update` method to modify values while ensuring consistency across instances.

#### update() method

```python
# Update a configuration value
settings.update_config({"app_ip": "1.2.3.4"})
# Update the ip key in the app table
settings.app.ip = "1.2.3.4"
settings.update()
```

### Accessing All Settings

Retrieve all configuration settings as a dictionary using the `get_settings` method. This provides an overview of all configured values.
#### update_config() method

```python
# Get all configuration settings
all_settings = settings.get_settings()
# Update the ip key in the app table
settings.update_config({"app_ip": "1.2.3.4"})
```

### Direct Configuration Modification
#### Update the config dictionary directly

You can directly modify configuration values within the `config` dictionary. After making changes, use the `update` method to write the updated configuration to the file.

Expand All @@ -61,6 +87,16 @@ settings.config["app"]["ip"] = "0.0.0.0"
settings.update()
```

### Accessing All Settings

Retrieve all configuration settings as a dictionary using the `get_settings` method. This provides an overview of all configured values.

```python
# Get all configuration settings
all_settings = settings.get_settings()
print(all_settings) # Output: {'app_ip': '1.2.3.4', 'app_host': '', 'app_port': 5000, 'app_upload_folder': 'uploads'}
```

### Customization with Inheritance

For advanced use cases, you can create a custom configuration class by inheriting from `Configuration`. This allows you to add custom logic and properties tailored to your application.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "Simple-TOML-Configurator"
version = "1.0.1"
version = "1.1.0"
license = {text = "MIT License"}
authors = [{name = "GilbN", email = "[email protected]"}]
description = "A simple TOML configurator for Python"
Expand Down
138 changes: 105 additions & 33 deletions src/simple_toml_configurator/toml_configurator.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from typing import Any
import os
import re
from collections.abc import Mapping
import logging
from pathlib import Path
import tomlkit
from tomlkit import TOMLDocument
from pathlib import Path
import logging

from .exceptions import *

__version__ = "1.0.1"
__version__ = "1.1.0"

logger = logging.getLogger(__name__)

Expand All @@ -28,14 +29,18 @@ class Configuration:
>>> from simple_toml_configurator import Configuration
>>> settings = Configuration("config", default_config, "app_config")
{'app': {'ip': '0.0.0.0', 'host': '', 'port': 5000, 'upload_folder': 'uploads'}}
# Update the config dict directly
>>> settings.app.ip = "1.1.1.1"
>>> settings.update()
>>> settings.app.ip
'1.1.1.1'
>>> settings.get_settings()
{'app_ip': '0.0.0.0', 'app_host': '', 'app_port': 5000, 'app_upload_folder': 'uploads'}
{'app_ip': '1.1.1.1', 'app_host': '', 'app_port': 5000, 'app_upload_folder': 'uploads'}
>>> settings.update_config({"app_ip":"1.2.3.4"})
>>> settings.app_ip
'1.2.3.4'
>>> settings.config.get("app").get("ip")
'1.2.3.4'
# Update the config dict directly
>>> settings.config["app"]["ip"] = "0.0.0.0"
>>> settings.update()
>>> settings.app_ip
Expand Down Expand Up @@ -139,33 +144,58 @@ def init_config(self, config_path:str|Path, defaults:dict[str,dict], config_file
return self.config

def _sync_config_values(self) -> None:
"""Add any new/missing values/tables from self.defaults into the existing TOML config"""
for default_table in self.defaults:
if default_table not in self.config.keys():
self.logger.info("Adding new TOML table: ('%s') to TOML Document", default_table)
self.config[default_table] = self.defaults[default_table]
continue
if default_table in self.config.keys():
for default_key, default_value in self.defaults[default_table].items():
if default_key not in self.config[default_table].keys():
self.logger.info("Adding new Key: ('%s':'***') to Table: ('%s')", default_key, default_table) # pragma: no cover
self.config[default_table][default_key] = default_value # pragma: no cover
"""Add any new/missing values/tables from self.defaults into the existing TOML config
- If a table is missing from the config, it will be added with the default table.
- If a table is missing from the defaults, it will be removed from the config.
If there is a mismatch in types between the default value and the config value, the config value will be replaced with the default value.
For example if the default value is a string and the existing config value is a dictionary, the config value will be replaced with the default value.
"""
def update_dict(current_dict:dict, default_dict:dict) -> dict:
"""Recursively update a dictionary with another dictionary.
Args:
current_dict (dict): The dictionary to update. Loaded from the config file.
default_dict (dict): The dictionary to update with. Loaded from the defaults.
Returns:
dict: The updated dictionary
"""
for key, value in default_dict.items():
if isinstance(value, Mapping):
if not isinstance(current_dict.get(key, {}), Mapping):
logger.warning("Mismatched types for key '%s': expected dictionary, got %s. Replacing with new dictionary.", key, type(current_dict.get(key))) # pragma: no cover
current_dict[key] = {}
if key not in current_dict:
logger.info("Adding new Table: ('%s')", key) # pragma: no cover
current_dict[key] = update_dict(current_dict.get(key, {}), value)
else:
if key not in current_dict:
logger.info("Adding new Key: ('%s':'***') in table: %s", key, current_dict) # pragma: no cover
current_dict[key] = value
return current_dict

self.config = update_dict(self.config, self.defaults)
self._write_config_to_file()
self._clear_old_config_values()

def _clear_old_config_values(self) -> None:
"""Remove any old values/tables from self.config that are not in self.defaults
"""
for table in self.config:
if table not in self.defaults.keys():
self.config.remove(table) # pragma: no cover
self._write_config_to_file() # pragma: no cover
return self._clear_old_config_values() # pragma: no cover
for key in list(self.config[table].keys()):
if key not in self.defaults[table]:
self.config[table].remove(key)
self._write_config_to_file()
continue
def remove_keys(config:dict, defaults:dict) -> None:
# Create a copy of config to iterate over
config_copy = config.copy()

# Remove keys that are in config but not in defaults
for key in config_copy:
if key not in defaults:
del config[key]
logger.info("Removing Key: ('%s') in Table: ( '%s' )", key, config_copy)
elif isinstance(config[key], Mapping):
remove_keys(config[key], defaults[key])

remove_keys(self.config, self.defaults)
self._write_config_to_file()

def get_settings(self) -> dict[str, Any]:
"""Get all config key values as a dictionary.
Expand Down Expand Up @@ -203,6 +233,7 @@ def _set_attributes(self) -> dict[str, Any]:
dict[str, Any]: Returns all attributes in a dictionary
"""
for table in self.config:
setattr(self,table,ConfigObject(self.config[table]))
for key, value in self.config[table].items():
setattr(self, f"_{table}_{key}", value)
setattr(self, f"{table}_{key}", value)
Expand Down Expand Up @@ -248,12 +279,14 @@ def update(self):
>>> settings = Configuration()
>>> defaults = {"mysql": {"databases": {"prod":"prod_db1", "dev":"dev_db1"}}}
>>> settings.init_config("config", defaults, "app_config")
>>> settings.mysql_databases["prod"]
'prod_db1'
>>> settings.config["mysql"]["databases"]["prod"] = "prod_db2"
>>> settings.mysql.databases.prod = "prod_db2"
>>> settings.update()
>>> {settings.mysql_databases["prod"]}
>>> settings.config["mysql"]["databases"]["prod"]
'prod_db2'
>>> settings.config["mysql"]["databases"]["prod"] = "prod_db3"
>>> settings.update()
>>> settings.mysql_databases["prod"]
'prod_db3'
```
"""
self._write_config_to_file()
Expand All @@ -264,7 +297,10 @@ def _write_config_to_file(self) -> None:
self.logger.debug("Writing config to file")
try:
with Path(self._full_config_path).open("w") as conf:
conf.write(tomlkit.dumps(self.config))
toml_document = tomlkit.dumps(self.config)
# Use regular expression to replace consecutive empty lines with a single newline
cleaned_toml = re.sub(r'\n{3,}', '\n\n', toml_document)
conf.write(cleaned_toml)
except (OSError,FileNotFoundError,TypeError) as exc: # pragma: no cover
self.logger.exception("Could not write config file!")
raise TOMLWriteConfigError("unable to write config file!") from exc # pragma: no cover
Expand Down Expand Up @@ -302,4 +338,40 @@ def _create_config(self, config_file_path:str) -> None:
conf.write(tomlkit.dumps(self.defaults))
except OSError as exc: # pragma: no cover
self.logger.exception("Could not create config file!")
raise TOMLCreateConfigError(f"unable to create config file: ({config_file_path})") from exc
raise TOMLCreateConfigError(f"unable to create config file: ({config_file_path})") from exc

class ConfigObject:
"""
Represents a configuration object that wraps a dictionary and provides attribute access.
Any key in the dictionary can be accessed as an attribute.
Args:
table (dict): The dictionary representing the configuration.
Attributes:
_table (dict): The internal dictionary representing the configuration.
"""

def __init__(self, table: dict):
self._table = table
for key, value in table.items():
if isinstance(value, dict):
self.__dict__[key] = ConfigObject(value)
else:
self.__dict__[key] = value

def __setattr__(self, __name: str, __value: Any) -> None:
"""Update the table value when an attribute is set"""
super().__setattr__(__name, __value)
if __name == "_table":
return
if hasattr(self, "_table") and __name in self._table:
self._table[__name] = __value

def __repr__(self) -> str:
return f"ConfigObject({self._table})"

def __str__(self) -> str:
return f"<ConfigObject> {self._table}"
Loading

0 comments on commit 981a1fd

Please sign in to comment.