Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Config refactor #298

Merged
merged 48 commits into from
Feb 26, 2020
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
7e67e68
First go with having function in the configuration.py
antalszava Feb 18, 2020
0efccb6
First draft
antalszava Feb 19, 2020
24952e0
Logic of load_config before testing
antalszava Feb 20, 2020
7d62a23
Testing config object creation, look_for_config_file function
antalszava Feb 21, 2020
e44a1b7
Unit tests for the parts making up load_config
antalszava Feb 21, 2020
e0afae1
Unit test for load_config; removing MagicMock from parse_environment_…
antalszava Feb 21, 2020
3ca6a5b
Marking xfail API Client tests (being refactored in another PR)
antalszava Feb 21, 2020
abdc0af
Marking xfail API Client tests (being refactored in another PR)
antalszava Feb 21, 2020
2995cd9
Adding xfail mark on another API Client test
antalszava Feb 21, 2020
0770172
Docstrings, reorganizing parse_environment_variables function, adding…
antalszava Feb 24, 2020
e7eb96a
Add create config object test with every keyword argument
antalszava Feb 24, 2020
7bc48ca
Adding load_config tests using each units separately; change logic fo…
antalszava Feb 24, 2020
fc5619a
Test docstrings
antalszava Feb 24, 2020
9109a46
Default config refactor
antalszava Feb 24, 2020
653f566
Add absolute path test
antalszava Feb 24, 2020
984c5a5
Saving path, linting, isort
antalszava Feb 24, 2020
b90ca00
Modify dict formatting in docstring
antalszava Feb 24, 2020
18a5d33
Update tests/frontend/test_configuration.py
antalszava Feb 24, 2020
42071b0
Modifying docstrings based on comments
antalszava Feb 24, 2020
ec800c3
Merge branch 'config_refactor' of https:/XanaduAI/strawbe…
antalszava Feb 24, 2020
c2e79d4
Modifying tests such that look_for_config_in_file returns a tuple;
antalszava Feb 24, 2020
b912be1
Update tests/frontend/test_configuration.py
antalszava Feb 24, 2020
2cef067
Updating test data
antalszava Feb 24, 2020
39538d3
Update tests/frontend/test_configuration.py
antalszava Feb 24, 2020
b5e8229
Update strawberryfields/configuration.py
antalszava Feb 24, 2020
47e483a
Update strawberryfields/configuration.py
antalszava Feb 24, 2020
66ad7da
Merge branch 'config_refactor' of https:/XanaduAI/strawbe…
antalszava Feb 24, 2020
f8e2c29
Update strawberryfields/configuration.py
antalszava Feb 25, 2020
03e2e04
Docstring and comment
antalszava Feb 25, 2020
4fa68ad
Merge branch 'config_refactor' of https:/XanaduAI/strawbe…
antalszava Feb 25, 2020
1f04d5c
Docstrings, renaming to load_config_file_if_found, adding defaults to…
antalszava Feb 25, 2020
b82c60f
Change function to get_config_filepath and adjust tests as well accor…
antalszava Feb 25, 2020
544b60c
Renaming create_config
antalszava Feb 25, 2020
228a0c4
Modifying tests to use setenv from monkeypatching in tests
antalszava Feb 25, 2020
4614d83
Adding configuration options and environment variables to module docs…
antalszava Feb 26, 2020
09a3b3d
Removing redundant return None statement
antalszava Feb 26, 2020
ef68ab5
Remove update_config function, add keep_valid_options function and ad…
antalszava Feb 26, 2020
3ca025e
Addig DEFAULT_CONFIG_SPEC
antalszava Feb 26, 2020
3772015
Removed debug option for now
antalszava Feb 26, 2020
d21f3fe
Update strawberryfields/configuration.py
antalszava Feb 26, 2020
7c73d5c
Update strawberryfields/configuration.py
antalszava Feb 26, 2020
47eac17
Moving configuration details to page, updating the configuration page…
antalszava Feb 26, 2020
7bb97c8
Resolving conflicts
antalszava Feb 26, 2020
e0938a9
Update strawberryfields/configuration.py
antalszava Feb 26, 2020
17be8bb
Applying comments: Configuration page modification, removing redundan…
antalszava Feb 26, 2020
3d2aa7d
Merge branch 'config_refactor' of https:/XanaduAI/strawbe…
antalszava Feb 26, 2020
6635879
CHANGELOG
antalszava Feb 26, 2020
0063663
Merge branch 'demo' into config_refactor
antalszava Feb 26, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
317 changes: 204 additions & 113 deletions strawberryfields/configuration.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2019 Xanadu Quantum Technologies Inc.
# Copyright 2019-2020 Xanadu Quantum Technologies Inc.

# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand All @@ -15,138 +15,229 @@
This module contains the :class:`Configuration` class, which is used to
load, store, save, and modify configuration options for Strawberry Fields.
antalszava marked this conversation as resolved.
Show resolved Hide resolved
"""
import os
import logging as log
import os

import toml
from appdirs import user_config_dir

log.getLogger()


DEFAULT_CONFIG = {
"api": {
"authentication_token": "",
"hostname": "localhost",
"use_ssl": True,
"port": 443,
"debug": False}
}
class ConfigurationError(Exception):
"""Exception used for configuration errors"""


def load_config(filename="config.toml", **kwargs):
"""Load configuration from keyword arguments, configuration file or
environment variables.

BOOLEAN_KEYS = ("debug", "use_ssl")
.. note::

The configuration object (a nested dictionary) will be created based
antalszava marked this conversation as resolved.
Show resolved Hide resolved
on the following (order defines the importance, going from most
important to least important):

1. keyword arguments passed to ``load_config``
2. data contained in environmental variables (if any)
3. data contained in a configuration file (if exists)

Keyword arguments:
filename (str): the name of the configuration file to look for
authentication_token (str): the token to be used for user
authentication
hostname (str): the name of the host to connect to
use_ssl (bool): specifies if requests should be sent using SSL
port (int): the port to be used when connecting to the remote service
debug (bool): determines if the debugging mode is requested
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking it might be best to move all the configuration options to the module docstring. This has several advantages:

  • Less redundancy --- configuration options only need to be specified in one place, and linked from everywhere else

  • We can use Sphinx glossary notation to add more details

  • It is user facing, whereas these docstrings aren't

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added them to the module docstring :) Added the list of environment variables, let me know if you think they need more adjustments :)


Returns:
dict[str, dict[str, Union[str, bool, int]]]: the configuration
object
antalszava marked this conversation as resolved.
Show resolved Hide resolved
"""
config = create_config_object()

parsed_config, _ = look_for_config_in_file(filename=filename)

if parsed_config is not None:
update_with_other_config(config, other_config=parsed_config)
else:
log.info("No Strawberry Fields configuration file found.")

update_from_environment_variables(config)

config_from_keyword_arguments = {"api": kwargs}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might make sense to define the kwargs as 'belonging' to the "api" section when the defaults/types are defined.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would that be referring to the defaults used in create_config?

One of the problems I came across was that if I use create_config and kwargs here, then having defaults will result in an unexpected overwrite.

That is

In [1]: import strawberryfields.configuration as conf
In [3]:     config = { 
   ...:         "api": { 
   ...:             "authentication_token": 'NotDefaultAuth', 
   ...:             "hostname": 'NotDefaultHostname', 
   ...:             "use_ssl": True, 
   ...:             "port": 123456789, 
   ...:             "debug": False 
   ...:             } 
   ...:     } 
   ...: other_config = conf.create_config(authentication_token="MyToken") 
   ...: conf.update_config(config, other_config) 
   ...: config                                                                  
Out[3]: 
{'api': {'authentication_token': 'MyToken',
  'hostname': 'localhost',
  'use_ssl': True,
  'port': 443,
  'debug': False}}

where 'hostname' and 'port' were overwritten, whereas they were not defined in kwargs.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see. This is because the other_config is replacing the default values 🤔

update_with_other_config(config, other_config=config_from_keyword_arguments)

return config
co9olguy marked this conversation as resolved.
Show resolved Hide resolved

def create_config_object(authentication_token="", **kwargs):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest changing this to create_config

"""Create a configuration object that stores configuration related data
organized into sections.

The configuration object contains API-related configuration options. This
function takes into consideration only pre-defined options.

If called without passing any keyword arguments, then a default
configuration object is created.

Keyword arguments:
authentication_token (str): the token to be used for user
authentication
hostname (str): the name of the host to connect to
use_ssl (bool): specifies if requests should be sent using SSL
port (int): the port to be used when connecting to the remote service
debug (bool): determines if the debugging mode is requested

Returns:
dict[str, dict[str, Union[str, bool, int]]]: the configuration
object
"""
hostname = kwargs.get("hostname", "localhost")
use_ssl = kwargs.get("use_ssl", True)
port = kwargs.get("port", 443)
debug = kwargs.get("debug", False)

config = {
"api": {
"authentication_token": authentication_token,
"hostname": hostname,
"use_ssl": use_ssl,
"port": port,
"debug": debug
}
}
return config

def look_for_config_in_file(filename="config.toml"):
antalszava marked this conversation as resolved.
Show resolved Hide resolved
"""Looks for the first configuration file to be found at certain paths.
antalszava marked this conversation as resolved.
Show resolved Hide resolved

.. note::

The following directories are checked (in the following order):

* The current working directory
* The directory specified by the environment variable SF_CONF (if specified)
* The user configuration directory (if specified)

Keyword arguments:
filename (str): the configuration file to look for

Returns:
dict[str, dict[str, Union[str, bool, int]]] or None: the
configuration object that was loaded
antalszava marked this conversation as resolved.
Show resolved Hide resolved
"""

# Search the current directory, the directory under environment
# variable SF_CONF, and default user config directory, in that order.
current_dir = os.getcwd()
sf_env_config_dir = os.environ.get("SF_CONF", "")
sf_user_config_dir = user_config_dir("strawberryfields", "Xanadu")

directories = [current_dir, sf_env_config_dir, sf_user_config_dir]
for directory in directories:
filepath = os.path.join(directory, filename)
try:
parsed_config = load_config_file(filepath)
break
except FileNotFoundError:
parsed_config, filepath = None, None

return parsed_config, filepath
antalszava marked this conversation as resolved.
Show resolved Hide resolved

def load_config_file(filepath):
"""Load a configuration object from a TOML formatted file.

Args:
filepath (str): path to the configuration file

Returns:
dict[str, dict[str, Union[str, bool, int]]]: the configuration
object that was loaded
"""
with open(filepath, "r") as f:
config_from_file = toml.load(f)
return config_from_file

def update_with_other_config(config, other_config):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be a case where a more verbose name makes it slightly harder to parse. I would suggest update_config is sufficient here, as it is similar to the dict.update() (and feels quite pythonic)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, is this method needed at all? Would config.update(other_config) work?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've changed this such that there the dict.update method is being used :)

One thing to note is that it cannot be replaced entirely. This is because assuming that not all options are defined in kwargs, calling on update on the outer dictionary does not update the inner dictionary, but overwrites it (updating is not recursive for the nested structure):

In [1]: a = {'a': {'b': 3}}                                                     

In [2]: a                                                                       
Out[2]: {'a': {'b': 3}}

In [3]: b = {'a': {'c': 5}}                                                     

In [4]: b                                                                       
Out[4]: {'a': {'c': 5}}

In [5]: a.update(b)                                                             

In [6]: a                                                                       
Out[6]: {'a': {'c': 5}}

# Restoring a

In [7]: a = {'a': {'b': 3}}                                                     

In [8]: a                                                                       
Out[8]: {'a': {'b': 3}}

In [9]: a['a'].update(b['a'])                                                   

In [10]: a                                                                      
Out[10]: {'a': {'b': 3, 'c': 5}}

"""Updates the current configuration object with another one.

Args:
config (dict[str, dict[str, Union[str, bool, int]]]): the
configuration to be updated
other_config (dict[str, dict[str, Union[str, bool, int]]]): the
configuration used for updating

Returns:
dict[str, dict[str, Union[str, bool, int]]]): the updated
configuration
"""
# Here an example for section is API
for section, sectionconfig in config.items():
for key in sectionconfig:
co9olguy marked this conversation as resolved.
Show resolved Hide resolved
if key in other_config[section]:
config[section][key] = other_config[section][key]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be made more succinct using the dict.update method?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've changed it so, however, will have to add certain validity check most probably, such that no problems can arise from updating with invalid configurations.


def update_from_environment_variables(config):
"""Updates the current configuration object from data stored in environment
variables.

.. note::

The following environment variables are checked:

* SF_API_AUTHENTICATION_TOKEN
* SF_API_HOSTNAME
* SF_API_USE_SSL
* SF_API_DEBUG
* SF_API_PORT

Args:
config (dict[str, dict[str, Union[str, bool, int]]]): the
configuration to be updated
Returns:
dict[str, dict[str, Union[str, bool, int]]]): the updated
configuration
"""
for section, sectionconfig in config.items():
env_prefix = "SF_{}_".format(section.upper())
for key in sectionconfig:
env = env_prefix + key.upper()
if env in os.environ:
config[section][key] = parse_environment_variable(key, os.environ[env])


BOOLEAN_KEYS = ("debug", "use_ssl")
INTEGER_KEYS = ("port")
antalszava marked this conversation as resolved.
Show resolved Hide resolved

def parse_environment_variable(key, value):
"""Parse a value stored in an environment variable.

Args:
key (str): the name of the environment variable
value (Union[str, bool, int]): the value obtained from the environment
variable

Returns:
[str, bool, int]: the parsed value
"""
trues = (True, "true", "True", "TRUE", "1", 1)
falses = (False, "false", "False", "FALSE", "0", 0)

if key in BOOLEAN_KEYS:
if value in trues:
return True
elif value in falses:
return False
else:
raise ValueError("Boolean could not be parsed")
else:
return value


class ConfigurationError(Exception):
"""Exception used for configuration errors"""

if value in falses:
return False
antalszava marked this conversation as resolved.
Show resolved Hide resolved

class Configuration:
"""Configuration class.
raise ValueError("Boolean could not be parsed")

This class is responsible for loading, saving, and storing StrawberryFields
and plugin/device configurations.
if key in INTEGER_KEYS:
return int(value)

Args:
name (str): filename of the configuration file.
This should be a valid TOML file. You may also pass an absolute
or a relative file path to the configuration file.
"""
return value

def __str__(self):
return "{}".format(self._config)

def __repr__(self):
return "Strawberry Fields Configuration <{}>".format(self._filepath)

def __init__(self, name="config.toml"):
# Look for an existing configuration file
self._config = DEFAULT_CONFIG
self._config_file = {}
self._filepath = None
self._name = name
self._user_config_dir = user_config_dir("strawberryfields", "Xanadu")
self._env_config_dir = os.environ.get("SF_CONF", "")

# Search the current directory, the directory under environment
# variable SF_CONF, and default user config directory, in that order.
directories = [os.getcwd(), self._env_config_dir, self._user_config_dir]
for directory in directories:
self._filepath = os.path.join(directory, self._name)
try:
config = self.load(self._filepath)
break
except FileNotFoundError:
config = False

if config:
self.update_config()
else:
log.info("No Strawberry Fields configuration file found.")

def update_config(self):
"""Updates the configuration from either a loaded configuration
file, or from an environment variable.

The environment variable takes precedence."""
for section, section_config in self._config.items():
env_prefix = "SF_{}_".format(section.upper())

for key in section_config:
# Environment variables take precedence
env = env_prefix + key.upper()

if env in os.environ:
# Update from environment variable
self._config[section][key] = parse_environment_variable(env, os.environ[env])
elif self._config_file and key in self._config_file[section]:
# Update from configuration file
self._config[section][key] = self._config_file[section][key]

def __getattr__(self, section):
if section in self._config:
return self._config[section]

raise ConfigurationError("Unknown Strawberry Fields configuration section.")

@property
def path(self):
"""Return the path of the loaded configuration file.

Returns:
str: If no configuration is loaded, this returns ``None``."""
return self._filepath

def load(self, filepath):
"""Load a configuration file.

Args:
filepath (str): path to the configuration file
"""
with open(filepath, "r") as f:
self._config_file = toml.load(f)

return self._config_file

def save(self, filepath):
"""Save a configuration file.

Args:
filepath (str): path to the configuration file
"""
with open(filepath, "w") as f:
toml.dump(self._config, f)
DEFAULT_CONFIG = create_config_object()
co9olguy marked this conversation as resolved.
Show resolved Hide resolved
configuration = load_config()
config_file_path = look_for_config_in_file()[1]
antalszava marked this conversation as resolved.
Show resolved Hide resolved
6 changes: 3 additions & 3 deletions tests/frontend/test_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ def json(self):
def raise_for_status(self):
raise requests.exceptions.HTTPError()


co9olguy marked this conversation as resolved.
Show resolved Hide resolved
@pytest.mark.xfail
class TestAPIClient:
def test_init_default_client(self):
"""
Expand Down Expand Up @@ -206,7 +206,7 @@ def test_join_path(self, client):
"""
assert client.join_path("jobs") == "{client.BASE_URL}/jobs".format(client=client)


@pytest.mark.xfail
class TestResourceManager:
def test_init(self):
"""
Expand Down Expand Up @@ -386,7 +386,7 @@ def mock_raise(exception):

assert len(client.errors) == 1


@pytest.mark.xfail
class TestJob:
def test_create_created(self, monkeypatch):
"""
Expand Down
Loading