From 436b3147e48ea8919dcd431c3a887279a41aab73 Mon Sep 17 00:00:00 2001
From: PGijsbers
Date: Tue, 6 Apr 2021 15:18:44 +0200
Subject: [PATCH 01/13] Add a non-functional entry point
---
.flake8 | 1 +
openml/cli.py | 33 +++++++++++++++++++++++++++++++++
setup.py | 1 +
3 files changed, 35 insertions(+)
create mode 100644 openml/cli.py
diff --git a/.flake8 b/.flake8
index 08bb8ea10..211234f22 100644
--- a/.flake8
+++ b/.flake8
@@ -5,6 +5,7 @@ select = C,E,F,W,B,T
ignore = E203, E402, W503
per-file-ignores =
*__init__.py:F401
+ *cli.py:T001
exclude =
venv
examples
diff --git a/openml/cli.py b/openml/cli.py
new file mode 100644
index 000000000..8f20b3beb
--- /dev/null
+++ b/openml/cli.py
@@ -0,0 +1,33 @@
+"""" Command Line Interface for `openml` to configure its settings. """
+
+import argparse
+
+# from openml import config
+
+
+def configure(args: argparse.Namespace):
+ """ Configures the openml configuration file. """
+ print("Configuring", args.file)
+
+ # check if API key exists, if so ask to overwrite.
+
+
+def main() -> None:
+ subroutines = {"configure": configure}
+
+ parser = argparse.ArgumentParser()
+ subparsers = parser.add_subparsers(dest="subroutine")
+
+ parser_configure = subparsers.add_parser(
+ "configure", description="Set or read variables in your configuration file.",
+ )
+ parser_configure.add_argument(
+ "file", default="~/.openml/config", help="The configuration file to edit or read."
+ )
+
+ args = parser.parse_args()
+ subroutines.get(args.subroutine, lambda _: parser.print_help())(args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/setup.py b/setup.py
index dc1a58863..6983bad40 100644
--- a/setup.py
+++ b/setup.py
@@ -102,4 +102,5 @@
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
],
+ entry_points={"console_scripts": ["openml=openml.cli:main"]},
)
From 0f812f2f869127d69cbbd4806dcaaed706c6dfd3 Mon Sep 17 00:00:00 2001
From: PGijsbers
Date: Thu, 8 Apr 2021 10:42:48 +0200
Subject: [PATCH 02/13] Allow setting of API key through CLI
- Add function to set any field in the configuration file
- Add function to read out the configuration file
- Towards full configurability from CLI
---
openml/cli.py | 108 ++++++++++++++++++++++++++++++++++++++++++++---
openml/config.py | 47 +++++++++++++++++----
2 files changed, 142 insertions(+), 13 deletions(-)
diff --git a/openml/cli.py b/openml/cli.py
index 8f20b3beb..496344d35 100644
--- a/openml/cli.py
+++ b/openml/cli.py
@@ -1,15 +1,95 @@
"""" Command Line Interface for `openml` to configure its settings. """
import argparse
+import string
+from typing import Union, Callable
-# from openml import config
+
+from openml import config
+
+
+def is_hex(string_: str) -> bool:
+ return all(c in string.hexdigits for c in string_)
+
+
+def looks_like_api_key(apikey: str) -> bool:
+ return len(apikey) == 32 and is_hex(apikey)
+
+
+def wait_until_valid_input(
+ prompt: str,
+ check: Callable[[str], bool],
+ error_message: Union[str, Callable[[str], str]] = "That is not a valid response.",
+) -> str:
+ """ Asks `prompt` until an input is received which returns True for `check`.
+
+ Parameters
+ ----------
+ prompt: str
+ message to display
+ check: Callable[[str], bool]
+ function to call with the given input, should return true only if the input is valid.
+ error_message: Union[str, Callable[[str], str]
+ a message to display on invalid input, or a `str`->`str` function that can give feedback
+ specific to the error.
+
+ Returns
+ -------
+
+ """
+
+ response = input(prompt)
+ while not check(response):
+ if isinstance(error_message, str):
+ print(error_message)
+ else:
+ print(error_message(response), end="\n\n")
+ response = input(prompt)
+
+ return response
+
+
+def print_configuration():
+ file = config.determine_config_file_path()
+ header = f"File '{file}' contains (or defaults to):"
+ print(header)
+
+ max_key_length = max(map(len, config.get_config_as_dict()))
+ for field, value in config.get_config_as_dict().items():
+ print(f"{field.ljust(max_key_length)}: {value}")
+
+
+def configure_apikey() -> None:
+ print(f"\nYour current API key is set to: '{config.apikey}'")
+ print("You can get an API key at https://new.openml.org")
+ print("You must create an account if you don't have one yet.")
+ print(" 1. Log in with the account.")
+ print(" 2. Navigate to the profile page (top right circle > Your Profile). ")
+ print(" 3. Click the API Key button to reach the page with your API key.")
+ print("If you have any difficulty following these instructions, please let us know on Github.")
+
+ def apikey_error(apikey: str) -> str:
+ if len(apikey) != 32:
+ return f"The key should contain 32 characters but contains {len(apikey)}."
+ if not is_hex(apikey):
+ return "Some characters are not hexadecimal."
+ return "This does not look like an API key."
+
+ response = wait_until_valid_input(
+ prompt="Please enter your API key:", check=looks_like_api_key, error_message=apikey_error,
+ )
+
+ config.set_field_in_config_file("apikey", response)
+ print("Key set.")
def configure(args: argparse.Namespace):
""" Configures the openml configuration file. """
- print("Configuring", args.file)
-
- # check if API key exists, if so ask to overwrite.
+ print_configuration()
+ set_functions = {
+ "apikey": configure_apikey,
+ }
+ set_functions.get(args.field, quit)()
def main() -> None:
@@ -21,8 +101,26 @@ def main() -> None:
parser_configure = subparsers.add_parser(
"configure", description="Set or read variables in your configuration file.",
)
+
parser_configure.add_argument(
- "file", default="~/.openml/config", help="The configuration file to edit or read."
+ "field",
+ type=str,
+ choices=[
+ "apikey",
+ "server",
+ "cachedir",
+ "avoid_duplicate_runs",
+ "connection_n_retries",
+ "verbosity",
+ "all",
+ "none",
+ ],
+ default="all",
+ nargs="?",
+ help="The field you wish to edit, auto-completes the field name, "
+ "e.g. `openml configure cache` is equivalent to `openml configure cachedir`."
+ "Choosing 'all' lets you configure all fields one by one."
+ "Choosing 'none' will print out the current configuration.",
)
args = parser.parse_args()
diff --git a/openml/config.py b/openml/config.py
index 9e2e697d5..973276295 100644
--- a/openml/config.py
+++ b/openml/config.py
@@ -9,7 +9,7 @@
import os
from pathlib import Path
import platform
-from typing import Tuple, cast
+from typing import Tuple, cast, Any
from io import StringIO
import configparser
@@ -177,6 +177,16 @@ def stop_using_configuration_for_example(cls):
cls._start_last_called = False
+def determine_config_file_path() -> Path:
+ if platform.system() == "Linux":
+ config_dir = Path(os.environ.get("XDG_CONFIG_HOME", Path("~") / ".config" / "openml"))
+ else:
+ config_dir = Path("~") / ".openml"
+ # Still use os.path.expanduser to trigger the mock in the unit test
+ config_dir = Path(os.path.expanduser(config_dir))
+ return config_dir / "config"
+
+
def _setup(config=None):
"""Setup openml package. Called on first import.
@@ -193,13 +203,8 @@ def _setup(config=None):
global connection_n_retries
global max_retries
- if platform.system() == "Linux":
- config_dir = Path(os.environ.get("XDG_CONFIG_HOME", Path("~") / ".config" / "openml"))
- else:
- config_dir = Path("~") / ".openml"
- # Still use os.path.expanduser to trigger the mock in the unit test
- config_dir = Path(os.path.expanduser(config_dir))
- config_file = config_dir / "config"
+ config_file = determine_config_file_path()
+ config_dir = config_file.parent
# read config file, create directory for config file
if not os.path.exists(config_dir):
@@ -258,6 +263,32 @@ def _get(config, key):
)
+def set_field_in_config_file(field: str, value: Any):
+ """ Overwrites the `field` in the configuration file with the new `value`. """
+ fields = [
+ "apikey",
+ "server",
+ "cache_directory",
+ "avoid_duplicate_runs",
+ "connection_n_retries",
+ "max_retries",
+ ]
+ if field not in fields:
+ return ValueError(f"Field '{field}' is not valid and must be one of '{fields}'.")
+
+ globals()[field] = value
+ config_file = determine_config_file_path()
+ config = _parse_config(str(config_file))
+ with open(config_file, "w") as fh:
+ for f in fields:
+ # We can't blindly set all values based on globals() because the user when the user
+ # sets it through config.FIELD it should not be stored to file.
+ value = config.get("FAKE_SECTION", f)
+ if f == field:
+ value = globals()[f]
+ fh.write(f"{f} = {value}\n")
+
+
def _parse_config(config_file: str):
""" Parse the config file, set up defaults. """
config = configparser.RawConfigParser(defaults=_defaults)
From ef8434b88861cb305542157d2ce8f81700b4b2c8 Mon Sep 17 00:00:00 2001
From: PGijsbers
Date: Thu, 8 Apr 2021 10:48:24 +0200
Subject: [PATCH 03/13] Remove autocomplete promise, use _defaults
Autocomplete seems to be incompatible with `choices`, so I'll ignore
that for now. We also use `config._defaults` instead of an explicit
list to avoid duplication.
---
openml/cli.py | 14 ++------------
openml/config.py | 14 +++-----------
2 files changed, 5 insertions(+), 23 deletions(-)
diff --git a/openml/cli.py b/openml/cli.py
index 496344d35..3bff36d17 100644
--- a/openml/cli.py
+++ b/openml/cli.py
@@ -105,20 +105,10 @@ def main() -> None:
parser_configure.add_argument(
"field",
type=str,
- choices=[
- "apikey",
- "server",
- "cachedir",
- "avoid_duplicate_runs",
- "connection_n_retries",
- "verbosity",
- "all",
- "none",
- ],
+ choices=[*config._defaults.keys(), "all", "none"],
default="all",
nargs="?",
- help="The field you wish to edit, auto-completes the field name, "
- "e.g. `openml configure cache` is equivalent to `openml configure cachedir`."
+ help="The field you wish to edit."
"Choosing 'all' lets you configure all fields one by one."
"Choosing 'none' will print out the current configuration.",
)
diff --git a/openml/config.py b/openml/config.py
index 973276295..a112a6613 100644
--- a/openml/config.py
+++ b/openml/config.py
@@ -265,22 +265,14 @@ def _get(config, key):
def set_field_in_config_file(field: str, value: Any):
""" Overwrites the `field` in the configuration file with the new `value`. """
- fields = [
- "apikey",
- "server",
- "cache_directory",
- "avoid_duplicate_runs",
- "connection_n_retries",
- "max_retries",
- ]
- if field not in fields:
- return ValueError(f"Field '{field}' is not valid and must be one of '{fields}'.")
+ if field not in _defaults:
+ return ValueError(f"Field '{field}' is not valid and must be one of '{_defaults.keys()}'.")
globals()[field] = value
config_file = determine_config_file_path()
config = _parse_config(str(config_file))
with open(config_file, "w") as fh:
- for f in fields:
+ for f in _defaults.keys():
# We can't blindly set all values based on globals() because the user when the user
# sets it through config.FIELD it should not be stored to file.
value = config.get("FAKE_SECTION", f)
From a64b3e9c9ae15281b406dce5dd0128466c90783a Mon Sep 17 00:00:00 2001
From: PGijsbers
Date: Thu, 8 Apr 2021 11:21:50 +0200
Subject: [PATCH 04/13] Add server configuration
---
openml/cli.py | 47 ++++++++++++++++++++++++++++++++++++++++++++---
openml/config.py | 5 ++++-
2 files changed, 48 insertions(+), 4 deletions(-)
diff --git a/openml/cli.py b/openml/cli.py
index 3bff36d17..1a1e9da47 100644
--- a/openml/cli.py
+++ b/openml/cli.py
@@ -3,6 +3,7 @@
import argparse
import string
from typing import Union, Callable
+from urllib.parse import urlparse
from openml import config
@@ -16,6 +17,14 @@ def looks_like_api_key(apikey: str) -> bool:
return len(apikey) == 32 and is_hex(apikey)
+def looks_like_url(url: str) -> bool:
+ # There's no thorough url parser, but we only seem to use netloc.
+ try:
+ return bool(urlparse(url).netloc)
+ except Exception:
+ return False
+
+
def wait_until_valid_input(
prompt: str,
check: Callable[[str], bool],
@@ -83,13 +92,43 @@ def apikey_error(apikey: str) -> str:
print("Key set.")
+def configure_server():
+ print("\nSpecify which server you wish to connect to.")
+
+ def is_valid_server(server: str) -> bool:
+ is_shorthand = server in ["test", "production"]
+ return is_shorthand or looks_like_url(server)
+
+ response = wait_until_valid_input(
+ prompt="Specify a url or use 'test' or 'production' as a shorthand:",
+ check=is_valid_server,
+ error_message="Must be 'test', 'production' or a url.",
+ )
+
+ if response == "test":
+ response = "https://test.openml.org/api/v1/xml"
+ elif response == "production":
+ response = "https://www.openml.org/api/v1/xml"
+
+ config.set_field_in_config_file("server", response)
+
+
def configure(args: argparse.Namespace):
- """ Configures the openml configuration file. """
+ """ Calls the right submenu(s) to edit `args.field` in the configuration file. """
print_configuration()
set_functions = {
"apikey": configure_apikey,
+ "server": configure_server,
}
- set_functions.get(args.field, quit)()
+
+ def not_supported_yet():
+ print(f"Setting '{args.field}' is not supported yet.")
+
+ if args.field not in ["all", "none"]:
+ set_functions.get(args.field, not_supported_yet)()
+ elif args.field == "all":
+ for set_field_function in set_functions.values():
+ set_field_function()
def main() -> None:
@@ -99,7 +138,9 @@ def main() -> None:
subparsers = parser.add_subparsers(dest="subroutine")
parser_configure = subparsers.add_parser(
- "configure", description="Set or read variables in your configuration file.",
+ "configure",
+ description="Set or read variables in your configuration file. For more help also see "
+ "'https://openml.github.io/openml-python/master/usage.html#configuration'.",
)
parser_configure.add_argument(
diff --git a/openml/config.py b/openml/config.py
index a112a6613..9db8a27c3 100644
--- a/openml/config.py
+++ b/openml/config.py
@@ -273,8 +273,11 @@ def set_field_in_config_file(field: str, value: Any):
config = _parse_config(str(config_file))
with open(config_file, "w") as fh:
for f in _defaults.keys():
- # We can't blindly set all values based on globals() because the user when the user
+ # We can't blindly set all values based on globals() because when the user
# sets it through config.FIELD it should not be stored to file.
+ # There doesn't seem to be a way to avoid writing defaults to file with configparser,
+ # because it is impossible to distinguish from an explicitly set value that matches
+ # the default value, to one that was set to its default because it was omitted.
value = config.get("FAKE_SECTION", f)
if f == field:
value = globals()[f]
From 2ff55d9d37a12fd410b57f81bdcb69bc0480f5b7 Mon Sep 17 00:00:00 2001
From: PGijsbers
Date: Fri, 9 Apr 2021 10:23:50 +0200
Subject: [PATCH 05/13] Allow fields to be set directly non-interactively
With the `openml configure FIELD VALUE` command.
---
openml/cli.py | 93 ++++++++++++++++++++++++++++++++-------------------
1 file changed, 59 insertions(+), 34 deletions(-)
diff --git a/openml/cli.py b/openml/cli.py
index 1a1e9da47..201367b91 100644
--- a/openml/cli.py
+++ b/openml/cli.py
@@ -68,15 +68,12 @@ def print_configuration():
print(f"{field.ljust(max_key_length)}: {value}")
-def configure_apikey() -> None:
- print(f"\nYour current API key is set to: '{config.apikey}'")
- print("You can get an API key at https://new.openml.org")
- print("You must create an account if you don't have one yet.")
- print(" 1. Log in with the account.")
- print(" 2. Navigate to the profile page (top right circle > Your Profile). ")
- print(" 3. Click the API Key button to reach the page with your API key.")
- print("If you have any difficulty following these instructions, please let us know on Github.")
+def verbose_set(field, value):
+ config.set_field_in_config_file(field, value)
+ print(f"{field} set to '{value}'.")
+
+def configure_apikey(value: str) -> None:
def apikey_error(apikey: str) -> str:
if len(apikey) != 32:
return f"The key should contain 32 characters but contains {len(apikey)}."
@@ -84,51 +81,75 @@ def apikey_error(apikey: str) -> str:
return "Some characters are not hexadecimal."
return "This does not look like an API key."
- response = wait_until_valid_input(
- prompt="Please enter your API key:", check=looks_like_api_key, error_message=apikey_error,
- )
-
- config.set_field_in_config_file("apikey", response)
- print("Key set.")
-
-
-def configure_server():
- print("\nSpecify which server you wish to connect to.")
-
+ if value is not None:
+ if not looks_like_api_key(value):
+ print(apikey_error(value))
+ quit(-1)
+ else:
+ print(f"\nYour current API key is set to: '{config.apikey}'")
+ print("You can get an API key at https://new.openml.org")
+ print("You must create an account if you don't have one yet.")
+ print(" 1. Log in with the account.")
+ print(" 2. Navigate to the profile page (top right circle > Your Profile). ")
+ print(" 3. Click the API Key button to reach the page with your API key.")
+ print("If you have any difficulty following these instructions, let us know on Github.")
+
+ value = wait_until_valid_input(
+ prompt="Please enter your API key:",
+ check=looks_like_api_key,
+ error_message=apikey_error,
+ )
+ verbose_set("apikey", value)
+
+
+def configure_server(value: str):
def is_valid_server(server: str) -> bool:
is_shorthand = server in ["test", "production"]
return is_shorthand or looks_like_url(server)
- response = wait_until_valid_input(
- prompt="Specify a url or use 'test' or 'production' as a shorthand:",
- check=is_valid_server,
- error_message="Must be 'test', 'production' or a url.",
- )
+ error_message = "Must be 'test', 'production' or a url."
+
+ if value is not None:
+ if not is_valid_server(value):
+ print(error_message)
+ quit(-1)
+ else:
+ print("\nSpecify which server you wish to connect to.")
+ value = wait_until_valid_input(
+ prompt="Specify a url or use 'test' or 'production' as a shorthand:",
+ check=is_valid_server,
+ error_message=error_message,
+ )
- if response == "test":
- response = "https://test.openml.org/api/v1/xml"
- elif response == "production":
- response = "https://www.openml.org/api/v1/xml"
+ if value == "test":
+ value = "https://test.openml.org/api/v1/xml"
+ elif value == "production":
+ value = "https://www.openml.org/api/v1/xml"
- config.set_field_in_config_file("server", response)
+ verbose_set("server", value)
def configure(args: argparse.Namespace):
""" Calls the right submenu(s) to edit `args.field` in the configuration file. """
- print_configuration()
set_functions = {
"apikey": configure_apikey,
"server": configure_server,
}
- def not_supported_yet():
+ def not_supported_yet(_):
print(f"Setting '{args.field}' is not supported yet.")
if args.field not in ["all", "none"]:
- set_functions.get(args.field, not_supported_yet)()
- elif args.field == "all":
+ set_functions.get(args.field, not_supported_yet)(args.value)
+ else:
+ if args.value is not None:
+ print(f"Can not set value ('{args.value}') when field is specified as '{args.field}'.")
+ quit()
+ print_configuration()
+
+ if args.field == "all":
for set_field_function in set_functions.values():
- set_field_function()
+ set_field_function(args.value)
def main() -> None:
@@ -154,6 +175,10 @@ def main() -> None:
"Choosing 'none' will print out the current configuration.",
)
+ parser_configure.add_argument(
+ "value", type=str, default=None, nargs="?", help="The value to set the FIELD to.",
+ )
+
args = parser.parse_args()
subroutines.get(args.subroutine, lambda _: parser.print_help())(args)
From 8c00fc05d8179154915c7f57e648a829b3a91b84 Mon Sep 17 00:00:00 2001
From: PGijsbers
Date: Fri, 9 Apr 2021 11:10:48 +0200
Subject: [PATCH 06/13] Combine error and check functionalities
Otherwise you have to duplicate all checks in the error message
function.
---
openml/cli.py | 99 +++++++++++++++++++++++++++++++--------------------
1 file changed, 61 insertions(+), 38 deletions(-)
diff --git a/openml/cli.py b/openml/cli.py
index 201367b91..900d8fdfc 100644
--- a/openml/cli.py
+++ b/openml/cli.py
@@ -1,8 +1,11 @@
"""" Command Line Interface for `openml` to configure its settings. """
import argparse
+
+# import os
+# import pathlib
import string
-from typing import Union, Callable
+from typing import Callable # Union, Callable
from urllib.parse import urlparse
@@ -13,10 +16,6 @@ def is_hex(string_: str) -> bool:
return all(c in string.hexdigits for c in string_)
-def looks_like_api_key(apikey: str) -> bool:
- return len(apikey) == 32 and is_hex(apikey)
-
-
def looks_like_url(url: str) -> bool:
# There's no thorough url parser, but we only seem to use netloc.
try:
@@ -25,35 +24,29 @@ def looks_like_url(url: str) -> bool:
return False
-def wait_until_valid_input(
- prompt: str,
- check: Callable[[str], bool],
- error_message: Union[str, Callable[[str], str]] = "That is not a valid response.",
-) -> str:
+def wait_until_valid_input(prompt: str, check: Callable[[str], str],) -> str:
""" Asks `prompt` until an input is received which returns True for `check`.
Parameters
----------
prompt: str
message to display
- check: Callable[[str], bool]
- function to call with the given input, should return true only if the input is valid.
- error_message: Union[str, Callable[[str], str]
- a message to display on invalid input, or a `str`->`str` function that can give feedback
- specific to the error.
+ check: Callable[[str], str]
+ function to call with the given input, that provides an error message if the input is not
+ valid otherwise, and False-like otherwise.
Returns
-------
+ valid input
"""
response = input(prompt)
- while not check(response):
- if isinstance(error_message, str):
- print(error_message)
- else:
- print(error_message(response), end="\n\n")
+ error_message = check(response)
+ while error_message:
+ print(error_message, end="\n\n")
response = input(prompt)
+ error_message = check(response)
return response
@@ -74,16 +67,17 @@ def verbose_set(field, value):
def configure_apikey(value: str) -> None:
- def apikey_error(apikey: str) -> str:
+ def check_apikey(apikey: str) -> str:
if len(apikey) != 32:
return f"The key should contain 32 characters but contains {len(apikey)}."
if not is_hex(apikey):
return "Some characters are not hexadecimal."
- return "This does not look like an API key."
+ return ""
if value is not None:
- if not looks_like_api_key(value):
- print(apikey_error(value))
+ apikey_malformed = check_apikey(value)
+ if apikey_malformed:
+ print(apikey_malformed)
quit(-1)
else:
print(f"\nYour current API key is set to: '{config.apikey}'")
@@ -94,31 +88,27 @@ def apikey_error(apikey: str) -> str:
print(" 3. Click the API Key button to reach the page with your API key.")
print("If you have any difficulty following these instructions, let us know on Github.")
- value = wait_until_valid_input(
- prompt="Please enter your API key:",
- check=looks_like_api_key,
- error_message=apikey_error,
- )
+ value = wait_until_valid_input(prompt="Please enter your API key:", check=check_apikey,)
verbose_set("apikey", value)
-def configure_server(value: str):
- def is_valid_server(server: str) -> bool:
+def configure_server(value: str) -> None:
+ def check_server(server: str) -> str:
is_shorthand = server in ["test", "production"]
- return is_shorthand or looks_like_url(server)
-
- error_message = "Must be 'test', 'production' or a url."
+ if is_shorthand or looks_like_url(server):
+ return ""
+ return "Must be 'test', 'production' or a url."
if value is not None:
- if not is_valid_server(value):
- print(error_message)
+ server_malformed = check_server(value)
+ if server_malformed:
+ print(server_malformed)
quit(-1)
else:
print("\nSpecify which server you wish to connect to.")
value = wait_until_valid_input(
prompt="Specify a url or use 'test' or 'production' as a shorthand:",
- check=is_valid_server,
- error_message=error_message,
+ check=check_server,
)
if value == "test":
@@ -129,6 +119,39 @@ def is_valid_server(server: str) -> bool:
verbose_set("server", value)
+# def configure_cachedir(value: str) -> None:
+# def is_valid_cachedir(path: str) -> bool:
+# return True
+#
+# def cache_dir_error(path: str) -> str:
+# p = pathlib.Path(path)
+# if p.is_file():
+# return f"'{path}' is a file, not a directory."
+# expanded = p.expanduser()
+# if not expanded.is_absolute():
+# return f"'{path}' is not absolute (even after expanding '~')."
+# if expanded.exists():
+# try:
+# os.mkdir(expanded)
+# except PermissionError:
+# return f"'{path}' does not exist and there are not enough permissions to create it."
+# return "is not valid"
+#
+# if value is not None:
+# if not is_valid_cachedir(value):
+# print(cache_dir_error(value))
+# quit(-1)
+# else:
+# print("\nConfiguring the cache directory. It can not be a relative path.")
+# value = wait_until_valid_input(
+# prompt="Specify the directory to use (or create) as cache directory:",
+# check=is_valid_cachedir,
+# )
+#
+# print("NOTE: Data from your old cache directory is not moved over.")
+# verbose_set("cachedir", value)
+
+
def configure(args: argparse.Namespace):
""" Calls the right submenu(s) to edit `args.field` in the configuration file. """
set_functions = {
From a41c41f47070a4160e8f49ff2b4795a3b740938e Mon Sep 17 00:00:00 2001
From: PGijsbers
Date: Fri, 9 Apr 2021 14:11:40 +0200
Subject: [PATCH 07/13] Share logic about setting/collecting the value
---
openml/cli.py | 161 +++++++++++++++++++++++++++++---------------------
1 file changed, 93 insertions(+), 68 deletions(-)
diff --git a/openml/cli.py b/openml/cli.py
index 900d8fdfc..32a62ccf6 100644
--- a/openml/cli.py
+++ b/openml/cli.py
@@ -1,11 +1,10 @@
"""" Command Line Interface for `openml` to configure its settings. """
import argparse
-
-# import os
-# import pathlib
+import os
+import pathlib
import string
-from typing import Callable # Union, Callable
+from typing import Union, Callable
from urllib.parse import urlparse
@@ -74,22 +73,23 @@ def check_apikey(apikey: str) -> str:
return "Some characters are not hexadecimal."
return ""
- if value is not None:
- apikey_malformed = check_apikey(value)
- if apikey_malformed:
- print(apikey_malformed)
- quit(-1)
- else:
- print(f"\nYour current API key is set to: '{config.apikey}'")
- print("You can get an API key at https://new.openml.org")
- print("You must create an account if you don't have one yet.")
- print(" 1. Log in with the account.")
- print(" 2. Navigate to the profile page (top right circle > Your Profile). ")
- print(" 3. Click the API Key button to reach the page with your API key.")
- print("If you have any difficulty following these instructions, let us know on Github.")
+ instructions = (
+ f"Your current API key is set to: '{config.apikey}'"
+ "You can get an API key at https://new.openml.org"
+ "You must create an account if you don't have one yet."
+ " 1. Log in with the account."
+ " 2. Navigate to the profile page (top right circle > Your Profile). "
+ " 3. Click the API Key button to reach the page with your API key."
+ "If you have any difficulty following these instructions, let us know on Github."
+ )
- value = wait_until_valid_input(prompt="Please enter your API key:", check=check_apikey,)
- verbose_set("apikey", value)
+ configure_field(
+ field="apikey",
+ value=value,
+ check_with_message=check_apikey,
+ intro_message=instructions,
+ input_message="Please enter your API key:",
+ )
def configure_server(value: str) -> None:
@@ -99,57 +99,81 @@ def check_server(server: str) -> str:
return ""
return "Must be 'test', 'production' or a url."
+ configure_field(
+ field="server",
+ value=value,
+ check_with_message=check_server,
+ intro_message="Specify which server you wish to connect to.",
+ input_message="Specify a url or use 'test' or 'production' as a shorthand:",
+ )
+
+
+def configure_cachedir(value: str) -> None:
+ def check_cache_dir(path: str) -> str:
+ p = pathlib.Path(path)
+ if p.is_file():
+ return f"'{path}' is a file, not a directory."
+ expanded = p.expanduser()
+ if not expanded.is_absolute():
+ return f"'{path}' is not absolute (even after expanding '~')."
+ if not expanded.exists():
+ try:
+ os.mkdir(expanded)
+ except PermissionError:
+ return f"'{path}' does not exist and there are not enough permissions to create it."
+ return ""
+
+ configure_field(
+ field="cachedir",
+ value=value,
+ check_with_message=check_cache_dir,
+ intro_message="Configuring the cache directory. It can not be a relative path.",
+ input_message="Specify the directory to use (or create) as cache directory:",
+ )
+ print("NOTE: Data from your old cache directory is not moved over.")
+
+
+def configure_field(
+ field: str,
+ value: Union[None, str],
+ check_with_message: Callable[[str], str],
+ intro_message: str,
+ input_message: str,
+) -> None:
+ """ Configure `field` with `value`. If `value` is None ask the user for input.
+
+ `value` and user input are validated with `check_with_message` function, and
+ in the case of user input the user gets to input a new value.
+ The change is saved in the openml configuration file.
+ In case an invalid `value` is supplied, no changes are made.
+
+ Parameters
+ ----------
+ field: str
+ Field to set.
+ value: str, None
+ Value to field to. If `None` will ask user for input.
+ check_with_message: Callable[[str], str]
+ Function which validates `value` or user input, and returns either an error message if it
+ is invalid, or a False-like value if `value` is valid.
+ intro_message: str
+ Message that is printed once if user input is requested (e.g. instructions).
+ input_message: str
+ Message that comes with the input prompt.
+
+ Returns
+ -------
+
+ """
if value is not None:
- server_malformed = check_server(value)
- if server_malformed:
- print(server_malformed)
- quit(-1)
+ malformed_input = check_with_message(value)
+ if malformed_input:
+ print(malformed_input)
+ quit()
else:
- print("\nSpecify which server you wish to connect to.")
- value = wait_until_valid_input(
- prompt="Specify a url or use 'test' or 'production' as a shorthand:",
- check=check_server,
- )
-
- if value == "test":
- value = "https://test.openml.org/api/v1/xml"
- elif value == "production":
- value = "https://www.openml.org/api/v1/xml"
-
- verbose_set("server", value)
-
-
-# def configure_cachedir(value: str) -> None:
-# def is_valid_cachedir(path: str) -> bool:
-# return True
-#
-# def cache_dir_error(path: str) -> str:
-# p = pathlib.Path(path)
-# if p.is_file():
-# return f"'{path}' is a file, not a directory."
-# expanded = p.expanduser()
-# if not expanded.is_absolute():
-# return f"'{path}' is not absolute (even after expanding '~')."
-# if expanded.exists():
-# try:
-# os.mkdir(expanded)
-# except PermissionError:
-# return f"'{path}' does not exist and there are not enough permissions to create it."
-# return "is not valid"
-#
-# if value is not None:
-# if not is_valid_cachedir(value):
-# print(cache_dir_error(value))
-# quit(-1)
-# else:
-# print("\nConfiguring the cache directory. It can not be a relative path.")
-# value = wait_until_valid_input(
-# prompt="Specify the directory to use (or create) as cache directory:",
-# check=is_valid_cachedir,
-# )
-#
-# print("NOTE: Data from your old cache directory is not moved over.")
-# verbose_set("cachedir", value)
+ print(intro_message)
+ value = wait_until_valid_input(prompt=input_message, check=check_with_message,)
+ verbose_set(field, value)
def configure(args: argparse.Namespace):
@@ -157,6 +181,7 @@ def configure(args: argparse.Namespace):
set_functions = {
"apikey": configure_apikey,
"server": configure_server,
+ "cachedir": configure_cachedir,
}
def not_supported_yet(_):
From 7703d3b4794239ba320943e7b898e75039f50980 Mon Sep 17 00:00:00 2001
From: PGijsbers
Date: Fri, 9 Apr 2021 14:58:52 +0200
Subject: [PATCH 08/13] Complete CLI for other fields.
Max_retries is excluded because it should not be user configurable, and
will most likely be removed.
Verbosity is configurable but is currently not actually used.
---
openml/cli.py | 95 +++++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 80 insertions(+), 15 deletions(-)
diff --git a/openml/cli.py b/openml/cli.py
index 32a62ccf6..9f726ed2f 100644
--- a/openml/cli.py
+++ b/openml/cli.py
@@ -74,12 +74,12 @@ def check_apikey(apikey: str) -> str:
return ""
instructions = (
- f"Your current API key is set to: '{config.apikey}'"
- "You can get an API key at https://new.openml.org"
- "You must create an account if you don't have one yet."
- " 1. Log in with the account."
- " 2. Navigate to the profile page (top right circle > Your Profile). "
- " 3. Click the API Key button to reach the page with your API key."
+ f"Your current API key is set to: '{config.apikey}'. "
+ "You can get an API key at https://new.openml.org. "
+ "You must create an account if you don't have one yet:\n"
+ " 1. Log in with the account.\n"
+ " 2. Navigate to the profile page (top right circle > Your Profile). \n"
+ " 3. Click the API Key button to reach the page with your API key.\n"
"If you have any difficulty following these instructions, let us know on Github."
)
@@ -104,7 +104,7 @@ def check_server(server: str) -> str:
value=value,
check_with_message=check_server,
intro_message="Specify which server you wish to connect to.",
- input_message="Specify a url or use 'test' or 'production' as a shorthand:",
+ input_message="Specify a url or use 'test' or 'production' as a shorthand: ",
)
@@ -128,11 +128,74 @@ def check_cache_dir(path: str) -> str:
value=value,
check_with_message=check_cache_dir,
intro_message="Configuring the cache directory. It can not be a relative path.",
- input_message="Specify the directory to use (or create) as cache directory:",
+ input_message="Specify the directory to use (or create) as cache directory: ",
)
print("NOTE: Data from your old cache directory is not moved over.")
+def configure_connection_n_retries(value: str) -> None:
+ def valid_connection_retries(value: str) -> str:
+ if not value.isdigit():
+ return f"Must be an integer number (smaller than {config.max_retries})."
+ if int(value) > config.max_retries:
+ return f"connection_n_retries may not exceed {config.max_retries}."
+ if int(value) == 0:
+ return "connection_n_retries must be non-zero."
+ return ""
+
+ configure_field(
+ field="connection_n_retries",
+ value=value,
+ check_with_message=valid_connection_retries,
+ intro_message="Configuring the number of times to attempt to connect to the OpenML Server",
+ input_message=f"Enter an integer between 0 and {config.max_retries}: ",
+ )
+
+
+def configure_avoid_duplicate_runs(value: str) -> None:
+ def is_python_bool(value: str) -> str:
+ if value in ["True", "False"]:
+ return ""
+ return "Must be 'True' or 'False' (mind the capital)."
+
+ intro_message = (
+ "If set to True, when `run_flow_on_task` or similar methods are called a lookup is "
+ "performed to see if there already exists such a run on the server. "
+ "If so, download those results instead. "
+ "If set to False, runs will always be executed."
+ )
+
+ configure_field(
+ field="avoid_duplicate_runs",
+ value=value,
+ check_with_message=is_python_bool,
+ intro_message=intro_message,
+ input_message="Enter 'True' or 'False': ",
+ )
+
+
+def configure_verbosity(value: str) -> None:
+ def is_zero_through_two(value: str) -> str:
+ if value in ["0", "1", "2"]:
+ return ""
+ return "Must be '0', '1' or '2'."
+
+ intro_message = (
+ "Set the verbosity of log messages which should be shown by openml-python."
+ " 0: normal output (warnings and errors)"
+ " 1: info output (some high-level progress output)"
+ " 2: debug output (detailed information (for developers))"
+ )
+
+ configure_field(
+ field="verbosity",
+ value=value,
+ check_with_message=is_zero_through_two,
+ intro_message=intro_message,
+ input_message="Enter '0', '1' or '2': ",
+ )
+
+
def configure_field(
field: str,
value: Union[None, str],
@@ -160,10 +223,6 @@ def configure_field(
Message that is printed once if user input is requested (e.g. instructions).
input_message: str
Message that comes with the input prompt.
-
- Returns
- -------
-
"""
if value is not None:
malformed_input = check_with_message(value)
@@ -182,6 +241,9 @@ def configure(args: argparse.Namespace):
"apikey": configure_apikey,
"server": configure_server,
"cachedir": configure_cachedir,
+ "connection_n_retries": configure_connection_n_retries,
+ "avoid_duplicate_runs": configure_avoid_duplicate_runs,
+ "verbosity": configure_verbosity,
}
def not_supported_yet(_):
@@ -197,6 +259,7 @@ def not_supported_yet(_):
if args.field == "all":
for set_field_function in set_functions.values():
+ print() # Visually separating the output by field.
set_field_function(args.value)
@@ -212,14 +275,16 @@ def main() -> None:
"'https://openml.github.io/openml-python/master/usage.html#configuration'.",
)
+ configurable_fields = [f for f in config._defaults if f not in ["max_retries"]]
+
parser_configure.add_argument(
"field",
type=str,
- choices=[*config._defaults.keys(), "all", "none"],
+ choices=[*configurable_fields, "all", "none"],
default="all",
nargs="?",
- help="The field you wish to edit."
- "Choosing 'all' lets you configure all fields one by one."
+ help="The field you wish to edit. "
+ "Choosing 'all' lets you configure all fields one by one. "
"Choosing 'none' will print out the current configuration.",
)
From 75e37833b295b84fcee74028553214a96c74b61b Mon Sep 17 00:00:00 2001
From: PGijsbers
Date: Fri, 9 Apr 2021 15:27:02 +0200
Subject: [PATCH 09/13] Bring back sanitizing user input
And extend it to the bool inputs.
---
openml/cli.py | 63 +++++++++++++++++++++++++++++++++++++++------------
1 file changed, 48 insertions(+), 15 deletions(-)
diff --git a/openml/cli.py b/openml/cli.py
index 9f726ed2f..0faa34d45 100644
--- a/openml/cli.py
+++ b/openml/cli.py
@@ -23,7 +23,9 @@ def looks_like_url(url: str) -> bool:
return False
-def wait_until_valid_input(prompt: str, check: Callable[[str], str],) -> str:
+def wait_until_valid_input(
+ prompt: str, check: Callable[[str], str], sanitize: Union[Callable[[str], str], None]
+) -> str:
""" Asks `prompt` until an input is received which returns True for `check`.
Parameters
@@ -33,18 +35,23 @@ def wait_until_valid_input(prompt: str, check: Callable[[str], str],) -> str:
check: Callable[[str], str]
function to call with the given input, that provides an error message if the input is not
valid otherwise, and False-like otherwise.
+ sanitize: Callable[[str], str], optional
+ A function which attempts to sanitize the user input (e.g. auto-complete).
Returns
-------
valid input
"""
-
response = input(prompt)
+ if sanitize:
+ response = sanitize(response)
error_message = check(response)
while error_message:
print(error_message, end="\n\n")
response = input(prompt)
+ if sanitize:
+ response = sanitize(response)
error_message = check(response)
return response
@@ -99,12 +106,20 @@ def check_server(server: str) -> str:
return ""
return "Must be 'test', 'production' or a url."
+ def replace_shorthand(server: str) -> str:
+ if server == "test":
+ return "https://test.openml.org/api/v1/xml"
+ if server == "production":
+ return "https://www.openml.org/api/v1/xml"
+ return server
+
configure_field(
field="server",
value=value,
check_with_message=check_server,
intro_message="Specify which server you wish to connect to.",
input_message="Specify a url or use 'test' or 'production' as a shorthand: ",
+ sanitize=replace_shorthand,
)
@@ -134,12 +149,12 @@ def check_cache_dir(path: str) -> str:
def configure_connection_n_retries(value: str) -> None:
- def valid_connection_retries(value: str) -> str:
- if not value.isdigit():
+ def valid_connection_retries(n: str) -> str:
+ if not n.isdigit():
return f"Must be an integer number (smaller than {config.max_retries})."
- if int(value) > config.max_retries:
+ if int(n) > config.max_retries:
return f"connection_n_retries may not exceed {config.max_retries}."
- if int(value) == 0:
+ if int(n) == 0:
return "connection_n_retries must be non-zero."
return ""
@@ -153,11 +168,18 @@ def valid_connection_retries(value: str) -> str:
def configure_avoid_duplicate_runs(value: str) -> None:
- def is_python_bool(value: str) -> str:
- if value in ["True", "False"]:
+ def is_python_bool(bool_: str) -> str:
+ if bool_ in ["True", "False"]:
return ""
return "Must be 'True' or 'False' (mind the capital)."
+ def autocomplete_bool(bool_: str) -> str:
+ if bool_.lower() in ["n", "no", "f", "false", "0"]:
+ return "False"
+ if bool_.lower() in ["y", "yes", "t", "true", "1"]:
+ return "True"
+ return bool_
+
intro_message = (
"If set to True, when `run_flow_on_task` or similar methods are called a lookup is "
"performed to see if there already exists such a run on the server. "
@@ -171,12 +193,13 @@ def is_python_bool(value: str) -> str:
check_with_message=is_python_bool,
intro_message=intro_message,
input_message="Enter 'True' or 'False': ",
+ sanitize=autocomplete_bool,
)
def configure_verbosity(value: str) -> None:
- def is_zero_through_two(value: str) -> str:
- if value in ["0", "1", "2"]:
+ def is_zero_through_two(verbosity: str) -> str:
+ if verbosity in ["0", "1", "2"]:
return ""
return "Must be '0', '1' or '2'."
@@ -202,13 +225,15 @@ def configure_field(
check_with_message: Callable[[str], str],
intro_message: str,
input_message: str,
+ sanitize: Union[Callable[[str], str], None] = None,
) -> None:
""" Configure `field` with `value`. If `value` is None ask the user for input.
- `value` and user input are validated with `check_with_message` function, and
- in the case of user input the user gets to input a new value.
- The change is saved in the openml configuration file.
- In case an invalid `value` is supplied, no changes are made.
+ `value` and user input are first corrected/auto-completed with `convert_value` if provided,
+ then validated with `check_with_message` function.
+ If the user input a wrong value in interactive mode, the user gets to input a new value.
+ The new valid value is saved in the openml configuration file.
+ In case an invalid `value` is supplied directly (non-interactive), no changes are made.
Parameters
----------
@@ -223,15 +248,23 @@ def configure_field(
Message that is printed once if user input is requested (e.g. instructions).
input_message: str
Message that comes with the input prompt.
+ sanitize: Union[Callable[[str], str], None]
+ A function to convert user input to 'more acceptable' input, e.g. for auto-complete.
+ If no correction of user input is possible, return the original value.
+ If no function is provided, don't attempt to correct/auto-complete input.
"""
if value is not None:
+ if sanitize:
+ value = sanitize(value)
malformed_input = check_with_message(value)
if malformed_input:
print(malformed_input)
quit()
else:
print(intro_message)
- value = wait_until_valid_input(prompt=input_message, check=check_with_message,)
+ value = wait_until_valid_input(
+ prompt=input_message, check=check_with_message, sanitize=sanitize,
+ )
verbose_set(field, value)
From a41b144b8cbc6303cfdb30cd59b297c98486ed3d Mon Sep 17 00:00:00 2001
From: PGijsbers
Date: Fri, 9 Apr 2021 15:30:32 +0200
Subject: [PATCH 10/13] Add small bit of info about the command line tool
---
doc/usage.rst | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/doc/usage.rst b/doc/usage.rst
index 1d54baa62..49f656cc3 100644
--- a/doc/usage.rst
+++ b/doc/usage.rst
@@ -59,6 +59,10 @@ which are separated by newlines. The following keys are defined:
* 1: info output
* 2: debug output
+This file is easily configurable by the ``openml`` command line interface.
+To see where the file is stored, and what its values are, use `openml configure none`.
+Set any field with ``openml configure FIELD`` or even all fields with just ``openml configure``.
+
~~~~~~~~~~~~
Key concepts
~~~~~~~~~~~~
From fb2c56b0c0db16867a88cdc2b30a71c8d82fa385 Mon Sep 17 00:00:00 2001
From: PGijsbers
Date: Fri, 9 Apr 2021 15:37:40 +0200
Subject: [PATCH 11/13] Add API key configuration note in the introduction
---
examples/20_basic/introduction_tutorial.py | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/examples/20_basic/introduction_tutorial.py b/examples/20_basic/introduction_tutorial.py
index 151692fdc..737362e49 100644
--- a/examples/20_basic/introduction_tutorial.py
+++ b/examples/20_basic/introduction_tutorial.py
@@ -42,13 +42,17 @@
# * After logging in, open your account page (avatar on the top right)
# * Open 'Account Settings', then 'API authentication' to find your API key.
#
-# There are two ways to authenticate:
+# There are two ways to permanently authenticate:
#
+# * Use the ``openml`` CLI tool with ``openml configure apikey MYKEY``,
+# replacing **MYKEY** with your API key.
# * Create a plain text file **~/.openml/config** with the line
# **'apikey=MYKEY'**, replacing **MYKEY** with your API key. The config
# file must be in the directory ~/.openml/config and exist prior to
# importing the openml module.
-# * Run the code below, replacing 'YOURKEY' with your API key.
+#
+# Alternatively, by running the code below and replacing 'YOURKEY' with your API key,
+# you authenticate for the duration of the python process.
#
# .. warning:: This example uploads data. For that reason, this example
# connects to the test server instead. This prevents the live server from
From 149065dc88b0dc22aa03b4c036a96bc1aba7b025 Mon Sep 17 00:00:00 2001
From: PGijsbers
Date: Fri, 9 Apr 2021 15:39:52 +0200
Subject: [PATCH 12/13] Add to progress log
---
doc/progress.rst | 1 +
1 file changed, 1 insertion(+)
diff --git a/doc/progress.rst b/doc/progress.rst
index 05446d61b..1acc7ed68 100644
--- a/doc/progress.rst
+++ b/doc/progress.rst
@@ -10,6 +10,7 @@ Changelog
~~~~~~
* FIX #1035: Render class attributes and methods again.
+* ADD #1049: Add a command line tool for configuration openml-python.
0.12.0
~~~~~~
From 935ee250df6242721bd3fdadb1fcea16906f3818 Mon Sep 17 00:00:00 2001
From: PGijsbers
Date: Fri, 9 Apr 2021 15:45:49 +0200
Subject: [PATCH 13/13] Refactor flow of wait_until_valid_input
---
openml/cli.py | 14 ++++++--------
1 file changed, 6 insertions(+), 8 deletions(-)
diff --git a/openml/cli.py b/openml/cli.py
index 0faa34d45..b26e67d2e 100644
--- a/openml/cli.py
+++ b/openml/cli.py
@@ -43,18 +43,16 @@ def wait_until_valid_input(
valid input
"""
- response = input(prompt)
- if sanitize:
- response = sanitize(response)
- error_message = check(response)
- while error_message:
- print(error_message, end="\n\n")
+
+ while True:
response = input(prompt)
if sanitize:
response = sanitize(response)
error_message = check(response)
-
- return response
+ if error_message:
+ print(error_message, end="\n\n")
+ else:
+ return response
def print_configuration():