├── clenv ├── __init__.py └── cli │ ├── __init__.py │ ├── config │ ├── __init__.py │ ├── config_loader.py │ ├── config_subcommand.py │ └── config_manager.py │ ├── task │ ├── __init__.py │ └── task_subcommand.py │ ├── user │ ├── __init__.py │ └── user_subcommand.py │ ├── queue │ ├── __init__.py │ └── queue_manager.py │ └── __main__.py ├── requirements.txt ├── static └── clenv-task-exec-1.png ├── CHANGELOG.md ├── test.py ├── LICENSE ├── setup.py ├── cliff.toml ├── .gitignore └── README.md /clenv/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clenv/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clenv/cli/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clenv/cli/task/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clenv/cli/user/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clenv/cli/queue/__init__.py: -------------------------------------------------------------------------------- 1 | import click 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click==8.1.3 2 | pyhocon==0.3.35 3 | bcrypt==4.0.1 4 | GitPython==3.1.31 5 | inquirerpy==0.3.4 -------------------------------------------------------------------------------- /static/clenv-task-exec-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DavidSonoda/clenv/HEAD/static/clenv-task-exec-1.png -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | 6 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | from clenv.cli.queue.queue_manager import QueueManager 2 | from clearml.backend_api.session.client import APIClient 3 | 4 | if __name__ == "__main__": 5 | manager = QueueManager() 6 | 7 | print(manager.get_available_queues()) 8 | -------------------------------------------------------------------------------- /clenv/cli/__main__.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | # Inmport the user and config commands 4 | from .user import user_subcommand 5 | from .config import config_subcommand 6 | from .task import task_subcommand 7 | 8 | 9 | @click.group() 10 | def clenv(): 11 | pass 12 | 13 | 14 | clenv.add_command(config_subcommand.config) 15 | clenv.add_command(user_subcommand.user) 16 | clenv.add_command(task_subcommand.task) 17 | 18 | 19 | def main(): 20 | clenv() 21 | 22 | 23 | if __name__ == "__main__": 24 | main() 25 | -------------------------------------------------------------------------------- /clenv/cli/config/config_loader.py: -------------------------------------------------------------------------------- 1 | from pyhocon import ConfigFactory, ConfigTree 2 | import os 3 | 4 | 5 | class ConfigLoader: 6 | def __init__(self, config_file_path) -> None: 7 | """ 8 | Load the ~/clearml.conf file as Hocon config object 9 | """ 10 | if config_file_path is None: 11 | config_file_path = "~/clearml.conf" 12 | 13 | self.__config_file_path = os.path.expanduser(config_file_path) 14 | 15 | def load(self): 16 | self.__config = ConfigFactory.parse_file(self.__config_file_path) 17 | 18 | def get_config_value(self, key: str): 19 | """ 20 | Get the value of the key from the config object 21 | """ 22 | # The key could be a nested key, e.g. api.web_server 23 | # recursively get the value of the key 24 | 25 | # Get the value of the key from the config object 26 | config_value = self.__config.get(key) 27 | return config_value 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Juewei Dong 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | with open("README.md", "r") as f: 4 | long_description = f.read() 5 | 6 | setup( 7 | name="clenv", 8 | version="0.0.8", 9 | description="ClearML config profile manager", 10 | long_description=long_description, 11 | long_description_content_type="text/markdown", 12 | author="Juewei Dong", 13 | author_email="juewei.dong@brainco.tech", 14 | url="https://github.com/DavidSonoda/clenv", 15 | license="MIT", 16 | classifiers=[ 17 | "Development Status :: 3 - Alpha", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: MIT License", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3.6", 22 | "Programming Language :: Python :: 3.7", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | ], 26 | python_requires=">=3.7", 27 | packages=find_packages(), 28 | entry_points={ 29 | "console_scripts": [ 30 | "clenv = clenv.cli.__main__:main", 31 | ], 32 | }, 33 | install_requires=[ 34 | "clearml>=1.10.0", 35 | "click>=8.1.0", 36 | "pyhocon==0.3.35", 37 | "bcrypt>=4.0.0", 38 | "GitPython==3.1.31", 39 | "inquirerpy==0.3.4", 40 | ], 41 | ) 42 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # configuration file for git-cliff (0.1.0) 2 | 3 | [changelog] 4 | # changelog header 5 | header = """ 6 | # Changelog\n 7 | All notable changes to this project will be documented in this file.\n 8 | """ 9 | # template for the changelog body 10 | # https://tera.netlify.app/docs/#introduction 11 | body = """ 12 | {% if version %}\ 13 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 14 | {% else %}\ 15 | ## [unreleased] 16 | {% endif %}\ 17 | {% for group, commits in commits | group_by(attribute="group") %} 18 | ### {{ group | upper_first }} 19 | {% for commit in commits %} 20 | - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\ 21 | {% endfor %} 22 | {% endfor %}\n 23 | """ 24 | # remove the leading and trailing whitespace from the template 25 | trim = true 26 | # changelog footer 27 | footer = """ 28 | 29 | """ 30 | 31 | [git] 32 | # parse the commits based on https://www.conventionalcommits.org 33 | conventional_commits = true 34 | # filter out the commits that are not conventional 35 | filter_unconventional = true 36 | # regex for preprocessing the commit messages 37 | commit_preprocessors = [ 38 | { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/orhun/git-cliff/issues/${2}))"}, 39 | ] 40 | # regex for parsing and grouping commits 41 | commit_parsers = [ 42 | { message = "^feat", group = "Features"}, 43 | { message = "^fix", group = "Bug Fixes"}, 44 | { message = "^doc", group = "Documentation"}, 45 | { message = "^perf", group = "Performance"}, 46 | { message = "^refactor", group = "Refactor"}, 47 | { message = "^style", group = "Styling"}, 48 | { message = "^test", group = "Testing"}, 49 | { message = "^chore\\(release\\): prepare for", skip = true}, 50 | { message = "^chore", group = "Miscellaneous Tasks"}, 51 | { body = ".*security", group = "Security"}, 52 | ] 53 | # filter out the commits that are not matched by commit parsers 54 | filter_commits = false 55 | # glob pattern for matching git tags 56 | tag_pattern = "v[0-9]*" 57 | # regex for skipping tags 58 | skip_tags = "v0.1.0-beta.1" 59 | # regex for ignoring tags 60 | ignore_tags = "" 61 | # sort the tags chronologically 62 | date_order = false 63 | # sort the commits inside sections by oldest/newest order 64 | sort_commits = "oldest" 65 | -------------------------------------------------------------------------------- /clenv/cli/queue/queue_manager.py: -------------------------------------------------------------------------------- 1 | from clearml.backend_api.session.client import APIClient 2 | from clenv.cli.config.config_loader import ConfigLoader 3 | import requests 4 | 5 | 6 | # Write a subcommand about the queue management 7 | class QueueManager: 8 | GET_ALL_EX = "queues.get_all_ex" 9 | 10 | def __init__(self): 11 | self.__client = APIClient(api_version="2.23") 12 | 13 | # Initialize an http client using requests 14 | self.__http_client = requests.Session() 15 | self.__config_loader = ConfigLoader(config_file_path="~/clearml.conf") 16 | self.__config_loader.load() 17 | 18 | def __refresh_token(self): 19 | resp = self.__client.auth.login() 20 | self.__token = resp.token 21 | 22 | def __get_all_queues(self): 23 | self.__refresh_token() 24 | api_server_addr = self.__config_loader.get_config_value("api.api_server") 25 | # Get JSON response from the API using the http client, the API url is /queues.get_all_ex 26 | # Also, pass the token in the header 27 | resp = self.__http_client.get( 28 | f"{api_server_addr}/{self.GET_ALL_EX}", 29 | headers={"Authorization": "Bearer " + self.__token}, 30 | ) 31 | 32 | # Get the queues from the response 33 | return resp.json().get("data").get("queues") 34 | 35 | def __get_queue_simple_details_by_id(self, queue_id): 36 | queue = self.__client.queues.get_by_id(queue_id) 37 | # Convert queue to a dict object 38 | return queue 39 | 40 | def get_all_queue_names(self): 41 | # Iterate queues, get the queue names 42 | queues = self.__get_all_queues() 43 | queue_names = [queue.name for queue in queues] 44 | return queue_names 45 | 46 | def get_queue_simple_details(self, queue_name, queue_id=None): 47 | queues = self.__get_all_queues() 48 | for queue in queues: 49 | if queue.name == queue_name or queue.id == queue_id: 50 | return self.__get_queue_simple_details_by_id(queue.id) 51 | 52 | def get_available_queues(self): 53 | queues = self.__get_all_queues() 54 | # Filter out the queues that are not available, which means the queue.workers is an not an empty list 55 | return [queue for queue in queues if queue["workers"]] 56 | 57 | # def list_queues_as_table(self): 58 | # queue_detail_list = self.list_queues() 59 | -------------------------------------------------------------------------------- /clenv/cli/user/user_subcommand.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | # Import generate_password from user.user_manager 4 | import bcrypt 5 | import os, re 6 | import json 7 | import base64 8 | from pyhocon import ConfigFactory, HOCONConverter 9 | 10 | 11 | @click.group(help="Privately hosted clearml server user helper tools") 12 | def user(): 13 | pass 14 | 15 | 16 | # Write a command to generate a user config file ending with .conf in hocon format 17 | # The command should take a username and a password as arguments, 18 | # Encrypt the password using UserManager.generate_password() and write the username and encrypted password to the config file 19 | # The config file should be named after the username and saved to home directory 20 | @user.command(help="Generate a user password") 21 | @click.argument("username", required=True) 22 | @click.argument("password", required=False) 23 | def genpass(username, password): 24 | try: 25 | if password is None: 26 | password = click.prompt("Create a password for the user", hide_input=True) 27 | cipher_pw = generate_password(password) 28 | 29 | config_dict = { 30 | "username": username, 31 | "password": base64.b64encode(cipher_pw).decode("utf-8"), 32 | } 33 | config = ConfigFactory.from_dict(config_dict) 34 | # Save the clearml-user.conf file in the home directory 35 | path = os.path.expanduser(f"~/clearml-server-{username}.conf") 36 | with open(path, "w") as f: 37 | f.write(HOCONConverter.to_hocon(config)) 38 | 39 | click.echo(json.dumps(config_dict, indent=4)) 40 | # Print the file path in green color 41 | click.echo( 42 | click.style( 43 | f"User name and cipher password config saved to {path}, please send the config file to server admin", 44 | fg="green", 45 | ) 46 | ) 47 | 48 | except ValueError as e: 49 | click.echo(click.style(str(e), fg="red")) 50 | return 51 | 52 | 53 | # Generate a cipher text password from a plain text password using bcrypt, reject the 54 | # password if it is not strong enough 55 | def generate_password(plain_text_password): 56 | # Assert the plain text password is at least 8 characters long, must contain a 57 | # number, and must contain a upper case letter and a lower case letter 58 | valid = check_string(plain_text_password) 59 | if not valid: 60 | raise ValueError( 61 | "Password must be at least 8 characters long, must contain a number, and must contain a upper case letter and a lower case letter" 62 | ) 63 | return bcrypt.hashpw(plain_text_password.encode("utf-8"), bcrypt.gensalt()) 64 | 65 | 66 | def check_string(string): 67 | # Check if the string contains at least one upper case letter, one lower case letter, one number, and is at least 8 characters long 68 | regex = re.compile("^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$") 69 | match = regex.match(string) 70 | return match is not None 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | # Nuitka compilation 163 | *.build/ 164 | *.dist/ 165 | *.onefile-build/ 166 | *.bin 167 | 168 | # VSCode 169 | .vscode/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # clenv - Unofficial ClearML CLI helper 2 | 3 | 4 | 5 | ## Pre-requisites 6 | 7 | - `clearml` installed, please refer to [ClearML installation guide](https://clear.ml/docs/latest/docs/getting_started/ds/ds_first_steps) for more details. 8 | - Run `clearml-init` and initialize your first ever config file. 9 | 10 | 11 | 12 | ## Installation 13 | 14 | ```bash 15 | pip install clenv 16 | ``` 17 | 18 | 19 | ## Usage 20 | 21 | ### Subcommand `config` 22 | Note: All config files must be in the format of `clearml-.conf` 23 | 24 | #### List all config profiles 25 | ```bash 26 | clenv config list 27 | ``` 28 | 29 | #### Create a new config profile 30 | ```bash 31 | clenv config create 32 | ``` 33 | 34 | #### Delete a config profile 35 | ```bash 36 | clenv config del 37 | ``` 38 | 39 | #### Switch to a config profile 40 | ```bash 41 | clenv config checkout 42 | ``` 43 | 44 | #### Reinitialize the `api` section of a config 45 | ```bash 46 | clenv config reinit 47 | # Please paste your multi-line configuration and press Enter: 48 | ``` 49 | Then paste your multi-line configuration generated through clearML server. 50 | 51 | ### Subcommand `user` 52 | 53 | #### Generate user/password hocon config 54 | ```bash 55 | clenv user genpass 56 | ``` 57 | 58 | ### Subcommand `task` 59 | 60 | > Note: This command only support git repos for now. The project name of the task created on the ClearML server will be the same as the git repo name. So please make sure you have a meaningful, easy to read git repo name. 61 | 62 | #### Execute a task remotely on ClearML server 63 | 64 | ```bash 65 | clenv task exec 66 | ``` 67 | 68 | It will prompt you to select an available queue, correct task type, your entrypoint script path and input the task name. 69 | 70 | ![clenv-task-exec-1](./static/clenv-task-exec-1.png) 71 | 72 | After inputting all the required configs, it will ask you whether to save the configs. By typing 'y', the config will be saved. When you execute `clenv task exec` next time in the same repo, it will load the saved configs and skip the config input process. However, it will still ask you for confirmation before submitting the task. 73 | 74 | #### Ignore the saved run configs when starting a new execution 75 | 76 | If you want to ignore the old run configs and freshly start a new execution, you can run: 77 | 78 | ```bash 79 | clenv task exec -N 80 | ``` 81 | 82 | The `-N` option will tell `clenv` to ignore the config file you have, and prompt you to input all the configs again. 83 | 84 | An alternative way of doing that is to delete the config file manually, which is located at `./.clenv/task_template.json`. Then running `clenv task exec` again will start a fresh execution as well. 85 | 86 | ## Examples 87 | 88 | ### Create a new clearml config profile for privately hosted clearml server 89 | 90 | #### Initialize profiles 91 | 92 | ```bash 93 | $ clenv config list 94 | # Input a name for your current profile 95 | ``` 96 | 97 | #### Create a new profile 98 | 99 | ```bash 100 | $ clenv config create brainco 101 | ``` 102 | 103 | #### Reinit the profile credentials 104 | 105 | ```bash 106 | $ clenv config reinit brainco 107 | ``` 108 | 109 | #### Checkout the new profile 110 | 111 | ```bash 112 | $ clenv config checkout brainco 113 | ``` 114 | 115 | ## Roadmap 116 | - Config management 117 | - [x] Config profile management 118 | - [ ] Support custom config file path 119 | - Privately hosted server management 120 | - [x] BCrypt password generation (Feature to be deprecated when more sophisticated user management is implemented) 121 | - [ ] Server side utils and config management 122 | - [ ] ClearML Agent side utils and config management 123 | - Remote task management 124 | - [x] A user friendly remote task execution wizard 125 | 126 | 127 | 128 | 129 | ## Disclaimer & License 130 | This project is not affiliated with Allegro AI, Inc. in any way. It is an independent and unofficial software. It's licensed under the MIT license. -------------------------------------------------------------------------------- /clenv/cli/config/config_subcommand.py: -------------------------------------------------------------------------------- 1 | from .config_manager import ConfigManager 2 | import click 3 | 4 | 5 | INDEX_FILE_PATH = "~/.clenv-config-index.json" 6 | EMPTY_INDEX_JSON = {"profiles": {}} 7 | 8 | 9 | # Create a group 10 | @click.group(help="Manage config files") 11 | def config(): 12 | pass 13 | 14 | 15 | @click.option( 16 | "--showpath", "-v", is_flag=True, help="Show config file path for the profile" 17 | ) 18 | @config.command(help="List all config profiles") 19 | def list(showpath): 20 | config_manager = ConfigManager(INDEX_FILE_PATH) 21 | 22 | # If the profile has not been initialized, prompt the user to input a profile name 23 | # The default profile name is 'default' 24 | if not config_manager.profile_has_initialized(): 25 | profile_name = click.prompt("Please input a profile name", default="default") 26 | config_manager.initialize_profile(profile_name) 27 | 28 | active_profiles = config_manager.get_active_profile() 29 | non_active_profiles = config_manager.get_non_active_profiles() 30 | 31 | # Print the active profiles 32 | for profile in active_profiles: 33 | click.echo(click.style(f'{profile["profile_name"]} [active]', fg="green")) 34 | if showpath: 35 | click.echo(click.style(f' {profile["file_path"]}')) 36 | 37 | # Print the non-active profiles 38 | for profile in non_active_profiles: 39 | click.echo(click.style(f'{profile["profile_name"]}', fg="yellow")) 40 | if showpath: 41 | click.echo(click.style(f' {profile["file_path"]}')) 42 | 43 | 44 | # Checkout the profile name specified by the user, and set it as the default profile. 45 | # If the profile name is not found in the index file, print an error message and exit 46 | # Solution 47 | @config.command(name="checkout", help="Checkout another profile") 48 | @click.argument("profile_name") 49 | def checkout(profile_name): 50 | config_manager = ConfigManager(INDEX_FILE_PATH) 51 | if not config_manager.has_profile(profile_name=profile_name): 52 | click.echo(f"Profile {profile_name} does not exist") 53 | return 54 | active_profile = config_manager.get_active_profile() 55 | if active_profile[0]["profile_name"] == profile_name: 56 | click.echo(f"Profile {profile_name} is already active") 57 | return 58 | else: 59 | config_manager.set_active_profile(profile_name) 60 | click.echo(f'Profile "{profile_name}" is now active') 61 | 62 | 63 | # Create a new profile, the profile name is specified by the user 64 | # If the profile name is already in the index file, print an error message and exit 65 | @click.argument("profile_name", required=True) 66 | @click.option("--base", "-b", help="Base profile name") 67 | @config.command(help="Create a new profile") 68 | def create(profile_name, base): 69 | config_manager = ConfigManager(INDEX_FILE_PATH) 70 | if config_manager.has_profile(profile_name=profile_name): 71 | click.echo(f"Profile {profile_name} already exists") 72 | return 73 | config_manager.create_profile(profile_name, base) 74 | click.echo(f"Profile {profile_name} created") 75 | 76 | 77 | # Delete the profile specified by the user, if the profile is the default profile, print an error 78 | # message and exit. If the profile is not found in the index file, print an error message and exit. 79 | # Solution 80 | @click.argument("profile_name", required=True) 81 | @config.command(name="del", help="Delete a profile") 82 | def delete(profile_name): 83 | config_manager = ConfigManager(INDEX_FILE_PATH) 84 | # Check if the profile exists 85 | if not config_manager.has_profile(profile_name=profile_name): 86 | click.echo(f"Profile {profile_name} does not exist") 87 | return 88 | # Check if the profile is active 89 | if config_manager.is_active_profile(profile_name): 90 | click.echo( 91 | f"Profile {profile_name} is active, only non-active profiles can be deleted" 92 | ) 93 | return 94 | # Delete the profile 95 | config_manager.delete_profile(profile_name) 96 | click.echo(f"Profile {profile_name} deleted") 97 | 98 | 99 | # Rename profile 100 | @config.command(help="Rename a profile") 101 | @click.argument("old_profile_name", required=True) 102 | @click.argument("new_profile_name", required=True) 103 | def rename(old_profile_name, new_profile_name): 104 | # Define a ConfigManager object for the index file 105 | config_manager = ConfigManager(INDEX_FILE_PATH) 106 | # If the old profile doesn't exist, print an error message 107 | if not config_manager.has_profile(profile_name=old_profile_name): 108 | click.echo(f"Profile {old_profile_name} does not exist") 109 | return 110 | # If the new profile already exists, print an error message 111 | if config_manager.has_profile(profile_name=new_profile_name): 112 | click.echo(f"Profile {new_profile_name} already exists") 113 | return 114 | # Rename the profile 115 | config_manager.rename_profile(old_profile_name, new_profile_name) 116 | # Print a message confirming the rename 117 | click.echo(f"Profile {old_profile_name} renamed to {new_profile_name}") 118 | 119 | 120 | # Reinitialize the api section of the config file. The argument is the hocon formatted 121 | # string that will be used to replace the api section of the config file. 122 | @click.argument("base_profile", required=True) 123 | @config.command(help="Reinitialize the api section of the config file") 124 | def reinit(base_profile): 125 | # Get the configuration from the user. 126 | click.echo("Please paste your multi-line configuration and press Enter:") 127 | config = read_multiline() 128 | 129 | # Reinitialize the api section of the config file. 130 | config_manager = ConfigManager(INDEX_FILE_PATH) 131 | config_manager.reinitialize_api_config(base_profile, config) 132 | 133 | 134 | # Reads multiple lines of input from the user and returns them as a string. 135 | def read_multiline(): 136 | lines = [] 137 | while True: 138 | try: 139 | line = input() 140 | if not line: 141 | break 142 | lines.append(line) 143 | except EOFError: 144 | break 145 | 146 | return "\n".join(lines) 147 | -------------------------------------------------------------------------------- /clenv/cli/task/task_subcommand.py: -------------------------------------------------------------------------------- 1 | from clearml import Task 2 | from clearml.backend_interface.task.populate import CreateAndPopulate 3 | from clenv.cli.queue.queue_manager import QueueManager 4 | from git import Repo 5 | from InquirerPy import prompt 6 | from InquirerPy.validator import PathValidator, EmptyInputValidator 7 | from collections import OrderedDict 8 | 9 | import click 10 | import os, json 11 | 12 | # Write a subcommand about the task management 13 | 14 | 15 | @click.group(help="Task management") 16 | def task(): 17 | pass 18 | 19 | 20 | @task.command( 21 | help="Execute a task remotely on configured queues. \n\nWhen executing freshly, " 22 | + "you will be prompted to select a queue to execute the task on. The status info " 23 | + "of the queues is retrieved from the ClearML server. Corresponding worker infomation, " 24 | + "including idle workers (The workers that will be able to execute your task immediately) " 25 | + "and total workers number is displayed. \n\n" 26 | + "By default, this command will read the run config from the ./.clenv/task_template.json " 27 | + "file, generated by your last execution with config saved. " 28 | ) 29 | @click.option( 30 | "--new/--no-new", 31 | "-N", 32 | help="Freshly execute the task ignoring existing execution configs", 33 | default=False, 34 | show_default=True, 35 | is_flag=True, 36 | ) 37 | def exec(new): 38 | # Give user an interactive prompt to select queue to execute the task from the available queues 39 | # Solution 40 | 41 | queue_manager = QueueManager() 42 | 43 | available_queues = queue_manager.get_available_queues() 44 | 45 | if len(available_queues) == 0: 46 | from clenv.cli.config.config_loader import ConfigLoader 47 | 48 | web_server_addr = ConfigLoader().get_config_value("api.web_server") 49 | click.echo(f"No available queues, please go to {web_server_addr}", err=True) 50 | return 51 | 52 | # Check if there is existing template 53 | # Solution 54 | selected_queue_name = None 55 | run_config = None 56 | if os.path.exists("./.clenv/task_template.json") and not new: 57 | # If there is an existing template, load the template and use it to create a task 58 | # Solution 59 | 60 | with open("./.clenv/task_template.json", "r") as f: 61 | run_config = json.load(f) 62 | # selected_queue_name = run_config["selected_queue"].split("\n")[0] 63 | # Check if selected_queue is in available_queues by filtering available_queues with name equals to selected_queue 64 | # Don't use queue manager, filter directly on available_queues 65 | selected_queue = [ 66 | queue 67 | for queue in available_queues 68 | if queue["name"] == run_config["selected_queue"] 69 | ][0] 70 | 71 | if selected_queue: 72 | # Check if the selected queue has idle workers 73 | if not selected_queue["workers"]: 74 | click.echo("Selected queue has no idle workers", err=True) 75 | return 76 | else: 77 | click.echo("Selected queue is not available", err=True) 78 | return 79 | 80 | show_config(run_config) 81 | # Ask user to confirm executing 82 | result = prompt( 83 | [ 84 | { 85 | "type": "confirm", 86 | "message": "Confirm executing the task using the above configuration?", 87 | "default": True, 88 | "name": "confirm", 89 | } 90 | ] 91 | ) 92 | if not result["confirm"]: 93 | click.echo("Task execution cancelled") 94 | return 95 | else: 96 | queue_names = [ 97 | f"{queue['name']}\n - idle workers: {[worker['name'] for worker in queue['workers'] if worker['task'] is None]}\n - total workers: {len(queue['workers'])}" 98 | for queue in available_queues 99 | ] 100 | 101 | questions = [ 102 | { 103 | "type": "list", 104 | "message": "Please choose a queue to execute the task", 105 | "choices": queue_names, 106 | "name": "selected_queue", 107 | }, 108 | { 109 | "type": "list", 110 | "message": "Please choose a task type", 111 | "choices": [ 112 | "training", 113 | "testing", 114 | "inference", 115 | "data_processing", 116 | "application", 117 | "monitor", 118 | "controller", 119 | "optimizer", 120 | "service", 121 | "qc", 122 | "other", 123 | ], 124 | "name": "selected_task_type", 125 | }, 126 | { 127 | "type": "input", 128 | "message": "Please enter a task name", 129 | "name": "task_name", 130 | "validate": EmptyInputValidator(message="Input cannot be empty"), 131 | }, 132 | { 133 | "type": "filepath", 134 | "message": "Please enter a script path", 135 | "default": ".", 136 | "name": "script_path", 137 | "validate": PathValidator(is_file=True, message="Input is not a file"), 138 | }, 139 | { 140 | "type": "confirm", 141 | "message": "Do you want to save this as a template? If saved, future task creation will be automatically based on this template", 142 | "name": "save_as_template", 143 | }, 144 | ] 145 | 146 | asnwers = prompt(questions) 147 | run_config = make_config(asnwers) 148 | 149 | if asnwers["save_as_template"]: 150 | # Save the template to the current directory 151 | # Firstly create a directory named .clenv in the current directory 152 | # Then create a file named task_template.json in the .clenv directory 153 | # Then save the answers to the task_template.json file 154 | # Solution 155 | 156 | os.makedirs(".clenv", exist_ok=True) 157 | with open(".clenv/task_template.json", "w") as f: 158 | json.dump(run_config, f, indent=4) 159 | 160 | execute_task(run_config) 161 | 162 | 163 | # Display the task template using the task template json, the input is a json object 164 | def show_config(task_template): 165 | # Iterate through all the key value pairs in the task_template 166 | for key, value in task_template.items(): 167 | desc = key.replace("_", " ").capitalize() 168 | click.echo(f"{desc}: {value}") 169 | 170 | 171 | def make_config(raw_template): 172 | # Iterate through all the key value pairs in the task_template 173 | # Declare a ordered dictionary to store the pretty template 174 | run_config = OrderedDict() 175 | for key, value in raw_template.items(): 176 | if key != "save_as_template": 177 | val = str(value).split("\n")[0] 178 | run_config[key] = val 179 | return run_config 180 | 181 | 182 | def execute_task(run_config): 183 | repo = Repo(".") 184 | # Check if the repo is in detached head state, if so, exit with error 185 | if repo.head.reference is None: 186 | click.echo( 187 | message="The repo is in detached head state, please checkout a branch", 188 | err=True, 189 | ) 190 | 191 | # Read the git information from current directory 192 | current_branch = repo.head.reference.name 193 | remote_url = repo.remotes.origin.url 194 | project_name = remote_url.split("/")[-1].split(".")[0] 195 | 196 | # Create a task object 197 | create_populate = CreateAndPopulate( 198 | project_name=project_name, 199 | task_name=run_config["task_name"], 200 | task_type=run_config["selected_task_type"], 201 | repo=remote_url, 202 | branch=current_branch, 203 | # commit=args.commit, 204 | script=run_config["script_path"], 205 | # working_directory=args.cwd, 206 | # packages=args.packages, 207 | # requirements_file=args.requirements, 208 | # docker=args.docker, 209 | # docker_args=args.docker_args, 210 | # docker_bash_setup_script=bash_setup_script, 211 | # output_uri=args.output_uri, 212 | # base_task_id=args.base_task_id, 213 | # add_task_init_call=not args.skip_task_init, 214 | # raise_on_missing_entries=True, 215 | verbose=True, 216 | ) 217 | create_populate.create_task() 218 | 219 | create_populate.task._set_runtime_properties({"_CLEARML_TASK": True}) 220 | 221 | click.echo("New task created id={}".format(create_populate.get_id())) 222 | 223 | Task.enqueue(create_populate.task, queue_name=run_config["selected_queue"]) 224 | 225 | click.echo( 226 | "Task id={} sent for execution on queue {}".format( 227 | create_populate.get_id(), run_config["selected_queue"] 228 | ) 229 | ) 230 | click.echo( 231 | "Execution log at: {}".format(create_populate.task.get_output_log_web_page()) 232 | ) 233 | -------------------------------------------------------------------------------- /clenv/cli/config/config_manager.py: -------------------------------------------------------------------------------- 1 | # Write a class that will load the index file and manage the profiles 2 | # The class will have several methods to add, remove, and checkout profiles 3 | # The class will also have a method to save the index file 4 | # Basically this ConfigManager class will be having all the functionalities in the config_manager.py file 5 | # But it's in a OOD way. 6 | import json 7 | import os 8 | import re 9 | import shutil 10 | from pyhocon import ConfigFactory, HOCONConverter 11 | 12 | 13 | class ConfigManager: 14 | def __init__(self, index_file_path, save_index=True): 15 | """ 16 | Initialize a new index for the given index file path. 17 | :param index_file_path: The file path of the index file. 18 | :param save_index: Whether to save the index after refreshing it. Defaults to True. 19 | """ 20 | self.__index_file_path = index_file_path 21 | self.__EMPTY_INDEX_JSON = {"profiles": {"active": [], "non_active": []}} 22 | index_json = self.__load_index_file(index_file_path) 23 | self.__new_index_json = self.__refresh_index(index_json) 24 | if save_index: 25 | self.save_index() 26 | 27 | # Return a list of object, each object has 2 keys: 28 | # - profile_name 29 | # - file_path 30 | # The list should contain all the profiles in the index file, including the active profile 31 | # and the non-active profiles 32 | # Solution 33 | def get_all_profiles(self): 34 | # Get all active profiles 35 | active_profiles = self.__new_index_json["profiles"]["active"] 36 | # Get all non active profiles 37 | non_active_profiles = self.__new_index_json["profiles"]["non_active"] 38 | # Concatenate both lists and return 39 | return active_profiles + non_active_profiles 40 | 41 | # Get active profile. Return a list of object, each object has 2 keys: 42 | # - profile_name 43 | # - file_path 44 | def get_active_profile(self): 45 | return self.__new_index_json["profiles"]["active"] 46 | 47 | def get_non_active_profiles(self): 48 | return self.__new_index_json["profiles"]["non_active"] 49 | 50 | def get_profile(self, profile_name): 51 | for profile in ( 52 | self.__new_index_json["profiles"]["active"] 53 | + self.__new_index_json["profiles"]["non_active"] 54 | ): 55 | if profile["profile_name"] == profile_name: 56 | return profile 57 | raise Exception(f"Profile {profile_name} does not exist") 58 | 59 | # Return a boolean value, True if there's at least one profile's name is 'default' 60 | def profile_has_initialized(self): 61 | for profile in ( 62 | self.__new_index_json["profiles"]["active"] 63 | + self.__new_index_json["profiles"]["non_active"] 64 | ): 65 | if profile["profile_name"] == "untitled": 66 | return False 67 | return True 68 | 69 | # Rename a profile, the old_profile_name could be in both non_active and active list 70 | def rename_profile(self, old_profile_name, new_profile_name): 71 | # Iterate over all profiles 72 | for profile in ( 73 | self.__new_index_json["profiles"]["active"] 74 | + self.__new_index_json["profiles"]["non_active"] 75 | ): 76 | # If the profile is found, rename it 77 | if profile["profile_name"] == old_profile_name: 78 | profile["profile_name"] = new_profile_name 79 | # If the profile is active, do not rename the config file path 80 | # If the profile is non-active, rename the config file path 81 | if profile in self.__new_index_json["profiles"]["non_active"]: 82 | # Rename the config file path 83 | new_file_path = os.path.expanduser( 84 | profile["file_path"].replace( 85 | f"clearml-{old_profile_name}.conf", 86 | f"clearml-{new_profile_name}.conf", 87 | ) 88 | ) 89 | os.rename(os.path.expanduser(profile["file_path"]), new_file_path) 90 | profile["file_path"] = new_file_path 91 | # Save the new index 92 | self.save_index() 93 | return 94 | # If the profile is not found, raise an error 95 | raise Exception(f"Profile {old_profile_name} does not exist") 96 | 97 | def initialize_profile(self, profile_name): 98 | self.rename_profile("untitled", profile_name) 99 | 100 | def is_active_profile(self, profile_name): 101 | return ( 102 | profile_name 103 | == self.__new_index_json["profiles"]["active"][0]["profile_name"] 104 | ) 105 | 106 | # Switch the active profile to the profile with the given profile_name, the profile_name must 107 | # be in the non_active list, if not, throw an exception 108 | # Solution 109 | def set_active_profile(self, profile_name): 110 | # Check if the profile_name is in the non_active list 111 | non_active_profile_list = self.__new_index_json["profiles"]["non_active"] 112 | active_profile_list = self.__new_index_json["profiles"]["active"] 113 | if profile_name == active_profile_list[0]["profile_name"]: 114 | raise Exception(f"Profile {profile_name} is already the active profile") 115 | for profile in non_active_profile_list: 116 | try: 117 | if profile["profile_name"] == profile_name: 118 | # Firstly, add the active profile to the non_active list with updated file_path 119 | # and also rename the config file name. For example, if the active profile is 'default', 120 | # then rename the config file name from 'clearml.conf' to 'clearml-default.conf' 121 | # Remove the active profile from the active list 122 | active_profile = self.__new_index_json["profiles"]["active"][0] 123 | old_active_fp = active_profile["file_path"] 124 | active_profile["file_path"] = active_profile["file_path"].replace( 125 | "clearml.conf", f'clearml-{active_profile["profile_name"]}.conf' 126 | ) 127 | self.__new_index_json["profiles"]["non_active"].append( 128 | active_profile 129 | ) 130 | self.__new_index_json["profiles"]["active"].remove(active_profile) 131 | os.rename( 132 | os.path.expanduser(old_active_fp), 133 | os.path.expanduser(active_profile["file_path"]), 134 | ) 135 | 136 | # Secondly, add the matching non-active profile to the active list with updated file_path 137 | # and also rename the config file name. For example, if the non-active profile is 'dev', 138 | # then rename the config file name from 'clearml-dev.conf' to 'clearml.conf' 139 | # Remove the matching non-active profile from the non-active list 140 | self.__new_index_json["profiles"]["non_active"].remove(profile) 141 | old_inactive_fp = profile["file_path"] 142 | profile["file_path"] = profile["file_path"].replace( 143 | f"clearml-{profile_name}.conf", "clearml.conf" 144 | ) 145 | os.rename( 146 | os.path.expanduser(old_inactive_fp), 147 | os.path.expanduser(profile["file_path"]), 148 | ) 149 | self.__new_index_json["profiles"]["active"].append(profile) 150 | self.save_index() 151 | return 152 | except: 153 | raise 154 | raise Exception(f"Profile {profile_name} does not exist") 155 | 156 | # Create a new profile based on the given profile_name, the profile_name must not be in the 157 | # active list or the non_active list, if it is, throw an exception 158 | # Solution 159 | def create_profile(self, profile_name, base_profile_name=None): 160 | if self.has_profile(profile_name): 161 | raise Exception(f"Profile {profile_name} already exists") 162 | new_profile = { 163 | "profile_name": profile_name, 164 | "file_path": os.path.expanduser(f"~/clearml-{profile_name}.conf"), 165 | } 166 | self.__new_index_json["profiles"]["non_active"].append(new_profile) 167 | # Copy the config file specified by base_profile_name and rename it to the file_path 168 | 169 | if base_profile_name is None: 170 | base_profile_name = self.get_active_profile()[0]["profile_name"] 171 | base_profile = self.get_profile(base_profile_name) 172 | shutil.copyfile( 173 | os.path.expanduser(base_profile["file_path"]), 174 | os.path.expanduser(new_profile["file_path"]), 175 | ) 176 | 177 | self.save_index() 178 | 179 | # Delete the profile with the given profile_name, the profile_name must be in the 180 | # active list or the non_active list, if it is not, throw an exception 181 | # Solution 182 | def delete_profile(self, profile_name): 183 | if not self.has_profile(profile_name): 184 | raise Exception(f"Profile {profile_name} does not exist") 185 | for profile in ( 186 | self.__new_index_json["profiles"]["active"] 187 | + self.__new_index_json["profiles"]["non_active"] 188 | ): 189 | if profile["profile_name"] == profile_name: 190 | self.__new_index_json["profiles"]["non_active"].remove(profile) 191 | os.remove(os.path.expanduser(profile["file_path"])) 192 | self.save_index() 193 | return 194 | 195 | def has_profile(self, profile_name): 196 | for profile in ( 197 | self.__new_index_json["profiles"]["active"] 198 | + self.__new_index_json["profiles"]["non_active"] 199 | ): 200 | if profile["profile_name"] == profile_name: 201 | return True 202 | return False 203 | 204 | def save_index(self): 205 | with open(os.path.expanduser(self.__index_file_path), "w") as f: 206 | f.write(json.dumps(self.__new_index_json, indent=4)) 207 | 208 | # Reinitialize the api part of the config file of a specific profile, make sure the api_config 209 | # is a a valid hocon format string using pyhocon. Then replace the 'api' section of the config file 210 | # with the api_config 211 | def reinitialize_api_config(self, profile_name, api_config_str): 212 | if not self.has_profile(profile_name): 213 | raise Exception(f"Profile {profile_name} does not exist") 214 | try: 215 | profile = self.get_profile(profile_name) 216 | clearml_config = ConfigFactory.parse_file( 217 | os.path.expanduser(profile["file_path"]) 218 | ) 219 | new_api_config = ConfigFactory.parse_string(api_config_str) 220 | clearml_config["api"] = new_api_config["api"] 221 | with open(os.path.expanduser(profile["file_path"]), "w") as f: 222 | f.write(HOCONConverter.convert(clearml_config)) 223 | except: 224 | raise Exception("Invalid api_config_str") 225 | 226 | def __refresh_index(self, index_json): 227 | # Scan the home directory 228 | new_index_json = self.__scan_home_dir() 229 | 230 | # If the default profile in index_json is not empty, update the new_index_json with the default profile 231 | # in index_json 232 | if len(index_json["profiles"]["active"]) > 0: 233 | new_index_json["profiles"]["active"] = index_json["profiles"]["active"] 234 | 235 | return new_index_json 236 | 237 | # Load the json content of the index file, return the json object. If the file 238 | # is empty or malformed, return an empty json object. 239 | def __load_index_file(self, file_path): 240 | index_file_path = os.path.expanduser(file_path) 241 | if not os.path.exists(index_file_path) or os.stat(index_file_path).st_size == 0: 242 | # return an empty json object 243 | return self.__EMPTY_INDEX_JSON 244 | else: 245 | try: 246 | with open(index_file_path, "r") as f: 247 | return json.load(f) 248 | except: 249 | return self.__EMPTY_INDEX_JSON 250 | 251 | def __scan_home_dir(self): 252 | # get the home directory 253 | home_dir = os.path.expanduser("~") 254 | # get the list of files in the home directory 255 | files = os.listdir(home_dir) 256 | # create lists to store the active and non-active profiles 257 | active_profile_list = [] 258 | non_active_profile_list = [] 259 | # for each file in the home directory 260 | for file in files: 261 | # if the file is a config file 262 | if ( 263 | file.endswith(".conf") 264 | and not os.path.isdir(file) 265 | and not os.path.islink(file) 266 | and "clearml" in file 267 | ): 268 | try: 269 | # parse the file 270 | ConfigFactory.parse_file(f"{home_dir}/{file}") 271 | # if the file is the active profile 272 | if file == "clearml.conf": 273 | # add the file to the list of active profiles 274 | active_profile_list.append( 275 | { 276 | "profile_name": self.__extract_profile_name(file), 277 | "file_path": f"{home_dir}/{file}", 278 | } 279 | ) 280 | # if the file is not the active profile 281 | else: 282 | # add the file to the list of non-active profiles 283 | non_active_profile_list.append( 284 | { 285 | "profile_name": self.__extract_profile_name(file), 286 | "file_path": f"{home_dir}/{file}", 287 | } 288 | ) 289 | except: 290 | # throw an error 291 | raise Exception( 292 | f"Not a valid config file: {file}, check file content" 293 | ) 294 | 295 | return { 296 | "profiles": { 297 | "active": active_profile_list, 298 | "non_active": non_active_profile_list, 299 | } 300 | } 301 | 302 | # Extract profile name from the file name, if the file name is clearml.conf, the profile name should be default 303 | # If the file name is clearml-.conf, the profile name should be 304 | # Use regex to extract the profile name 305 | def __extract_profile_name(self, file_name): 306 | if file_name == "clearml.conf": 307 | return "untitled" 308 | else: 309 | return re.match(r"clearml-(.+)\.conf", file_name).group(1) 310 | --------------------------------------------------------------------------------