Skip to content

Commit 029bd56

Browse files
committed
add sample settings.py module
1 parent e1800eb commit 029bd56

File tree

6 files changed

+214
-21
lines changed

6 files changed

+214
-21
lines changed

src/pkgmt/assets/template/pyproject-setup.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ build-backend = "setuptools.build_meta"
3434

3535
[project]
3636
name = "$project_name"
37-
version = "0.0.1"
37+
version = "0.1dev"
3838
description = "A sample Python project"
3939
readme = "README.md"
4040
requires-python = ">=3.9"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# import os
2+
from pathlib import Path
3+
4+
# from dotenv import load_dotenv
5+
6+
# load_dotenv()
7+
8+
path_to_home = Path.home()
9+
10+
PATH_TO_PROJECT_ROOT = Path(__file__).parent
11+
12+
# PATH_TO_DB = path_to_home / "data" / "db.sqlite"
13+
# LOCAL_DB_URI = f"sqlite:///{PATH_TO_DB}"
14+
# OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
1+
# flake8: noqa
2+
from $package_name._settings import Settings
3+
4+
SETTINGS = Settings()
5+
16
__version__ = "0.1dev"
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
from pathlib import Path
2+
import sys
3+
from copy import copy
4+
from inspect import getmembers
5+
import importlib
6+
import os
7+
from contextlib import contextmanager
8+
9+
10+
@contextmanager
11+
def add_to_sys_path(path):
12+
"""Add the given path to sys.path for the duration of the context"""
13+
path = os.path.abspath(path)
14+
sys.path.insert(0, path)
15+
16+
try:
17+
yield
18+
finally:
19+
sys.path.remove(path)
20+
21+
22+
class BaseSchema:
23+
"""A base object to define a schema for settings validation"""
24+
25+
@staticmethod
26+
def _get_public_attributes(obj):
27+
return {k.upper(): v for k, v in obj.__dict__.items() if not k.startswith("_")}
28+
29+
@classmethod
30+
def _validate(cls, settings):
31+
validators = cls._get_public_attributes(cls)
32+
validators_docs = {k: v.__doc__ for k, v in validators.items()}
33+
34+
missing = sorted(set(validators) - set(settings))
35+
36+
if missing:
37+
missing_with_docs = {
38+
k: v for k, v in validators_docs.items() if k in missing
39+
}
40+
41+
formatted_error = "\n".join(
42+
[f"{k}: {v}" for k, v in missing_with_docs.items()]
43+
)
44+
45+
raise RuntimeError(
46+
f"Error validating settings, missing:\n\n{formatted_error}"
47+
)
48+
49+
unexpected = sorted(set(settings) - set(validators))
50+
51+
if unexpected:
52+
formatted_error = "\n".join([k for k in unexpected])
53+
54+
raise RuntimeError(
55+
f"Error validating settings, unexpected:\n\n{formatted_error}"
56+
)
57+
58+
matched = set(validators) & set(settings)
59+
60+
for match in matched:
61+
try:
62+
validators[match](settings[match])
63+
except Exception as e:
64+
raise RuntimeError(
65+
f"Error validating settings, the validator for {match} "
66+
f"failed: {str(e)}"
67+
) from e
68+
69+
70+
class Schema(BaseSchema):
71+
"""The schema that validates the settings
72+
73+
Notes
74+
-----
75+
To register a new setting, add a new method to this class. The method name
76+
must match the setting name in uppercase. The method docstring will be
77+
used to display the error message if the setting is missing or invalid.
78+
The body of the function can raise exceptions to validate the setting.
79+
"""
80+
81+
@staticmethod
82+
def path_to_project_root(value):
83+
"""The path to project root"""
84+
pass
85+
86+
87+
class BaseSettings:
88+
"""A base object to load settings from a settings.py file"""
89+
90+
SCHEMA = None
91+
92+
def __init__(self) -> None:
93+
self._path_to_settings, _ = find_file_recursively("settings.py")
94+
self._load()
95+
96+
def _load(self):
97+
with add_to_sys_path(self._path_to_settings.parent):
98+
module = importlib.import_module("settings")
99+
100+
del sys.modules["settings"]
101+
102+
self._settings = {k: v for k, v in getmembers(module) if k.upper() == k}
103+
self.SCHEMA._validate(self._settings)
104+
105+
for k, v in self._settings.items():
106+
setattr(self, k, v)
107+
108+
def to_dict(self):
109+
return copy(self._settings)
110+
111+
def to_environ(self):
112+
"""Set all settings as environment variables"""
113+
for k, v in self.to_dict().items():
114+
os.environ[k] = str(v)
115+
116+
117+
class Settings(BaseSettings):
118+
"""
119+
The settings object used to load settings from settings.py. It validates
120+
with the Schema class
121+
"""
122+
123+
SCHEMA = Schema
124+
125+
126+
def find_file_recursively(name, max_levels_up=6, starting_dir=None):
127+
"""
128+
Find a file by looking into the current folder and parent folders,
129+
returns None if no file was found otherwise pathlib.Path to the file
130+
131+
Parameters
132+
----------
133+
name : str
134+
Filename
135+
136+
Returns
137+
-------
138+
path : str
139+
Absolute path to the file
140+
levels : int
141+
How many levels up the file is located
142+
"""
143+
current_dir = starting_dir or os.getcwd()
144+
current_dir = Path(current_dir).resolve()
145+
path_to_file = None
146+
levels = None
147+
148+
for levels in range(max_levels_up):
149+
current_path = Path(current_dir, name)
150+
151+
if current_path.exists():
152+
path_to_file = current_path.resolve()
153+
break
154+
155+
current_dir = current_dir.parent
156+
157+
if not path_to_file:
158+
raise FileNotFoundError(f"File {name} not found")
159+
160+
return path_to_file, levels

src/pkgmt/new.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import shutil
44
import importlib.resources
55
from string import Template
6-
6+
import re
77
from pkgmt import assets
88

99

@@ -36,6 +36,7 @@ def package(name, use_setup_py=False):
3636
".github/workflows/ci.yml",
3737
"src/package_name/cli.py",
3838
"src/package_name/log.py",
39+
"src/package_name/__init__.py",
3940
):
4041
render_inplace(
4142
root / file,
@@ -52,7 +53,14 @@ def package(name, use_setup_py=False):
5253
(root / "setup.py").unlink()
5354
(root / "MANIFEST.in").unlink()
5455
(root / "pyproject-setup.toml").rename(root / "pyproject.toml")
55-
(root / "src" / package_name / "__init__.py").write_text("")
56+
57+
# Remove __version__ from __init__.py
58+
content = (root / "src" / package_name / "__init__.py").read_text()
59+
lines = content.splitlines()
60+
new_lines = [
61+
line for line in lines if not re.match(r"__version__\s+=\s+", line)
62+
]
63+
(root / "src" / package_name / "__init__.py").write_text("\n".join(new_lines))
5664

5765
# Remove flake8: noqa comments from Python files, we added them because
5866
# they contain $TAG, which raises a warning when linting

tests/test_new.py

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from unittest.mock import ANY
33
from pathlib import Path
44
import subprocess
5+
import os
6+
57
from pkgmt import new
68

79
import pytest
@@ -16,15 +18,17 @@ def uninstall():
1618
def test_package_setup_py(tmp_empty, uninstall):
1719
new.package("some-cool_pkg", use_setup_py=True)
1820

19-
subprocess.check_call(["pip", "install", "some-cool-pkg/"])
20-
21-
pyproject = Path("some-cool-pkg", "pyproject.toml").read_text()
22-
setup = Path("some-cool-pkg", "setup.py").read_text()
23-
ci = Path("some-cool-pkg", ".github", "workflows", "ci.yml").read_text()
24-
manifest = Path("some-cool-pkg", "MANIFEST.in").read_text()
25-
init = Path("some-cool-pkg", "src", "some_cool_pkg", "__init__.py").read_text()
26-
cli = Path("some-cool-pkg", "src", "some_cool_pkg", "cli.py").read_text()
27-
assert '__version__ = "0.1dev"\n' == init
21+
# move the working directory so the settings.py file is found
22+
os.chdir("some-cool-pkg")
23+
subprocess.check_call(["pip", "install", "."])
24+
25+
pyproject = Path("pyproject.toml").read_text()
26+
setup = Path("setup.py").read_text()
27+
ci = Path(".github", "workflows", "ci.yml").read_text()
28+
manifest = Path("MANIFEST.in").read_text()
29+
init = Path("src", "some_cool_pkg", "__init__.py").read_text()
30+
cli = Path("src", "some_cool_pkg", "cli.py").read_text()
31+
assert '__version__ = "0.1dev"\n' in init
2832
assert 'github = "ploomber/some-cool-pkg"' in pyproject
2933
assert 'package_name = "some_cool_pkg"' in pyproject
3034
assert 'env_name = "some-cool-pkg"' in pyproject
@@ -46,17 +50,19 @@ def test_package_setup_py(tmp_empty, uninstall):
4650
def test_package_pyproject_toml(tmp_empty, uninstall):
4751
new.package("some-cool_pkg", use_setup_py=False)
4852

49-
subprocess.check_call(["pip", "install", "some-cool-pkg/"])
53+
# move the working directory so the settings.py file is found
54+
os.chdir("some-cool-pkg")
55+
subprocess.check_call(["pip", "install", "."])
5056

51-
pyproject = Path("some-cool-pkg", "pyproject.toml").read_text()
52-
ci = Path("some-cool-pkg", ".github", "workflows", "ci.yml").read_text()
53-
init = Path("some-cool-pkg", "src", "some_cool_pkg", "__init__.py").read_text()
54-
cli = Path("some-cool-pkg", "src", "some_cool_pkg", "cli.py").read_text()
55-
assert not Path("some-cool-pkg", "setup.py").exists()
56-
assert not Path("some-cool-pkg", "MANIFEST.in").exists()
57-
assert not Path("some-cool-pkg", "pyproject-setup.toml").exists()
57+
pyproject = Path("pyproject.toml").read_text()
58+
ci = Path(".github", "workflows", "ci.yml").read_text()
59+
init = Path("src", "some_cool_pkg", "__init__.py").read_text()
60+
cli = Path("src", "some_cool_pkg", "cli.py").read_text()
61+
assert not Path("setup.py").exists()
62+
assert not Path("MANIFEST.in").exists()
63+
assert not Path("pyproject-setup.toml").exists()
5864

59-
assert init == "\n"
65+
assert "__version__" not in init
6066

6167
assert 'github = "ploomber/some-cool-pkg"' in pyproject
6268
assert 'package_name = "some_cool_pkg"' in pyproject

0 commit comments

Comments
 (0)