Skip to content

Commit 05fab5a

Browse files
authored
Merge pull request #1 from posit-dev/tdstein/add-config
feat: adds boilerplate for config with factories.
2 parents 4884968 + 219d4ba commit 05fab5a

File tree

15 files changed

+235
-15
lines changed

15 files changed

+235
-15
lines changed

.coveragerc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[report]
2+
fail_under = 100

.github/workflows/test.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: Test
2+
on: [push]
3+
jobs:
4+
test:
5+
runs-on: ubuntu-latest
6+
strategy:
7+
fail-fast: false
8+
matrix:
9+
python-version:
10+
- '3.8'
11+
- '3.9'
12+
- '3.10'
13+
- '3.11'
14+
- '3.12'
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: extractions/setup-just@v1
18+
- uses: actions/setup-python@v5
19+
with:
20+
python-version: ${{ matrix.python-version }}
21+
- run: just deps
22+
- run: just test
23+
- run: just cov
24+
- run: just lint

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ ipython_config.py
8585
# pyenv
8686
# For a library or package, you might want to ignore these files since the code is
8787
# intended to run in multiple environments; otherwise, check them in:
88-
# .python-version
88+
.python-version
8989

9090
# pipenv
9191
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
@@ -161,3 +161,6 @@ cython_debug/
161161

162162
# Version file
163163
/src/posit/_version.py
164+
165+
# Ruff
166+
.ruff_cache/

.pre-commit-config.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# See https://pre-commit.com for more information
2+
# See https://pre-commit.com/hooks.html for more hooks
3+
repos:
4+
- repo: local
5+
hooks:
6+
- id: format
7+
name: format
8+
entry: bash -c "just fmt"
9+
language: system
10+
- id: lint
11+
name: lint
12+
entry: bash -c "just lint"
13+
language: system

Justfile

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ default:
1111
#!/usr/bin/env bash
1212
{{ OPTIONS }}
1313

14-
just clean
1514
just deps
1615
just test
1716
just cov
17+
just lint
1818
just build
1919

2020
build:
@@ -23,7 +23,6 @@ build:
2323

2424
{{ PYTHON }} -m build
2525

26-
2726
cov *args="report":
2827
#!/usr/bin/env bash
2928
{{ OPTIONS }}
@@ -34,11 +33,14 @@ clean:
3433
#!/usr/bin/env bash
3534
{{ OPTIONS }}
3635

36+
find . -name "*.egg-info" -exec rm -rf {} +
3737
find . -name "*.pyc" -exec rm -f {} +
3838
find . -name "__pycache__" -exec rm -rf {} +
3939
rm -rf\
4040
.coverage\
41+
.mypy_cache\
4142
.pytest_cache\
43+
.ruff_cache\
4244
*.egg-info\
4345
build\
4446
dist\
@@ -50,6 +52,19 @@ deps:
5052

5153
{{ PIP }} install -e '.[test]'
5254

55+
fmt:
56+
#!/usr/bin/env bash
57+
{{ OPTIONS }}
58+
59+
{{ PYTHON }} -m ruff format .
60+
61+
lint:
62+
#!/usr/bin/env bash
63+
{{ OPTIONS }}
64+
65+
{{ PYTHON }} -m mypy --install-types --non-interactive .
66+
{{ PYTHON }} -m ruff check
67+
5368
test:
5469
#!/usr/bin/env bash
5570
{{ OPTIONS }}

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ classifiers = [
1919
]
2020
dynamic = ["version"]
2121
dependencies = [
22-
"requests==2.31.0"
22+
"requests>=2.31.0,<3"
2323
]
2424

2525
[project.optional-dependencies]
2626
test = [
2727
"coverage",
28+
"mypy",
2829
"pytest",
30+
"ruff",
2931
"setuptools-scm"
3032
]
3133

src/posit/auth.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from requests import PreparedRequest
2+
from requests.auth import AuthBase
3+
4+
5+
class Auth(AuthBase):
6+
def __init__(self, key) -> None:
7+
self.key = key
8+
9+
def __call__(self, r: PreparedRequest) -> PreparedRequest:
10+
r.headers["Authorization"] = f"Key {self.key}"
11+
return r

src/posit/auth_test.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from unittest.mock import Mock
2+
3+
from .auth import Auth
4+
5+
6+
class TestAuth:
7+
def test_auth_headers(self):
8+
key = "foobar"
9+
auth = Auth(key=key)
10+
r = Mock()
11+
r.headers = {}
12+
auth(r)
13+
assert r.headers == {"Authorization": f"Key {key}"}

src/posit/client.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from requests import Session
2+
from typing import Optional
3+
4+
from .auth import Auth
5+
from .config import ConfigBuilder
6+
7+
8+
class Client:
9+
def __init__(
10+
self, endpoint: Optional[str] = None, api_key: Optional[str] = None
11+
) -> None:
12+
builder = ConfigBuilder()
13+
builder.set_api_key(api_key)
14+
builder.set_endpoint(endpoint)
15+
self._config = builder.build()
16+
self._session = Session()
17+
self._session.auth = Auth(self._config.api_key)
18+
19+
def get(self, endpoint: str, *args, **kwargs): # pragma: no cover
20+
return self._session.request(
21+
"GET", f"{self._config.endpoint}/{endpoint}", *args, **kwargs
22+
)

src/posit/client_test.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from unittest.mock import MagicMock, Mock, patch
2+
3+
from .client import Client
4+
5+
6+
class TestClient:
7+
@patch("posit.client.Session")
8+
@patch("posit.client.ConfigBuilder")
9+
@patch("posit.client.Auth")
10+
def test_init(self, Auth: MagicMock, ConfigBuilder: MagicMock, Session: MagicMock):
11+
api_key = "foobar"
12+
endpoint = "http://foo.bar"
13+
config = Mock()
14+
config.api_key = api_key
15+
builder = ConfigBuilder.return_value
16+
builder.set_api_key = Mock()
17+
builder.set_endpoint = Mock()
18+
builder.build = Mock(return_value=config)
19+
client = Client(api_key=api_key, endpoint=endpoint)
20+
ConfigBuilder.assert_called_once()
21+
builder.set_api_key.assert_called_once_with(api_key)
22+
builder.set_endpoint.assert_called_once_with(endpoint)
23+
builder.build.assert_called_once()
24+
Session.assert_called_once()
25+
Auth.assert_called_once_with(api_key)
26+
assert client._config == config

src/posit/config.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import os
2+
3+
from abc import ABC, abstractmethod
4+
from dataclasses import dataclass
5+
from typing import List, Optional
6+
7+
8+
@dataclass
9+
class Config:
10+
api_key: Optional[str] = None
11+
endpoint: Optional[str] = None
12+
13+
14+
class ConfigProvider(ABC):
15+
@abstractmethod
16+
def get_value(self, key: str) -> Optional[str]:
17+
raise NotImplementedError # pragma: no cover
18+
19+
20+
class EnvironmentConfigProvider(ConfigProvider):
21+
def get_value(self, key: str) -> Optional[str]:
22+
if key == "api_key":
23+
return os.environ.get("CONNECT_API_KEY")
24+
25+
if key == "endpoint":
26+
return os.environ.get("CONNECT_SERVER")
27+
28+
return None
29+
30+
31+
class ConfigBuilder:
32+
def __init__(
33+
self, providers: List[ConfigProvider] = [EnvironmentConfigProvider()]
34+
) -> None:
35+
self._config = Config()
36+
self._providers = providers
37+
38+
def build(self) -> Config:
39+
for key in Config.__annotations__:
40+
if not getattr(self._config, key):
41+
setattr(
42+
self._config,
43+
key,
44+
next(
45+
(provider.get_value(key) for provider in self._providers), None
46+
),
47+
)
48+
return self._config
49+
50+
def set_api_key(self, api_key: Optional[str]):
51+
self._config.api_key = api_key
52+
53+
def set_endpoint(self, endpoint: Optional[str]):
54+
self._config.endpoint = endpoint

src/posit/config_test.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from unittest.mock import Mock, patch
2+
3+
from .config import Config, ConfigBuilder, EnvironmentConfigProvider
4+
5+
6+
class TestEnvironmentConfigProvider:
7+
@patch.dict("os.environ", {"CONNECT_API_KEY": "foobar"})
8+
def test_get_api_key(self):
9+
provider = EnvironmentConfigProvider()
10+
api_key = provider.get_value("api_key")
11+
assert api_key == "foobar"
12+
13+
@patch.dict("os.environ", {"CONNECT_SERVER": "http://foo.bar"})
14+
def test_get_endpoint(self):
15+
provider = EnvironmentConfigProvider()
16+
endpoint = provider.get_value("endpoint")
17+
assert endpoint == "http://foo.bar"
18+
19+
def test_get_value_miss(self):
20+
provider = EnvironmentConfigProvider()
21+
value = provider.get_value("foobar")
22+
assert value is None
23+
24+
25+
class TestConfigBuilder:
26+
def test_build(self):
27+
builder = ConfigBuilder()
28+
assert builder._config == Config()
29+
30+
def test_build_with_provider(self):
31+
provider = Mock()
32+
provider.get_value = Mock()
33+
builder = ConfigBuilder([provider])
34+
builder.build()
35+
for key in Config.__annotations__:
36+
provider.get_value.assert_any_call(key)
37+
38+
def test_set_api_key(self):
39+
builder = ConfigBuilder()
40+
builder.set_api_key("foobar")
41+
assert builder._config.api_key == "foobar"
42+
43+
def test_set_endpoint(self):
44+
builder = ConfigBuilder()
45+
builder.set_endpoint("http://foo.bar")
46+
assert builder._config.endpoint == "http://foo.bar"

src/posit/say_hello.py

Lines changed: 0 additions & 2 deletions
This file was deleted.

tests/__init__.py

Whitespace-only changes.

tests/test_say_hello.py

Lines changed: 0 additions & 9 deletions
This file was deleted.

0 commit comments

Comments
 (0)