Skip to content

Commit

Permalink
RF: Configuration module (#5)
Browse files Browse the repository at this point in the history
* FIX: Rework `setup()` to allow overriding options

* FIX: Use string representation of UUIDs, clean up load

* RF: Rework `Config.init()` logic

* TST: Add configuration testing

* TST: Add GH actions workflow
  • Loading branch information
mgxd authored Jun 28, 2022
1 parent de0ddd8 commit 2e707c0
Show file tree
Hide file tree
Showing 5 changed files with 185 additions and 23 deletions.
62 changes: 62 additions & 0 deletions .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: install-test

on:
push:
branches:
- master
tags:
- '*'
pull_request:
branches:
- master
schedule:
# 7am EST / 8am EDT Mondays
- cron: '0 12 * * 1'

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.7, 3.8, 3.9, 3.10]
install: [repo]
pip-flags: ['', '--pre']
include:
- python-version: 3.9
install: sdist
pip-flags: ''
- python-version: 3.9
install: wheel
pip-flags: ''
- python-version: 3.9
install: editable
pip-flags: ''

env:
INSTALL_TYPE: ${{ matrix.install }}
PIP_FLAGS: ${{ matrix.pip-flags }}

steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Select archive
run: |
if [ "$INSTALL_TYPE" = "sdist" ]; then
ARCHIVE=$( ls dist/*.tar.gz )
elif [ "$INSTALL_TYPE" = "wheel" ]; then
ARCHIVE=$( ls dist/*.whl )
elif [ "$INSTALL_TYPE" = "repo" ]; then
ARCHIVE="."
elif [ "$INSTALL_TYPE" = "editable" ]; then
ARCHIVE="-e ."
fi
echo "ARCHIVE=$ARCHIVE" >> $GITHUB_ENV
- name: Install package and test dependencies
run: python -m pip install $PIP_FLAGS $ARCHIVE[test]
- name: Run tests
run: python -m pytest -sv --doctest-modules etelemetry
105 changes: 82 additions & 23 deletions etelemetry/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,77 @@


DEFAULT_ENDPOINT = "http://0.0.0.0:8000/graphql" # localhost test
CONFIG_FILENAME = Path.home() / '.cache' / 'etelemetry' / 'config.json'
DEFAULT_CONFIG_FILE = Path.home() / '.cache' / 'etelemetry' / 'config.json'

# TODO: 3.10 - Replace with | operator
File = typing.Union[str, Path]


@dataclass
class Config:
"""
Class to store client-side configuration, facilitating communication with the server.
The class stores the following components:
- `endpoint`:
The URL of the etelemetry server
- `user_id`:
A string representation of a UUID (RFC 4122) assigned to the user.
- `session_id`:
A string representation of a UUID assigned to the lifespan of the etelemetry invocation.
"""
endpoint: str = None
user_id: uuid.UUID = None
user_id: str = None
session_id: str = None
_is_setup = False


def load(filename: File = CONFIG_FILENAME) -> bool:
@classmethod
def init(
cls,
*,
endpoint: str = None,
user_id: str = None,
session_id: str = None,
final: bool = True,
) -> None:
if cls._is_setup:
return
if endpoint is not None:
cls.endpoint = endpoint
elif cls.endpoint is None:
cls.endpoint = DEFAULT_ENDPOINT
if user_id is not None or cls.user_id is None:
try:
uuid.UUID(user_id)
cls.user_id = user_id
except Exception:
cls.user_id = gen_uuid()
# Do not set automatically, leave to developers
if session_id is not None:
try:
uuid.UUID(session_id)
cls.session_id = session_id
except Exception:
pass
cls._is_setup = final


@classmethod
def _reset(cls):
cls.endpoint = None
cls.user_id = None
cls.session_id = None
cls._is_setup = False


def load(filename: File) -> bool:
"""Load existing configuration file, or create a new one."""
config = json.loads(Path(filename).read_text())
Config.endpoint = config.get("endpoint")
user_id = config.get("user_id")
if user_id:
Config.user_id = uuid.UUID(user_id)
Config._is_setup = True
Config.init(final=False, **config)
return True


def save(filename: File = CONFIG_FILENAME) -> str:
def save(filename: File) -> str:
"""Save to a file."""
config = {
field: getattr(Config, field) for field in Config.__annotations__.keys()
Expand All @@ -42,23 +88,36 @@ def save(filename: File = CONFIG_FILENAME) -> str:
return str(filename)


def setup(et_endpoint: str = None, user_id: uuid.UUID = None, filename: File = CONFIG_FILENAME):
"""Configure the client, and save configuration to an output file."""
def setup(
*,
endpoint: str = None,
user_id: str = None,
session_id: str = None,
save_config: bool = True,
filename: File = None,
) -> None:
"""
Configure the client, and save configuration to an output file.
This method is invoked before each API call, but can also be called by
application developers for finer-grain control.
"""
if Config._is_setup:
return
filename = filename or DEFAULT_CONFIG_FILE
if Path(filename).exists():
return load(filename)
Config.endpoint = et_endpoint or DEFAULT_ENDPOINT
Config.user_id = user_id or gen_user_uuid()
Config._is_setup = True
save(filename)
load(filename)
# if any parameters have been set, override the current attribute
Config.init(endpoint=endpoint, user_id=user_id, session_id=session_id)
if save_config:
save(filename)


def gen_user_uuid(uuid_factory: str = "safe") -> uuid.UUID:
def gen_uuid(uuid_factory: str = "safe") -> str:
"""
Generate a user ID in UUID format.
Generate a RFC 4122 UUID.
Depending on what `uuid_factory` is provided, the user ID will be generated differently:
Depending on what `uuid_factory` is provided, the UUID will be generated differently:
- `safe`: This is multiprocessing safe, and uses system information.
- `random`: This is random, and may run into problems if setup is called across multiple
processes.
Expand All @@ -70,12 +129,12 @@ def gen_user_uuid(uuid_factory: str = "safe") -> uuid.UUID:
# TODO: 3.10 - Replace with match/case
if uuid_factory == "safe":
return _safe_uuid_factory()
if uuid_factory == "random":
return uuid.uuid4()
elif uuid_factory == "random":
return str(uuid.uuid4())
raise NotImplementedError


def _safe_uuid_factory() -> uuid.UUID:
def _safe_uuid_factory() -> str:
import getpass
import socket

Expand Down
Empty file added etelemetry/tests/__init__.py
Empty file.
37 changes: 37 additions & 0 deletions etelemetry/tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import uuid

import pytest

from .. import config


@pytest.fixture(autouse=True)
def tmp_config(tmp_path, monkeypatch):
home = tmp_path / 'config.json'
monkeypatch.setattr(config, 'DEFAULT_CONFIG_FILE', home)


def test_setup_default():

conf = config.Config
assert conf.endpoint is None
assert conf.user_id is None
assert conf.session_id is None
assert conf._is_setup is False

config.setup()
assert conf.endpoint == config.DEFAULT_ENDPOINT
assert uuid.UUID(conf.user_id)
assert conf.session_id is None
assert conf._is_setup is True

# after being set up, cannot be overriden
new_endpoint = 'https://github.com'
config.setup(endpoint=new_endpoint)
assert conf.endpoint == config.DEFAULT_ENDPOINT

# but fine if cleared
conf._reset()
assert conf.endpoint is None
config.setup(endpoint=new_endpoint)
assert conf.endpoint == new_endpoint
4 changes: 4 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ install_requires =
ci-info >= 0.2
typing_extensions; python_version<'3.8'

[options.extras_require]
test =
pytest

[flake8]
max-line-length = 99

Expand Down

0 comments on commit 2e707c0

Please sign in to comment.