Skip to content

Commit

Permalink
fix(set): non-str key raising type error #1005 (#1008)
Browse files Browse the repository at this point in the history
fix bug reported on #1005
  • Loading branch information
pedro-psb committed Sep 28, 2023
1 parent 9170beb commit 4ed2350
Show file tree
Hide file tree
Showing 6 changed files with 115 additions and 35 deletions.
16 changes: 12 additions & 4 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,22 +396,30 @@ mixed settings formats across your application.

#### Supported formats

Create your settings in the desired format and specify it on `settings_files`
argument on your dynaconf instance or pass it in `-f <format>` if using `dynaconf init` command.

The following are the currently supported formats:

- **.toml** - Default and **recommended** file format.
- **.yaml|.yml** - Recommended for Django applications.
- **.json** - Useful to reuse existing or exported settings.
- **.ini** - Useful to reuse legacy settings.
- **.py** - **Not Recommended** but supported for backwards compatibility.
- **.env** - Useful to automate the loading of environment variables.

!!! info
Create your settings in the desired format and specify it on `settings_files`
argument on your dynaconf instance or pass it in `-f <format>` if using `dynaconf init` command.

!!! tip
Can't find the file format you need for your settings?
You can create your custom loader and read any data source.
read more on [extending dynaconf](/advanced/)

#### Key types

Dynaconf will try to preserve non-string integers such as `1: foo` in yaml,
or arbitrary types defined within python, like `settings.set("a", {1: "b", (1,2): "c"})`.

This is intended for special cases only, as envvars and most file loaders won't support non-string key types.

#### Reading settings from files

On files by default dynaconf loads all the existing keys and sections
Expand Down
67 changes: 37 additions & 30 deletions dynaconf/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,23 +476,24 @@ def get(
sysenv_fallback = self._store.get("SYSENV_FALLBACK_FOR_DYNACONF")

nested_sep = self._store.get("NESTED_SEPARATOR_FOR_DYNACONF")
if nested_sep and nested_sep in key:
# turn FOO__bar__ZAZ in `FOO.bar.ZAZ`
key = key.replace(nested_sep, ".")

if dotted_lookup is empty:
dotted_lookup = self._store.get("DOTTED_LOOKUP_FOR_DYNACONF")

if "." in key and dotted_lookup:
return self._dotted_get(
dotted_key=key,
default=default,
cast=cast,
fresh=fresh,
parent=parent,
)
if isinstance(key, str):
if nested_sep and nested_sep in key:
# turn FOO__bar__ZAZ in `FOO.bar.ZAZ`
key = key.replace(nested_sep, ".")

if dotted_lookup is empty:
dotted_lookup = self._store.get("DOTTED_LOOKUP_FOR_DYNACONF")

if "." in key and dotted_lookup:
return self._dotted_get(
dotted_key=key,
default=default,
cast=cast,
fresh=fresh,
parent=parent,
)

key = upperfy(key)
key = upperfy(key)

# handles system environment fallback
if default is None:
Expand Down Expand Up @@ -913,7 +914,7 @@ def set(
):
"""Set a value storing references for the loader
:param key: The key to store
:param key: The key to store. Can be of any type.
:param value: The raw value to parse and store
:param loader_identifier: Optional loader name e.g: toml, yaml etc.
Or isntance of SourceMetadata
Expand All @@ -938,20 +939,21 @@ def set(
if dotted_lookup is empty:
dotted_lookup = self.get("DOTTED_LOOKUP_FOR_DYNACONF")
nested_sep = self.get("NESTED_SEPARATOR_FOR_DYNACONF")
if nested_sep and nested_sep in key:
key = key.replace(nested_sep, ".") # FOO__bar -> FOO.bar

if "." in key and dotted_lookup is True:
return self._dotted_set(
key,
value,
loader_identifier=source_metadata,
tomlfy=tomlfy,
validate=validate,
)
if isinstance(key, str):
if nested_sep and nested_sep in key:
key = key.replace(nested_sep, ".") # FOO__bar -> FOO.bar

if "." in key and dotted_lookup is True:
return self._dotted_set(
key,
value,
loader_identifier=source_metadata,
tomlfy=tomlfy,
validate=validate,
)
key = upperfy(key.strip())

parsed = parse_conf_data(value, tomlfy=tomlfy, box_settings=self)
key = upperfy(key.strip())

# Fix for #869 - The call to getattr trigger early evaluation
existing = (
Expand Down Expand Up @@ -1001,7 +1003,12 @@ def set(
# Set the parsed value
self.store[key] = parsed
self._deleted.discard(key)
super().__setattr__(key, parsed)

# check if str because we can't directly set/get non-str with obj. e.g.
# setting.1
# settings.(1,2)
if isinstance(key, str):
super().__setattr__(key, parsed)

# Track history for inspect, store the raw_value
if source_metadata in self._loaded_by_loaders:
Expand Down
6 changes: 5 additions & 1 deletion dynaconf/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,9 @@ def isnamedtupleinstance(value):


def find_the_correct_casing(key: str, data: dict[str, Any]) -> str | None:
"""Given a key, find the proper casing in data
"""Given a key, find the proper casing in data.
Return 'None' for non-str key types.
Arguments:
key {str} -- A key to be searched in data
Expand All @@ -473,6 +475,8 @@ def find_the_correct_casing(key: str, data: dict[str, Any]) -> str | None:
if not isinstance(key, str) or key in data:
return key
for k in data.keys():
if not isinstance(k, str):
return None
if k.lower() == key.lower():
return k
if k.replace(" ", "_").lower() == key.lower():
Expand Down
20 changes: 20 additions & 0 deletions tests/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,26 @@ def test_set_new_merge_issue_241_5(tmpdir):
}


def test_set_with_non_str_types():
"""This replicates issue #1005 in a simplified setup."""
settings = Dynaconf(merge_enabled=True)
settings.set("a", {"b": {1: "foo"}})
settings.set("a", {"b": {"c": "bar"}})
assert settings["a"]["b"][1] == "foo"
assert settings["a"]["b"]["c"] == "bar"


def test_set_with_non_str_types_on_first_level():
"""Non-str key types on first level."""
settings = Dynaconf(merge_enabled=True)
settings.set(1, {"b": {1: "foo"}})
settings.set("a", {"1": {1: "foo"}})
assert settings[1]["b"][1] == "foo"
assert settings[1].b[1] == "foo"
assert settings.get(1).b[1] == "foo"
assert settings["a"]["1"][1] == "foo"


def test_exists(settings):
settings.set("BOOK", "TAOCP")
assert settings.exists("BOOK") is True
Expand Down
4 changes: 4 additions & 0 deletions tests_functional/issues/1005-key-type-error/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.PHONY: test

test:
pytest -v app_test.py
37 changes: 37 additions & 0 deletions tests_functional/issues/1005-key-type-error/app_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""
https://github.com/dynaconf/dynaconf/issues/1005
"""
from __future__ import annotations

from textwrap import dedent

from dynaconf import Dynaconf


def create_file(fn: str, data: str):
with open(fn, "w") as file:
file.write(dedent(data))
return fn


def test_issue_1005(tmp_path):
file_a = create_file(
tmp_path / "t.yaml",
"""\
default:
a_mapping:
1: ["a_string"]
""",
)
create_file(
tmp_path / "t.local.yaml",
"""\
default:
a_value: .inf
""",
)

settings = Dynaconf(settings_file=file_a, merge_enabled=True)

assert settings.as_dict()["DEFAULT"]["a_mapping"]
assert settings.as_dict()["DEFAULT"]["a_value"]

0 comments on commit 4ed2350

Please sign in to comment.