diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml new file mode 100644 index 0000000..b5c2cfb --- /dev/null +++ b/.github/workflows/build-test.yml @@ -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 diff --git a/etelemetry/config.py b/etelemetry/config.py index 99d2883..1379e69 100644 --- a/etelemetry/config.py +++ b/etelemetry/config.py @@ -7,7 +7,7 @@ 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] @@ -15,23 +15,69 @@ @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() @@ -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. @@ -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 diff --git a/etelemetry/tests/__init__.py b/etelemetry/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/etelemetry/tests/test_config.py b/etelemetry/tests/test_config.py new file mode 100644 index 0000000..afc4541 --- /dev/null +++ b/etelemetry/tests/test_config.py @@ -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 diff --git a/setup.cfg b/setup.cfg index 264665c..4d8b825 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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