├── .devcli ├── README.rst ├── devcli.toml └── example.py ├── .gitignore ├── CONTRIBUTING.rst ├── Justfile ├── LICENSE ├── README.rst ├── TODO.taskpaper ├── bin └── devcli ├── devcli ├── commands │ ├── README │ ├── config.py │ ├── op.py │ └── url.py ├── conf │ └── defaults.toml ├── config │ ├── __init__.py │ └── config.py ├── core │ ├── __init__.py │ └── cli.py ├── framework │ ├── __init__.py │ ├── base.py │ ├── console.py │ └── errors.py └── utils │ ├── __init__.py │ ├── one_password.py │ └── shell.py ├── docs ├── .bundle │ └── config ├── .ruby-version ├── 404.html ├── CNAME ├── Gemfile ├── Gemfile.lock ├── Rakefile ├── _config.yml ├── assets │ ├── css │ │ └── style.scss │ └── img │ │ └── logo.png ├── design │ └── logo.graffle └── index.md ├── poetry.lock ├── pyproject.toml └── tests ├── commands ├── test_config.py ├── test_edit.py ├── test_op.py └── test_url.py ├── conftest.py ├── fixtures ├── general.toml └── specific.toml ├── test_base.py ├── test_cli.py ├── test_conf.py ├── test_console.py ├── test_core.py ├── test_error.py ├── test_example.py └── utils ├── test_one_password.py └── test_shell.py /.devcli/README.rst: -------------------------------------------------------------------------------- 1 | Template command 2 | ---------------- 3 | 4 | The template command is a simple example of how to create your own 5 | commands. It should be functional with no side effects for you to test 6 | while you run the *devcli* inside its own project dir. -------------------------------------------------------------------------------- /.devcli/devcli.toml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvaltas/devcli/a5d501e4f993bee98c8d7a5de9b2da70a1482596/.devcli/devcli.toml -------------------------------------------------------------------------------- /.devcli/example.py: -------------------------------------------------------------------------------- 1 | from typer import Context 2 | import devcli.framework as cmd 3 | 4 | cli = cmd.new("Examples on how create cli commands with *devcli*") 5 | 6 | 7 | @cli.command() 8 | def hello(name: str, rainbow: bool = False): 9 | """ 10 | Simplest example of a command, it just outputs "Hello, NAME!" 11 | 12 | If --rainbow is passed it will make it colorful 13 | """ 14 | if rainbow: 15 | colors = ["red", "orange1", "yellow1", "green", "blue", "purple"] 16 | name = "".join( 17 | f"[{colors[i % len(colors)]}]{char}[/{colors[i % len(colors)]}]" 18 | for i, char in enumerate(name) 19 | ) 20 | cmd.echo(f"Hello, {name}!") 21 | 22 | 23 | @cli.command() 24 | def ping(): 25 | """ 26 | Replies with a PONG! 27 | """ 28 | cmd.echo("PONG!") 29 | 30 | 31 | @cli.command() 32 | def text(): 33 | """ 34 | Demo types of text output you can use 35 | out of the box as shortcuts. 36 | """ 37 | cmd.echo("This is a cmd.echo(msg)") 38 | cmd.info("This is a cmd.info(msg)") 39 | cmd.warn("This is a cmd.warn(msg)") 40 | cmd.error("This is a cmd.error(msg)") 41 | 42 | 43 | @cli.command() 44 | def config(ctx: Context): 45 | """ 46 | Example of access to global configurations 47 | """ 48 | cmd.echo(f"Default devcli config: {ctx.obj['devcli']}") 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE files 2 | .idea 3 | 4 | # macOS 5 | .DS_Store 6 | 7 | # python files 8 | __pycache__/ 9 | dist/ 10 | 11 | # github pages jekyll files 12 | docs/vendor 13 | docs/_site 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ========================= 2 | Contributing to *devcli* 3 | ========================= 4 | 5 | Thank you very much for your interest in contributing with *devcli*. I'll try 6 | to outline some guidance if you want to extend and improve this tool. 7 | 8 | Package organization 9 | -------------------- 10 | 11 | * ``devcli.core`` - dedicated only to the basic cli setup and execution 12 | * ``devcli.config`` - dedicated to the config subsystem, parsing and access 13 | * ``devcli.framework`` - dedicated to things that enable the creation of subcommands 14 | * ``devcli.utils`` - utility functions that can be used internally or imported to subcommands 15 | * ``devcli.commands`` - out of the box commands, these should only depend on ``devcli.utils`` and ``devcli.framework`` 16 | 17 | 18 | Principles behind devcli 19 | -------------------------- 20 | 21 | - **Simplicity at core**: As simple as possible at its core. 22 | Its power comes for the ability to extend it. As more complex the core gets, 23 | the harder it will be to extend it. 24 | 25 | - **Extensibility**: Easy to extend. Given the simplicity of 26 | its core, it should be easy to add new commands. 27 | 28 | - **Discoverability**: Commands should be easy to discover and understand. The paradigm 29 | of commands, subcommands, and options should be leveraged to explain the target system 30 | of the command. 31 | 32 | - **Proximity**: Information on how to work with a system should be close to the system 33 | itself. 34 | 35 | - **Interfacing**: *devcli* serves as an interface to other systems. As such, it should 36 | provide the features necessary to help create and diagnose these interfaces, 37 | rather than functioning as the system itself. 38 | 39 | - **Disposability**: Under any circumstance, the system which *devcli* interfaces should rely on it to work. 40 | It is meant to help developers, not to be the part of the system. Removing it should not be detrimental 41 | to the system. 42 | 43 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | @test *options: lint 2 | poetry run pytest {{options}} 3 | 4 | @lint: 5 | poetry run black . --check --diff --color 6 | 7 | @format: 8 | poetry run black . 9 | 10 | @update: 11 | poetry update 12 | 13 | @build: 14 | poetry build --format=wheel 15 | 16 | @install: build 17 | pipx install --force dist/devcli*.whl 18 | 19 | @check: install 20 | devcli example ping 21 | -devcli show-version 22 | -devcli show-config 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2014-2024 Marco Valtas 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. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | devcli - A command line tool to create command line tools 2 | ========================================================= 3 | 4 | Why 5 | --- 6 | 7 | There's probably no shortage of tools for developers. *devcli* came from two 8 | necessities I had. First, I wanted to avoid memorizing complex command lines 9 | and the order of commands for daily repetitive tasks—something like `The 10 | General Problem `_. During my time as a software 11 | consultant, I had to memorize a lot of these commands for each new project. The 12 | second necessity was a consequence of the first. Since new commands had to be 13 | added and removed easily, I needed a way to "framework" the creation of these 14 | commands, and that's when I decided to create *devcli*. 15 | 16 | Installation 17 | ------------ 18 | 19 | For now the easiest way to use *devcli* is to clone this repository and 20 | using `just `_. You can install ``just`` using 21 | `homebrew `_ on macOS. I don't have the resources to support 22 | other platforms. Dependencies are managed through `poetry `_ so you need to install 23 | it too if you don't have it:: 24 | 25 | $ brew install just 26 | $ git clone https://github.com/mvaltas/devcli.git 27 | $ cd devcli 28 | $ just install 29 | 30 | This should be enough to install *devcli* and make it available on your path. 31 | 32 | Usage 33 | ----- 34 | 35 | This tool is designed with the principle of **discoverability**, which means 36 | that to explore the tool you can run it without arguments and be able to explore 37 | the commands available and find more about them:: 38 | 39 | devcli 40 | 41 | Usage: devcli [OPTIONS] COMMAND [ARGS]... 42 | 43 | ╭─ Options ────────────────────────────────────────────────────────────╮ 44 | │ --debug Enable debug log │ 45 | │ --verbose Enable info log │ 46 | │ --help Show this message and exit. │ 47 | ╰──────────────────────────────────────────────────────────────────────╯ 48 | ╭─ Commands ───────────────────────────────────────────────────────────╮ 49 | │ example Examples on how create cli commands with *devcli* │ 50 | │ op Shortcuts for 1Password CLI │ 51 | ╰──────────────────────────────────────────────────────────────────────╯ 52 | 53 | Extending 54 | --------- 55 | 56 | *devcli* will scan for directories called ``.devcli`` in these directories it will expect two things, 57 | other commands to load and a configuration file ``devcli.toml`` (which can be empty). You can check 58 | the `example `_ for some examples 59 | of how you can extend. For the simplest case we can create a file ``hello.py`` in your ``.devcli`` 60 | directory, like so:: 61 | 62 | import devcli.framework as cmd 63 | cli = cmd.new("This is a hello world command") 64 | @cli.command() 65 | def say(): 66 | """Simply replies with a Hello, World!""" 67 | print("Hello, World!") 68 | 69 | Once you did that, running *devcli* again you see a new command available called ``hello``:: 70 | 71 | $ devcli 72 | 73 | Usage: devcli [OPTIONS] COMMAND [ARGS]... 74 | 75 | ╭─ Options ────────────────────────────────────────────────────────────╮ 76 | │ --debug Enable debug log │ 77 | │ --verbose Enable info log │ 78 | │ --help Show this message and exit. │ 79 | ╰──────────────────────────────────────────────────────────────────────╯ 80 | ╭─ Commands ───────────────────────────────────────────────────────────╮ 81 | │ example Examples on how create cli commands with *devcli* │ 82 | │ hello This is a hello world command │ 83 | │ op Shortcuts for 1Password CLI │ 84 | ╰──────────────────────────────────────────────────────────────────────╯ 85 | 86 | That's it, now you can run the ``--help`` to inspect your new command documentation:: 87 | 88 | $ devcli hello --help 89 | 90 | Usage: devcli hello [OPTIONS] COMMAND [ARGS]... 91 | 92 | This is a hello world command 93 | 94 | ╭─ Options ───────────────────────────────────────────╮ 95 | │ --help Show this message and exit. │ 96 | ╰─────────────────────────────────────────────────────╯ 97 | ╭─ Commands ──────────────────────────────────────────╮ 98 | │ say Simply replies with Hello, World! │ 99 | ╰─────────────────────────────────────────────────────╯ 100 | 101 | And if you call your new command:: 102 | 103 | $ devcli hello say 104 | Hello, World! 105 | 106 | Now you have created your first *devcli* command. To learn more about how to create commands check 107 | the `example `_ command for more 108 | advanced options. 109 | 110 | -------------------------------------------------------------------------------- /TODO.taskpaper: -------------------------------------------------------------------------------- 1 | python_version: 2 | - Python initial version @done 3 | - installation capability @done 4 | - project specific overrides of configuration @done 5 | - project specific commands @done 6 | - traverse for configuration files @done 7 | - traverse for commands @done 8 | - clean up zsh files @done 9 | - add a CONTRIBUTING file @done 10 | - update README file @done 11 | 12 | url: 13 | - display / --raw to to output only the URL and not the key? 14 | 15 | global: 16 | - --quiet parameter to suppress cmd.info() messages? 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /bin/devcli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | # get this file parent directory and add to path 6 | project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 7 | sys.path.insert(0, project_root) 8 | 9 | from devcli.core.cli import cli 10 | 11 | if __name__ == "__main__": 12 | sys.exit(cli()) 13 | -------------------------------------------------------------------------------- /devcli/commands/README: -------------------------------------------------------------------------------- 1 | This folder does not contain a __init__.py on purpose. Only commands 2 | should be stored here and will be loaded dynamically by *devcli*. Also 3 | no code defined in here should be imported somewhere else. -------------------------------------------------------------------------------- /devcli/commands/config.py: -------------------------------------------------------------------------------- 1 | from typer import Context 2 | 3 | import devcli.framework as cmd 4 | 5 | cli = cmd.new("Shortcuts to read from devcli configuration") 6 | 7 | 8 | @cli.command() 9 | def get(ctx: Context, key: str): 10 | cmd.echo(ctx.obj[key]) 11 | -------------------------------------------------------------------------------- /devcli/commands/op.py: -------------------------------------------------------------------------------- 1 | from typer import Context 2 | 3 | import devcli.framework as cmd 4 | from devcli.framework.errors import MissConfError 5 | from devcli.utils.one_password import OnePassword 6 | 7 | cli = cmd.new("Shortcuts for 1Password CLI") 8 | 9 | 10 | def get_op(config): 11 | # vault is optional and defaults to 'Private' on OnePassword 12 | vault = config["devcli.commands.op.vault"] 13 | if vault is None: 14 | vault = "Private" 15 | 16 | # account is mandatory for op cli to work 17 | acct = config["devcli.commands.op.account"] 18 | if acct is None: 19 | raise MissConfError( 20 | topic="devcli.commands.op", entry="account", example="VALID_OP_ACCOUNT" 21 | ) 22 | return OnePassword(acct, vault=vault) 23 | 24 | 25 | @cli.command() 26 | def credential(ctx: Context, key: str): 27 | """ 28 | Will read from 1Password an entry in the form op://Private/{key}/credential 29 | """ 30 | cmd.echo(get_op(ctx.obj).credential(key)) 31 | 32 | 33 | @cli.command() 34 | def password(ctx: Context, item: str): 35 | """ 36 | Will read from 1Password a login entry and return its password 37 | """ 38 | cmd.echo(get_op(ctx.obj).password(item)) 39 | -------------------------------------------------------------------------------- /devcli/commands/url.py: -------------------------------------------------------------------------------- 1 | from typer import Context 2 | 3 | import devcli.framework as cmd 4 | import devcli.utils.shell as shell 5 | from devcli.framework.errors import MissConfError 6 | 7 | cli = cmd.new("Shortcuts URLs bookmarking") 8 | 9 | 10 | def fuzzy_search(urls: dict, key: str): 11 | """ 12 | A simple fuzzy search for the URL key, if no exact 13 | matches are found the 'last' partial match will be 14 | used as fallback. 15 | 16 | The last partial match is the first alphabetically 17 | so, if entries are 'site', and 'site-prod' and the 18 | search is 'si', we should return 'site' value. 19 | """ 20 | partial_match = None 21 | for k, v in reversed(sorted(urls.items())): 22 | if key == k: 23 | return v 24 | elif key in k: 25 | partial_match = v 26 | return partial_match 27 | 28 | 29 | def display_urls(urls: dict): 30 | max_key_length = max(len(k) for k in urls.keys()) 31 | for k, v in sorted(urls.items()): 32 | cmd.echo(f"{k:<{max_key_length}}: {v}") 33 | 34 | 35 | @cli.command("open") 36 | def url_open(ctx: Context, key: str): 37 | """ 38 | Open a URL from the configuration 39 | """ 40 | url = fuzzy_search(ctx.obj[f"devcli.commands.url"], key) 41 | if url is None: 42 | raise MissConfError(topic="devcli.commands.url", entry=key, example="VALID_URL") 43 | 44 | cmd.info(f"Opening '{url}'") 45 | shell.run(f"open '{url}'") 46 | 47 | 48 | @cli.command("list") 49 | def url_list(ctx: Context): 50 | """ 51 | List all the URLs in the configuration 52 | """ 53 | urls = ctx.obj[f"devcli.commands.url"] 54 | cmd.info("Available URLs:\n") 55 | display_urls(urls) 56 | 57 | 58 | @cli.command() 59 | def search(ctx: Context, key: str): 60 | """ 61 | Search for a URL in the configuration 62 | """ 63 | cmd.info(f"Searching for '[green]{key}[/green]' in URLs\n") 64 | found = {} 65 | for k, v in ctx.obj[f"devcli.commands.url"].items(): 66 | if key in k or key in v: 67 | found.update({k: v}) 68 | display_urls(found) 69 | -------------------------------------------------------------------------------- /devcli/conf/defaults.toml: -------------------------------------------------------------------------------- 1 | ################### 2 | # devcli configuration 3 | # 4 | # The following section is for global options for devcli 5 | # this name space should not be used by dynamic commands 6 | # to avoid conflicts 7 | [devcli] 8 | 9 | # whether devcli should load its own subcommands 10 | enable_builtin_commands = true 11 | 12 | ############################# 13 | # op command is a shortcut for some OnePassword cli calls 14 | [devcli.commands.op] 15 | 16 | # The account ID you want to use. 1Password can be logged in 17 | # more than one account, you can use `op account list` to 18 | # find your account id. 19 | # account = "OUR_ACCOUNT_ID" 20 | 21 | # Optional: which vault use by default 22 | # You can list your vaulst with `op vault list` 23 | # by default vault is set to 'Private' 24 | # vault = "Private" 25 | 26 | ############################# 27 | # url command is a shortcut for URLs that you use often 28 | # each 'key' in this configuration is an alias 29 | # for the URL you want to use 30 | [devcli.commands.url] 31 | 32 | # devcli = "https://github.com/mvaltas/devcli" 33 | # duck = "https://duckduckgo.com" 34 | -------------------------------------------------------------------------------- /devcli/config/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import Config 2 | -------------------------------------------------------------------------------- /devcli/config/config.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import logging 3 | from pathlib import Path 4 | from typing import Any 5 | 6 | import toml 7 | 8 | from devcli.core import project_root, XDG_CONFIG_HOME 9 | 10 | 11 | class Config: 12 | logger = logging.getLogger(__name__) 13 | _instance = None 14 | 15 | def __new__(cls, *args, **kwargs): 16 | if not cls._instance: 17 | cls.logger.info(f"loading Config singleton") 18 | 19 | cls._instance = super(Config, cls).__new__(cls, *args, **kwargs) 20 | 21 | cls._instance._config = {} # init config holder empty 22 | cls._instance._audit = {} # history of what was loaded 23 | 24 | cls.logger.info("loading default configuration locations") 25 | # load defaults from devcli package 26 | cls._instance.add_config( 27 | project_root() / "devcli" / "conf" / "defaults.toml" 28 | ) 29 | # load global user config it can override defaults.toml 30 | cls._instance.add_config(XDG_CONFIG_HOME / "devcli" / "devcli.toml") 31 | 32 | return cls._instance 33 | 34 | def add_config(self, config_file: str | Path): 35 | if Path(config_file).is_file(): 36 | with open(config_file) as file: 37 | self.logger.debug(f"loading {config_file} data") 38 | config_contents = toml.load(file) 39 | self._audit[config_file] = copy.deepcopy(config_contents) 40 | self.logger.debug(f"configuration contents: {config_contents}") 41 | self.merge_update(self._config, config_contents) 42 | else: 43 | self.logger.warning(f"{config_file} is not a file or does not exist") 44 | 45 | return self 46 | 47 | def merge_update(self, source: dict, overrides: dict): 48 | """ 49 | It merges similar keys in a deep dict structure, preserving 50 | keys unless they are exactly the same, in which case their 51 | values will be overwritten. 52 | """ 53 | for key, value in overrides.items(): 54 | if ( 55 | isinstance(value, dict) 56 | and key in source 57 | and isinstance(source[key], dict) 58 | ): 59 | self.merge_update(source[key], value) 60 | else: 61 | source[key] = value 62 | 63 | def audit(self): 64 | """ 65 | Returns a list with the files and content that was loaded 66 | into the configuration object from each file. The Config 67 | object itself keeps a merged version of the configuration 68 | and should be used for fetching values. 69 | """ 70 | return copy.deepcopy(self._audit) 71 | 72 | def files(self): 73 | """ 74 | Returns the files that were loaded into configuration. 75 | :return: List[Path] 76 | """ 77 | return list(self.audit().keys()) 78 | 79 | def __getitem__(self, item: str) -> Any: 80 | self.logger.debug(f"getitem:{item}") 81 | if "." in item: 82 | keys = item.split(".") 83 | level = self._config 84 | for key in keys: 85 | if isinstance(level, dict) and key in level: 86 | level = level[key] 87 | else: 88 | return None 89 | return level 90 | else: 91 | return self._config.get(item, None) 92 | 93 | def __repr__(self): 94 | self.logger.debug("dumping config representation with toml.dumps()") 95 | return toml.dumps(self._config) 96 | -------------------------------------------------------------------------------- /devcli/core/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib.util 2 | import logging 3 | import os 4 | import sys 5 | from pathlib import Path 6 | 7 | from typer import Typer 8 | 9 | LOG_FORMAT = "%(asctime)s - %(name)s:%(lineno)d - %(levelname)s - %(message)s" 10 | 11 | DEVCLI_LOGLEVEL = os.environ.get("DEVCLI_LOGLEVEL", "WARNING") 12 | 13 | # user default configuration path 14 | XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") 15 | 16 | logging.basicConfig( 17 | format=LOG_FORMAT, level=logging.getLevelName(DEVCLI_LOGLEVEL.upper()) 18 | ) 19 | 20 | # debug only available if DEVCLI_LOGLEVEL is defined as "debug" 21 | logger = logging.getLogger("devcli.core.__init__") 22 | 23 | 24 | def project_root(filename=None) -> Path: 25 | """ 26 | Should return the directory in which devcli is contained, which 27 | is mostly the entry point for defaults and supporting code. 28 | If filename is given it will append project_root() into filename. 29 | """ 30 | # this is predicated on the fact we know where this file is 31 | parent = Path(__file__).resolve().parent.parent.parent 32 | logger.debug(f"project_root={parent}") 33 | if filename is None: 34 | return parent 35 | else: 36 | return parent / filename 37 | 38 | 39 | def load_dynamic_commands(app: Typer, directory: Path): 40 | """ 41 | scan a given directory for .py files and check if they 42 | contain the attribute 'cli', if so, it will consider 43 | a subcommand and add to Typer dynamically. 44 | """ 45 | logger.debug(f"load_dynamic_commands:{directory}") 46 | if not directory.exists(): 47 | logger.debug(f"couldn't find dir {directory}") 48 | return 49 | 50 | # adds directory as part of the loader path, this 51 | # allows commands to use relative import for their 52 | # own functions 53 | if directory not in sys.path: 54 | sys.path.insert(0, str(directory)) 55 | 56 | for file in directory.glob("*.py"): 57 | logger.debug(f"found file: {file}") 58 | module_name = file.stem # Get the file name without '.py' 59 | logger.debug(f"module: {module_name}") 60 | spec = importlib.util.spec_from_file_location(module_name, file) 61 | module = importlib.util.module_from_spec(spec) 62 | logger.debug(f"executing module: {module}") 63 | spec.loader.exec_module(module) 64 | if hasattr(module, "cli"): 65 | logger.debug( 66 | f'"cli" attribute found, adding as a subcommand: {module_name}' 67 | ) 68 | app.add_typer(module.cli, name=module_name) 69 | 70 | 71 | def traverse_search(target: str | Path, start: str | Path = Path.cwd()) -> [Path]: 72 | """ 73 | Given a target and a start point, it will traverse upwards the directory 74 | tree until getting to root directory and return a list of the 75 | locations where 'target' was found. 76 | """ 77 | logger.debug(f"traverse_search[{target}, {start}]") 78 | 79 | search_start_from = Path(start) 80 | if search_start_from.is_file(): 81 | logger.debug(f"start was a file, converting to directory") 82 | search_start_from = search_start_from.parent 83 | 84 | target = Path(target).name 85 | logger.debug(f"start search for {target}, from {search_start_from}") 86 | 87 | found = [] # results 88 | # Traverse up the directory tree 89 | while True: 90 | path_to_check = search_start_from / target 91 | if path_to_check.exists(): 92 | found.append(path_to_check) 93 | 94 | if search_start_from.parent == search_start_from: # Root directory reached 95 | break 96 | search_start_from = search_start_from.parent 97 | 98 | return found 99 | 100 | 101 | def traverse_load_dynamic_commands( 102 | app: Typer, subcommand_dir: str, start: Path = Path.cwd() 103 | ): 104 | """ 105 | Uses traverse_search to load commands dynamically 106 | """ 107 | directories = traverse_search(subcommand_dir, start) 108 | for d in directories: 109 | load_dynamic_commands(app, d) 110 | -------------------------------------------------------------------------------- /devcli/core/cli.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import typer 4 | from rich import print 5 | from typer import Context 6 | 7 | from devcli.config import Config 8 | from devcli.core import ( 9 | project_root, 10 | traverse_load_dynamic_commands, 11 | traverse_search, 12 | load_dynamic_commands, 13 | ) 14 | 15 | cli = typer.Typer(add_completion=False, name="devcli") 16 | 17 | # config is a singleton to be 18 | # available to all parts of the system 19 | boot_conf = Config() 20 | 21 | # load user defined configurations in opposite order 22 | for d in reversed(traverse_search(".devcli")): 23 | boot_conf.add_config(d / "devcli.toml") 24 | 25 | # should we load our own builtin commands? 26 | if boot_conf["devcli.enable_builtin_commands"]: 27 | load_dynamic_commands(cli, project_root("devcli/commands")) 28 | 29 | # load use defined commands 30 | traverse_load_dynamic_commands(cli, ".devcli") 31 | 32 | 33 | @cli.command(hidden=True) 34 | def show_version(): 35 | """ 36 | Show devcli version which is defined in pyproject.toml file 37 | 38 | :return: tool.poetry.version 39 | """ 40 | project_conf = boot_conf.add_config(project_root("pyproject.toml")) 41 | print(f"devcli version {project_conf['tool.poetry.version']}") 42 | 43 | 44 | @cli.command(hidden=True) 45 | def show_config(explain: bool = False): 46 | """ 47 | Display the current configuration parsed by devcli. 48 | 49 | If the `explain` parameter is set to True, the function will provide a detailed explanation 50 | of the configuration files that were loaded, including the order in which they were processed 51 | and any overrides that occurred. 52 | 53 | Args: 54 | explain (bool, optional): If True, provide a detailed explanation of the configuration 55 | loading process. Defaults to False. 56 | """ 57 | if explain: 58 | print("[cyan]Explaining configurations:[/cyan]") 59 | print(boot_conf.audit()) 60 | else: 61 | print(boot_conf) 62 | 63 | 64 | @cli.callback(invoke_without_command=True) 65 | def main( 66 | ctx: Context, 67 | debug: bool = typer.Option(False, "--debug", "-d", help="Enable debug log"), 68 | verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable info log"), 69 | ): 70 | logger = logging.getLogger() 71 | # set global log level 72 | if debug: 73 | logger.setLevel(level=logging.DEBUG) 74 | logging.debug("Debug logging enabled") 75 | elif verbose: 76 | logger.setLevel(level=logging.INFO) 77 | logging.info("Verbose mode enabled") 78 | 79 | # call help on the absence of a command 80 | if ctx.invoked_subcommand is None: 81 | logger.info("no subcommand given, defaulting to help message") 82 | typer.echo(ctx.get_help()) 83 | raise typer.Exit() 84 | 85 | logger.debug("setting configuration in the subcommand context") 86 | ctx.obj = Config() 87 | -------------------------------------------------------------------------------- /devcli/framework/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import new, stop, logger 2 | 3 | from .console import echo, info, warn, error 4 | 5 | __all__ = ["new", "echo", "warn", "error", "echo", "info", "stop", "logger"] 6 | -------------------------------------------------------------------------------- /devcli/framework/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | devcli.framework is a collection of helper functions for tool commands 3 | so that they don't have to reimplement common tasks related to a command 4 | work. 5 | """ 6 | 7 | import logging 8 | import sys 9 | from pathlib import Path 10 | 11 | import typer 12 | import inspect 13 | 14 | import devcli.framework.console as console 15 | 16 | _logger = logging.getLogger() 17 | 18 | 19 | def new(description: str = None) -> typer.Typer: 20 | """ 21 | The base of starting a new dynamic command. It 22 | returns the basic Typer type for command declaration. 23 | :returns: a typer.Typer 24 | """ 25 | _logger.info(f"creating new command description:{description}") 26 | return typer.Typer(help=description, no_args_is_help=True) 27 | 28 | 29 | def stop(msg: str = "Error", exit_code: int = 1): 30 | """ 31 | Prints a message in red if defined and stops the execution 32 | :param msg: 33 | :param exit_code: Optional exit number, defaults to 1 34 | """ 35 | _logger.info(f"stopping executing msg:{msg} exit_code:{exit_code}") 36 | console.error(msg) 37 | sys.exit(exit_code) 38 | 39 | 40 | def logger(name: str = None) -> logging.Logger: 41 | """ 42 | Returns an instance of logging.logger set to file name of the 43 | caller. 44 | 45 | :return: 46 | """ 47 | if name is None: 48 | caller_frame = inspect.stack()[1] 49 | name = f"command:{Path(caller_frame[1]).stem}" 50 | 51 | _logger.debug(f"returning a new logger to command ({name})") 52 | return logging.getLogger(name) 53 | -------------------------------------------------------------------------------- /devcli/framework/console.py: -------------------------------------------------------------------------------- 1 | from rich import print 2 | 3 | 4 | def echo(msg: str): 5 | """ 6 | Just print a message into the terminal. 7 | It uses rich.print() which allows for color tagging like [red]message[/red]. 8 | :param msg: A str with the message 9 | """ 10 | print(msg) 11 | 12 | 13 | def error(msg: str): 14 | """ 15 | Print a message in red 16 | """ 17 | print(f"[red]{msg}[/red]") 18 | 19 | 20 | def warn(msg: str): 21 | """ 22 | Prints a message in yellow 23 | """ 24 | print(f"[yellow]{msg}[/yellow]") 25 | 26 | 27 | def info(msg: str): 28 | """ 29 | Prints a message in cyan 30 | """ 31 | print(f"[cyan]{msg}[/cyan]") 32 | -------------------------------------------------------------------------------- /devcli/framework/errors.py: -------------------------------------------------------------------------------- 1 | class MissConfError(Exception): 2 | """ 3 | MissConfError helps to document missing configuration values from subcommands, 4 | it presents a friendly message together with example of what is missing. 5 | 6 | Example: 7 | `` 8 | acct = cmd.obj['config.account'] 9 | if acct is None: 10 | raise MissConfError(topic="config", entry="account", example="VALID_OP_ACCOUNT") 11 | `` 12 | """ 13 | 14 | def __init__(self, topic, entry, example): 15 | self.topic = topic 16 | self.entry = entry 17 | self.example = example 18 | super().__init__(self._generate_message()) 19 | 20 | def _generate_message(self): 21 | return ( 22 | f"Missing entry '{self.entry}' on '{self.topic}'.\n" 23 | f"Ensure you have the following in your configuration:\n\n" 24 | f"[{self.topic}]\n" 25 | f"{self.entry} = {self.example}\n" 26 | ) 27 | -------------------------------------------------------------------------------- /devcli/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvaltas/devcli/a5d501e4f993bee98c8d7a5de9b2da70a1482596/devcli/utils/__init__.py -------------------------------------------------------------------------------- /devcli/utils/one_password.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from devcli.utils.shell import ShellExecutable, capture 4 | 5 | 6 | class OnePassword(ShellExecutable): 7 | logger = logging.getLogger(__name__) 8 | 9 | def __init__(self, account: str, vault: str = "Private"): 10 | super().__init__() 11 | self.logger.debug(f"using account:{account} and vault:{vault}") 12 | self.account = f"--account {account}" 13 | self.vault = vault 14 | 15 | def read(self, key: str) -> str: 16 | self.logger.debug(f"read key={key}") 17 | return capture(f"op read {self.account} op://{key}").rstrip("\n") 18 | 19 | def credential(self, key: str) -> str: 20 | return self.read(f"{self.vault}/{key}/credential") 21 | 22 | def password(self, item: str) -> str: 23 | return self.read(f"{self.vault}/{item}/password") 24 | 25 | def login(self, item: str) -> str: 26 | return self.read(f"{self.vault}/{item}/login") 27 | -------------------------------------------------------------------------------- /devcli/utils/shell.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import random 4 | import shlex 5 | import subprocess 6 | import threading 7 | from io import StringIO 8 | from typing import Union, List 9 | 10 | from rich.console import Console 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def styled_text(text: str, sty: str = None, end: str = ""): 16 | out = StringIO() 17 | console = Console(file=out, force_terminal=True) 18 | console.print(text, style=sty, end=end) 19 | return out.getvalue() 20 | 21 | 22 | def _prepare_command(command): 23 | quoted_command = shlex.quote(command) 24 | # attempt to use user's shell, fallback to /bin/sh 25 | user_shell = os.environ.get("SHELL", "/bin/sh") 26 | logger.debug(f"resolving shell to: {user_shell}") 27 | final_command = f"{user_shell} -c {quoted_command}" 28 | return final_command 29 | 30 | 31 | def create_process(command: str, cwd: str = os.curdir): 32 | logger.info(f"create process for: {command}") 33 | final_command = _prepare_command(command) 34 | logger.debug(f"about to execute: {final_command}, on: {cwd}") 35 | proc = subprocess.Popen( 36 | final_command, 37 | shell=True, 38 | stdout=subprocess.PIPE, 39 | stderr=subprocess.STDOUT, 40 | env=os.environ, 41 | cwd=cwd, 42 | bufsize=1, 43 | universal_newlines=True, 44 | text=True, 45 | ) 46 | return proc 47 | 48 | 49 | def process_output(process, process_name: str): 50 | while True: 51 | output = process.stdout.readline() 52 | if process.poll() is not None and output == "": 53 | break 54 | if output: 55 | print(f"{process_name}: {output.strip()}") 56 | 57 | 58 | def start_process_thread(process, alias): 59 | thread = threading.Thread(target=process_output, args=(process, alias)) 60 | thread.start() 61 | return thread 62 | 63 | 64 | def iter_for(commands): 65 | """ 66 | Will return an iterable in the form of k,v for 67 | any str in a list, dict or purely a str 68 | """ 69 | logger.debug(f"creating iterator for: {commands}") 70 | if isinstance(commands, list): 71 | return enumerate(commands) 72 | elif isinstance(commands, dict): 73 | return commands.items() 74 | else: 75 | # since commands is a single command, split any arguments 76 | # and get the command name as its own alias 77 | alias = os.path.basename(commands.split(" ")[0]) 78 | return iter_for({alias: commands}) 79 | 80 | 81 | def run(command: Union[str, List[str], dict], cwd: str = os.curdir): 82 | """ 83 | A basic shell execution that will execute the command and directly 84 | output its messages. It won't capture the output and calling this 85 | is a run and forget. 86 | 87 | If more than one command is given, in a list or a dict format they 88 | will be fired in order but immediately sent to the background. 89 | The outputs will be collected in the order they return from 90 | the commands. 91 | """ 92 | results = {} 93 | procs = [] 94 | for alias, cmd in iter_for(command): 95 | process = create_process(cmd, cwd) 96 | results.update({process.pid: {"alias": alias}}) 97 | thread = start_process_thread( 98 | process, styled_text(f"{alias}", f"color({random.randint(1, 231)})") 99 | ) 100 | procs.append((process, thread)) 101 | for proc, thread in procs: 102 | results[proc.pid]["exitcode"] = proc.wait() 103 | thread.join() 104 | 105 | return promote_value_to_key(results, new_key="alias", new_value="pid") 106 | 107 | 108 | def capture(command: str, cwd: str = os.curdir) -> str: 109 | """ 110 | A run which captures the output and returns it, it won't display the stdout 111 | of the command during its execution. 112 | """ 113 | logger.debug(f"running shell: {command}") 114 | final_command = _prepare_command(command) 115 | logger.debug(f"about to execute: {final_command}, on: {cwd}") 116 | result = subprocess.run( 117 | final_command, cwd=cwd, shell=True, capture_output=True, text=True 118 | ) 119 | logger.debug(f"return code: {result.returncode}") 120 | return result.stdout 121 | 122 | 123 | def promote_value_to_key(nested_dict: dict, new_key: str, new_value: str) -> dict: 124 | """This function works on a dict which has values that are also dicts. 125 | It will swap a value of the nested dict and add the key as a value. 126 | It works with the assumption that the values that can be accessed 127 | on 'new_key' are unique enough to become keys on the dict, if 128 | not data will be lost during the transformation. 129 | 130 | Example: 131 | { 123: {"alias": "cmd1", "code": "111" }, 132 | 456: {"alias": "cmd2", "code": "222" }} 133 | transform with new_key="alias", new_value="pid" results in 134 | { "cmd1": { "pid": 123, "code": "111" }, 135 | "cmd2": { "pid": 456, "code": "222" }} 136 | """ 137 | transformed_dict = {} 138 | for key, value in nested_dict.items(): 139 | nk_key = value.pop(new_key) 140 | if nk_key not in transformed_dict: 141 | transformed_dict[nk_key] = {} 142 | transformed_dict[nk_key].update({new_value: key, **value}) 143 | return transformed_dict 144 | 145 | 146 | class ShellExecutable: 147 | logger = logging.getLogger(__name__) 148 | 149 | def __init__(self): 150 | self.result = None 151 | 152 | def capture(self, cmd: str): 153 | self.result = capture(cmd) 154 | return self.result 155 | -------------------------------------------------------------------------------- /docs/.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_PATH: "vendor/bundle" 3 | -------------------------------------------------------------------------------- /docs/.ruby-version: -------------------------------------------------------------------------------- 1 | 3.3.6 2 | -------------------------------------------------------------------------------- /docs/404.html: -------------------------------------------------------------------------------- 1 | --- 2 | permalink: /404.html 3 | layout: default 4 | --- 5 | 6 | 19 | 20 |
21 |

404

22 | 23 |

Page not found :(

24 |

The requested page could not be found.

25 |
26 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | devcli.io -------------------------------------------------------------------------------- /docs/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "jekyll" 4 | gem "jekyll-theme-minimal" 5 | -------------------------------------------------------------------------------- /docs/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | addressable (2.8.7) 5 | public_suffix (>= 2.0.2, < 7.0) 6 | bigdecimal (3.1.8) 7 | colorator (1.1.0) 8 | concurrent-ruby (1.3.4) 9 | em-websocket (0.5.3) 10 | eventmachine (>= 0.12.9) 11 | http_parser.rb (~> 0) 12 | eventmachine (1.2.7) 13 | ffi (1.17.0) 14 | ffi (1.17.0-aarch64-linux-gnu) 15 | ffi (1.17.0-aarch64-linux-musl) 16 | ffi (1.17.0-arm-linux-gnu) 17 | ffi (1.17.0-arm-linux-musl) 18 | ffi (1.17.0-arm64-darwin) 19 | ffi (1.17.0-x86-linux-gnu) 20 | ffi (1.17.0-x86-linux-musl) 21 | ffi (1.17.0-x86_64-darwin) 22 | ffi (1.17.0-x86_64-linux-gnu) 23 | ffi (1.17.0-x86_64-linux-musl) 24 | forwardable-extended (2.6.0) 25 | google-protobuf (4.29.1) 26 | bigdecimal 27 | rake (>= 13) 28 | google-protobuf (4.29.1-aarch64-linux) 29 | bigdecimal 30 | rake (>= 13) 31 | google-protobuf (4.29.1-arm64-darwin) 32 | bigdecimal 33 | rake (>= 13) 34 | google-protobuf (4.29.1-x86-linux) 35 | bigdecimal 36 | rake (>= 13) 37 | google-protobuf (4.29.1-x86_64-darwin) 38 | bigdecimal 39 | rake (>= 13) 40 | google-protobuf (4.29.1-x86_64-linux) 41 | bigdecimal 42 | rake (>= 13) 43 | http_parser.rb (0.8.0) 44 | i18n (1.14.6) 45 | concurrent-ruby (~> 1.0) 46 | jekyll (4.3.4) 47 | addressable (~> 2.4) 48 | colorator (~> 1.0) 49 | em-websocket (~> 0.5) 50 | i18n (~> 1.0) 51 | jekyll-sass-converter (>= 2.0, < 4.0) 52 | jekyll-watch (~> 2.0) 53 | kramdown (~> 2.3, >= 2.3.1) 54 | kramdown-parser-gfm (~> 1.0) 55 | liquid (~> 4.0) 56 | mercenary (>= 0.3.6, < 0.5) 57 | pathutil (~> 0.9) 58 | rouge (>= 3.0, < 5.0) 59 | safe_yaml (~> 1.0) 60 | terminal-table (>= 1.8, < 4.0) 61 | webrick (~> 1.7) 62 | jekyll-sass-converter (3.0.0) 63 | sass-embedded (~> 1.54) 64 | jekyll-seo-tag (2.8.0) 65 | jekyll (>= 3.8, < 5.0) 66 | jekyll-theme-minimal (0.2.0) 67 | jekyll (> 3.5, < 5.0) 68 | jekyll-seo-tag (~> 2.0) 69 | jekyll-watch (2.2.1) 70 | listen (~> 3.0) 71 | kramdown (2.5.1) 72 | rexml (>= 3.3.9) 73 | kramdown-parser-gfm (1.1.0) 74 | kramdown (~> 2.0) 75 | liquid (4.0.4) 76 | listen (3.9.0) 77 | rb-fsevent (~> 0.10, >= 0.10.3) 78 | rb-inotify (~> 0.9, >= 0.9.10) 79 | mercenary (0.4.0) 80 | pathutil (0.16.2) 81 | forwardable-extended (~> 2.6) 82 | public_suffix (6.0.1) 83 | rake (13.2.1) 84 | rb-fsevent (0.11.2) 85 | rb-inotify (0.11.1) 86 | ffi (~> 1.0) 87 | rexml (3.4.0) 88 | rouge (4.5.1) 89 | safe_yaml (1.0.5) 90 | sass-embedded (1.83.0) 91 | google-protobuf (~> 4.28) 92 | rake (>= 13) 93 | sass-embedded (1.83.0-aarch64-linux-android) 94 | google-protobuf (~> 4.28) 95 | sass-embedded (1.83.0-aarch64-linux-gnu) 96 | google-protobuf (~> 4.28) 97 | sass-embedded (1.83.0-aarch64-linux-musl) 98 | google-protobuf (~> 4.28) 99 | sass-embedded (1.83.0-aarch64-mingw-ucrt) 100 | google-protobuf (~> 4.28) 101 | sass-embedded (1.83.0-arm-linux-androideabi) 102 | google-protobuf (~> 4.28) 103 | sass-embedded (1.83.0-arm-linux-gnueabihf) 104 | google-protobuf (~> 4.28) 105 | sass-embedded (1.83.0-arm-linux-musleabihf) 106 | google-protobuf (~> 4.28) 107 | sass-embedded (1.83.0-arm64-darwin) 108 | google-protobuf (~> 4.28) 109 | sass-embedded (1.83.0-riscv64-linux-android) 110 | google-protobuf (~> 4.28) 111 | sass-embedded (1.83.0-riscv64-linux-gnu) 112 | google-protobuf (~> 4.28) 113 | sass-embedded (1.83.0-riscv64-linux-musl) 114 | google-protobuf (~> 4.28) 115 | sass-embedded (1.83.0-x86-cygwin) 116 | google-protobuf (~> 4.28) 117 | sass-embedded (1.83.0-x86-linux-android) 118 | google-protobuf (~> 4.28) 119 | sass-embedded (1.83.0-x86-linux-gnu) 120 | google-protobuf (~> 4.28) 121 | sass-embedded (1.83.0-x86-linux-musl) 122 | google-protobuf (~> 4.28) 123 | sass-embedded (1.83.0-x86-mingw-ucrt) 124 | google-protobuf (~> 4.28) 125 | sass-embedded (1.83.0-x86_64-cygwin) 126 | google-protobuf (~> 4.28) 127 | sass-embedded (1.83.0-x86_64-darwin) 128 | google-protobuf (~> 4.28) 129 | sass-embedded (1.83.0-x86_64-linux-android) 130 | google-protobuf (~> 4.28) 131 | sass-embedded (1.83.0-x86_64-linux-gnu) 132 | google-protobuf (~> 4.28) 133 | sass-embedded (1.83.0-x86_64-linux-musl) 134 | google-protobuf (~> 4.28) 135 | terminal-table (3.0.2) 136 | unicode-display_width (>= 1.1.1, < 3) 137 | unicode-display_width (2.6.0) 138 | webrick (1.9.1) 139 | 140 | PLATFORMS 141 | aarch64-linux 142 | aarch64-linux-android 143 | aarch64-linux-gnu 144 | aarch64-linux-musl 145 | aarch64-mingw-ucrt 146 | arm-linux-androideabi 147 | arm-linux-gnu 148 | arm-linux-gnueabihf 149 | arm-linux-musl 150 | arm-linux-musleabihf 151 | arm64-darwin 152 | riscv64-linux-android 153 | riscv64-linux-gnu 154 | riscv64-linux-musl 155 | ruby 156 | x86-cygwin 157 | x86-linux 158 | x86-linux-android 159 | x86-linux-gnu 160 | x86-linux-musl 161 | x86-mingw-ucrt 162 | x86_64-cygwin 163 | x86_64-darwin 164 | x86_64-linux 165 | x86_64-linux-android 166 | x86_64-linux-gnu 167 | x86_64-linux-musl 168 | 169 | DEPENDENCIES 170 | jekyll 171 | jekyll-theme-minimal 172 | 173 | BUNDLED WITH 174 | 2.5.22 175 | -------------------------------------------------------------------------------- /docs/Rakefile: -------------------------------------------------------------------------------- 1 | 2 | task default: %w[serve] 3 | 4 | task :serve do 5 | # starts the jekyll server and open the browser 6 | sh 'bundle exec jekyll serve -o' 7 | end 8 | 9 | task :doctor do 10 | # runs the jekyll doctor command 11 | sh 'bundle exec jekyll doctor' 12 | end 13 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | # Welcome to Jekyll! 2 | # 3 | # This config file is meant for settings that affect your whole blog, values 4 | # which you are expected to set up once and rarely edit after that. If you find 5 | # yourself editing this file very often, consider using Jekyll's data files 6 | # feature for the data you need to update frequently. 7 | # 8 | # For technical reasons, this file is *NOT* reloaded automatically when you use 9 | # 'bundle exec jekyll serve'. If you change this file, please restart the server process. 10 | # 11 | # If you need help with YAML syntax, here are some quick references for you: 12 | # https://learn-the-web.algonquindesign.ca/topics/markdown-yaml-cheat-sheet/#yaml 13 | # https://learnxinyminutes.com/docs/yaml/ 14 | # 15 | # Site settings 16 | # These are used to personalize your new site. If you look in the HTML files, 17 | # you will see them accessed via {{ site.title }}, {{ site.email }}, and so on. 18 | # You can create any custom variable you would like, and they will be accessible 19 | # in the templates via {{ site.myvariable }}. 20 | 21 | title: devcli 22 | description: >- 23 | devcli is a command line tool to create command line 24 | tools. 25 | 26 | logo: /assets/img/logo.png 27 | 28 | # Build settings 29 | theme: jekyll-theme-minimal 30 | 31 | exclude: 32 | - CNAME 33 | - Rakefile 34 | - Gemfile 35 | - Gemfile.lock 36 | - node_modules 37 | - vendor/bundle/ 38 | - vendor/cache/ 39 | - vendor/gems/ 40 | - vendor/ruby/ 41 | -------------------------------------------------------------------------------- /docs/assets/css/style.scss: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | @import "{{ site.theme }}"; 5 | 6 | a { 7 | color:#000; 8 | text-decoration:none; 9 | } 10 | 11 | a:hover, a:focus { 12 | color:#090; 13 | font-weight: bold; 14 | } 15 | -------------------------------------------------------------------------------- /docs/assets/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvaltas/devcli/a5d501e4f993bee98c8d7a5de9b2da70a1482596/docs/assets/img/logo.png -------------------------------------------------------------------------------- /docs/design/logo.graffle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvaltas/devcli/a5d501e4f993bee98c8d7a5de9b2da70a1482596/docs/design/logo.graffle -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 | 5 | Welcome to the **devcli** documentation site! Since this tool aims to simplify 6 | the development process, we need to ensure it's easy to use and understand. 7 | 8 | ## Installation 9 | 10 | Installation can be done using Python tools [`poetry`](https://python-poetry.org/docs/#installation) 11 | and [`pipx`](https://pypi.org/project/pipx/). If you don't have them installed, you can use [`homebrew`](https://brew.sh/) to install them. 12 | 13 | ```bash 14 | $ brew install poetry 15 | $ brew install pipx 16 | ``` 17 | 18 | Once you have them installed, you can install `devcli` by cloning the repository and running the following commands, like so: 19 | 20 | ```bash 21 | $ git clone https://github.com/mvaltas/devcli.git 22 | $ cd devcli 23 | $ poetry build --format=wheel 24 | $ pipx install --force dist/devcli*.whl 25 | ``` 26 | 27 | ## Usage 28 | 29 | This tool is designed with the principle of *discoverability*, which means you 30 | can explore it by running it without any arguments. This way, you'll be able to 31 | see the available commands and learn more about them: 32 | 33 | ``` 34 | $ devcli 35 | 36 | Usage: devcli [OPTIONS] COMMAND [ARGS]... 37 | 38 | ╭─ Options ────────────────────────────────────────────────────────────╮ 39 | │ --debug Enable debug log │ 40 | │ --verbose Enable info log │ 41 | │ --help Show this message and exit. │ 42 | ╰──────────────────────────────────────────────────────────────────────╯ 43 | ╭─ Commands ───────────────────────────────────────────────────────────╮ 44 | │ example Examples on how create cli commands with *devcli* │ 45 | ╰──────────────────────────────────────────────────────────────────────╯ 46 | ``` 47 | 48 | ## Extending 49 | 50 | *devcli* will scan for directories named `.devcli`, where it will expect two 51 | things: other commands to load and a configuration file named `devcli.toml` 52 | (which can be empty). You can check the 53 | [`example`](https://github.com/mvaltas/devcli/blob/main/.devcli/example.py) for 54 | some ways to extend it. For the simplest case, you can create a file named 55 | `hello.py` in your `.devcli` directory, like this: 56 | 57 | ```python 58 | import devcli.framework as cmd 59 | 60 | cli = cmd.new("This is a hello world command") 61 | 62 | @cli.command() 63 | def say(): 64 | """Simply replies with a Hello, World!""" 65 | print("Hello, World!") 66 | 67 | ``` 68 | 69 | Once you did that, running *devcli* again you see a new command available called `hello`: 70 | 71 | ``` 72 | $ devcli 73 | 74 | Usage: devcli [OPTIONS] COMMAND [ARGS]... 75 | 76 | ╭─ Options ────────────────────────────────────────────────────────────╮ 77 | │ --debug Enable debug log │ 78 | │ --verbose Enable info log │ 79 | │ --help Show this message and exit. │ 80 | ╰──────────────────────────────────────────────────────────────────────╯ 81 | ╭─ Commands ───────────────────────────────────────────────────────────╮ 82 | │ example Examples on how create cli commands with *devcli* │ 83 | │ hello This is a hello world command │ 84 | ╰──────────────────────────────────────────────────────────────────────╯ 85 | 86 | ``` 87 | 88 | That's it, now you can run the `--help` to inspect your new command documentation:: 89 | 90 | 91 | ``` 92 | $ devcli hello --help 93 | 94 | Usage: devcli hello [OPTIONS] COMMAND [ARGS]... 95 | 96 | This is a hello world command 97 | 98 | ╭─ Options ───────────────────────────────────────────╮ 99 | │ --help Show this message and exit. │ 100 | ╰─────────────────────────────────────────────────────╯ 101 | ╭─ Commands ──────────────────────────────────────────╮ 102 | │ say Simply replies with Hello, World! │ 103 | ╰─────────────────────────────────────────────────────╯ 104 | ``` 105 | 106 | And if you call your new command:: 107 | 108 | ```bash 109 | $ devcli hello say 110 | Hello, World! 111 | ``` 112 | 113 | Now you have created your first *devcli* command. To learn more about how to create commands check 114 | the [`example`](https://github.com/mvaltas/devcli/blob/main/.devcli/example.py) command for more 115 | advanced options. 116 | 117 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "black" 5 | version = "25.1.0" 6 | description = "The uncompromising code formatter." 7 | optional = false 8 | python-versions = ">=3.9" 9 | groups = ["main"] 10 | files = [ 11 | {file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"}, 12 | {file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"}, 13 | {file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"}, 14 | {file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"}, 15 | {file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"}, 16 | {file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"}, 17 | {file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"}, 18 | {file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"}, 19 | {file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"}, 20 | {file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"}, 21 | {file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"}, 22 | {file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"}, 23 | {file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"}, 24 | {file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"}, 25 | {file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"}, 26 | {file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"}, 27 | {file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"}, 28 | {file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"}, 29 | {file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"}, 30 | {file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"}, 31 | {file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"}, 32 | {file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"}, 33 | ] 34 | 35 | [package.dependencies] 36 | click = ">=8.0.0" 37 | mypy-extensions = ">=0.4.3" 38 | packaging = ">=22.0" 39 | pathspec = ">=0.9.0" 40 | platformdirs = ">=2" 41 | 42 | [package.extras] 43 | colorama = ["colorama (>=0.4.3)"] 44 | d = ["aiohttp (>=3.10)"] 45 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 46 | uvloop = ["uvloop (>=0.15.2)"] 47 | 48 | [[package]] 49 | name = "certifi" 50 | version = "2025.1.31" 51 | description = "Python package for providing Mozilla's CA Bundle." 52 | optional = false 53 | python-versions = ">=3.6" 54 | groups = ["main"] 55 | files = [ 56 | {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, 57 | {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, 58 | ] 59 | 60 | [[package]] 61 | name = "charset-normalizer" 62 | version = "3.4.1" 63 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 64 | optional = false 65 | python-versions = ">=3.7" 66 | groups = ["main"] 67 | files = [ 68 | {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, 69 | {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, 70 | {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, 71 | {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, 72 | {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, 73 | {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, 74 | {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, 75 | {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, 76 | {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, 77 | {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, 78 | {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, 79 | {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, 80 | {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, 81 | {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, 82 | {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, 83 | {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, 84 | {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, 85 | {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, 86 | {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, 87 | {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, 88 | {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, 89 | {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, 90 | {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, 91 | {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, 92 | {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, 93 | {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, 94 | {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, 95 | {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, 96 | {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, 97 | {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, 98 | {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, 99 | {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, 100 | {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, 101 | {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, 102 | {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, 103 | {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, 104 | {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, 105 | {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, 106 | {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, 107 | {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, 108 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, 109 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, 110 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, 111 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, 112 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, 113 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, 114 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, 115 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, 116 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, 117 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, 118 | {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, 119 | {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, 120 | {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, 121 | {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, 122 | {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, 123 | {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, 124 | {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, 125 | {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, 126 | {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, 127 | {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, 128 | {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, 129 | {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, 130 | {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, 131 | {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, 132 | {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, 133 | {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, 134 | {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, 135 | {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, 136 | {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, 137 | {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, 138 | {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, 139 | {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, 140 | {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, 141 | {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, 142 | {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, 143 | {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, 144 | {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, 145 | {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, 146 | {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, 147 | {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, 148 | {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, 149 | {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, 150 | {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, 151 | {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, 152 | {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, 153 | {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, 154 | {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, 155 | {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, 156 | {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, 157 | {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, 158 | {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, 159 | {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, 160 | ] 161 | 162 | [[package]] 163 | name = "click" 164 | version = "8.1.8" 165 | description = "Composable command line interface toolkit" 166 | optional = false 167 | python-versions = ">=3.7" 168 | groups = ["main"] 169 | files = [ 170 | {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, 171 | {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, 172 | ] 173 | 174 | [package.dependencies] 175 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 176 | 177 | [[package]] 178 | name = "colorama" 179 | version = "0.4.6" 180 | description = "Cross-platform colored terminal text." 181 | optional = false 182 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 183 | groups = ["main", "dev"] 184 | files = [ 185 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 186 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 187 | ] 188 | markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "sys_platform == \"win32\""} 189 | 190 | [[package]] 191 | name = "idna" 192 | version = "3.10" 193 | description = "Internationalized Domain Names in Applications (IDNA)" 194 | optional = false 195 | python-versions = ">=3.6" 196 | groups = ["main"] 197 | files = [ 198 | {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, 199 | {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, 200 | ] 201 | 202 | [package.extras] 203 | all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] 204 | 205 | [[package]] 206 | name = "iniconfig" 207 | version = "2.0.0" 208 | description = "brain-dead simple config-ini parsing" 209 | optional = false 210 | python-versions = ">=3.7" 211 | groups = ["main", "dev"] 212 | files = [ 213 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 214 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 215 | ] 216 | 217 | [[package]] 218 | name = "markdown-it-py" 219 | version = "3.0.0" 220 | description = "Python port of markdown-it. Markdown parsing, done right!" 221 | optional = false 222 | python-versions = ">=3.8" 223 | groups = ["main"] 224 | files = [ 225 | {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, 226 | {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, 227 | ] 228 | 229 | [package.dependencies] 230 | mdurl = ">=0.1,<1.0" 231 | 232 | [package.extras] 233 | benchmarking = ["psutil", "pytest", "pytest-benchmark"] 234 | code-style = ["pre-commit (>=3.0,<4.0)"] 235 | compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] 236 | linkify = ["linkify-it-py (>=1,<3)"] 237 | plugins = ["mdit-py-plugins"] 238 | profiling = ["gprof2dot"] 239 | rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] 240 | testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] 241 | 242 | [[package]] 243 | name = "mdurl" 244 | version = "0.1.2" 245 | description = "Markdown URL utilities" 246 | optional = false 247 | python-versions = ">=3.7" 248 | groups = ["main"] 249 | files = [ 250 | {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, 251 | {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, 252 | ] 253 | 254 | [[package]] 255 | name = "mock" 256 | version = "5.1.0" 257 | description = "Rolling backport of unittest.mock for all Pythons" 258 | optional = false 259 | python-versions = ">=3.6" 260 | groups = ["main"] 261 | files = [ 262 | {file = "mock-5.1.0-py3-none-any.whl", hash = "sha256:18c694e5ae8a208cdb3d2c20a993ca1a7b0efa258c247a1e565150f477f83744"}, 263 | {file = "mock-5.1.0.tar.gz", hash = "sha256:5e96aad5ccda4718e0a229ed94b2024df75cc2d55575ba5762d31f5767b8767d"}, 264 | ] 265 | 266 | [package.extras] 267 | build = ["blurb", "twine", "wheel"] 268 | docs = ["sphinx"] 269 | test = ["pytest", "pytest-cov"] 270 | 271 | [[package]] 272 | name = "mypy-extensions" 273 | version = "1.0.0" 274 | description = "Type system extensions for programs checked with the mypy type checker." 275 | optional = false 276 | python-versions = ">=3.5" 277 | groups = ["main"] 278 | files = [ 279 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 280 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 281 | ] 282 | 283 | [[package]] 284 | name = "packaging" 285 | version = "24.2" 286 | description = "Core utilities for Python packages" 287 | optional = false 288 | python-versions = ">=3.8" 289 | groups = ["main", "dev"] 290 | files = [ 291 | {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, 292 | {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, 293 | ] 294 | 295 | [[package]] 296 | name = "pathspec" 297 | version = "0.12.1" 298 | description = "Utility library for gitignore style pattern matching of file paths." 299 | optional = false 300 | python-versions = ">=3.8" 301 | groups = ["main"] 302 | files = [ 303 | {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, 304 | {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, 305 | ] 306 | 307 | [[package]] 308 | name = "platformdirs" 309 | version = "4.3.6" 310 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 311 | optional = false 312 | python-versions = ">=3.8" 313 | groups = ["main"] 314 | files = [ 315 | {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, 316 | {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, 317 | ] 318 | 319 | [package.extras] 320 | docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] 321 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] 322 | type = ["mypy (>=1.11.2)"] 323 | 324 | [[package]] 325 | name = "pluggy" 326 | version = "1.5.0" 327 | description = "plugin and hook calling mechanisms for python" 328 | optional = false 329 | python-versions = ">=3.8" 330 | groups = ["main", "dev"] 331 | files = [ 332 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 333 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 334 | ] 335 | 336 | [package.extras] 337 | dev = ["pre-commit", "tox"] 338 | testing = ["pytest", "pytest-benchmark"] 339 | 340 | [[package]] 341 | name = "pyfakefs" 342 | version = "5.7.4" 343 | description = "pyfakefs implements a fake file system that mocks the Python file system modules." 344 | optional = false 345 | python-versions = ">=3.7" 346 | groups = ["main"] 347 | files = [ 348 | {file = "pyfakefs-5.7.4-py3-none-any.whl", hash = "sha256:3e763d700b91c54ade6388be2cfa4e521abc00e34f7defb84ee511c73031f45f"}, 349 | {file = "pyfakefs-5.7.4.tar.gz", hash = "sha256:4971e65cc80a93a1e6f1e3a4654909c0c493186539084dc9301da3d68c8878fe"}, 350 | ] 351 | 352 | [[package]] 353 | name = "pygments" 354 | version = "2.19.1" 355 | description = "Pygments is a syntax highlighting package written in Python." 356 | optional = false 357 | python-versions = ">=3.8" 358 | groups = ["main"] 359 | files = [ 360 | {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, 361 | {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, 362 | ] 363 | 364 | [package.extras] 365 | windows-terminal = ["colorama (>=0.4.6)"] 366 | 367 | [[package]] 368 | name = "pytest" 369 | version = "8.3.4" 370 | description = "pytest: simple powerful testing with Python" 371 | optional = false 372 | python-versions = ">=3.8" 373 | groups = ["main", "dev"] 374 | files = [ 375 | {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, 376 | {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, 377 | ] 378 | 379 | [package.dependencies] 380 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 381 | iniconfig = "*" 382 | packaging = "*" 383 | pluggy = ">=1.5,<2" 384 | 385 | [package.extras] 386 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 387 | 388 | [[package]] 389 | name = "requests" 390 | version = "2.32.3" 391 | description = "Python HTTP for Humans." 392 | optional = false 393 | python-versions = ">=3.8" 394 | groups = ["main"] 395 | files = [ 396 | {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, 397 | {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, 398 | ] 399 | 400 | [package.dependencies] 401 | certifi = ">=2017.4.17" 402 | charset-normalizer = ">=2,<4" 403 | idna = ">=2.5,<4" 404 | urllib3 = ">=1.21.1,<3" 405 | 406 | [package.extras] 407 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 408 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 409 | 410 | [[package]] 411 | name = "rich" 412 | version = "13.9.4" 413 | description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" 414 | optional = false 415 | python-versions = ">=3.8.0" 416 | groups = ["main"] 417 | files = [ 418 | {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, 419 | {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, 420 | ] 421 | 422 | [package.dependencies] 423 | markdown-it-py = ">=2.2.0" 424 | pygments = ">=2.13.0,<3.0.0" 425 | 426 | [package.extras] 427 | jupyter = ["ipywidgets (>=7.5.1,<9)"] 428 | 429 | [[package]] 430 | name = "shellingham" 431 | version = "1.5.4" 432 | description = "Tool to Detect Surrounding Shell" 433 | optional = false 434 | python-versions = ">=3.7" 435 | groups = ["main"] 436 | files = [ 437 | {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, 438 | {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, 439 | ] 440 | 441 | [[package]] 442 | name = "toml" 443 | version = "0.10.2" 444 | description = "Python Library for Tom's Obvious, Minimal Language" 445 | optional = false 446 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 447 | groups = ["main"] 448 | files = [ 449 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 450 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 451 | ] 452 | 453 | [[package]] 454 | name = "typer" 455 | version = "0.15.1" 456 | description = "Typer, build great CLIs. Easy to code. Based on Python type hints." 457 | optional = false 458 | python-versions = ">=3.7" 459 | groups = ["main"] 460 | files = [ 461 | {file = "typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847"}, 462 | {file = "typer-0.15.1.tar.gz", hash = "sha256:a0588c0a7fa68a1978a069818657778f86abe6ff5ea6abf472f940a08bfe4f0a"}, 463 | ] 464 | 465 | [package.dependencies] 466 | click = ">=8.0.0" 467 | rich = ">=10.11.0" 468 | shellingham = ">=1.3.0" 469 | typing-extensions = ">=3.7.4.3" 470 | 471 | [[package]] 472 | name = "typing-extensions" 473 | version = "4.12.2" 474 | description = "Backported and Experimental Type Hints for Python 3.8+" 475 | optional = false 476 | python-versions = ">=3.8" 477 | groups = ["main"] 478 | files = [ 479 | {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, 480 | {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, 481 | ] 482 | 483 | [[package]] 484 | name = "urllib3" 485 | version = "2.3.0" 486 | description = "HTTP library with thread-safe connection pooling, file post, and more." 487 | optional = false 488 | python-versions = ">=3.9" 489 | groups = ["main"] 490 | files = [ 491 | {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, 492 | {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, 493 | ] 494 | 495 | [package.extras] 496 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] 497 | h2 = ["h2 (>=4,<5)"] 498 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 499 | zstd = ["zstandard (>=0.18.0)"] 500 | 501 | [metadata] 502 | lock-version = "2.1" 503 | python-versions = "^3.12" 504 | content-hash = "8a0e1d32d9db52d44502a044b687baf13ef09f7be2c7282107d04835a9bb697a" 505 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "devcli" 3 | version = "3.0.0" 4 | description = "A command line tool to create command line tools" 5 | authors = ["Marco Valtas"] 6 | license = "MIT" 7 | readme = "README.rst" 8 | include = ["pyproject.toml"] 9 | packages = [ 10 | { include = "devcli/**/*" }, 11 | ] 12 | 13 | 14 | [tool.poetry.dependencies] 15 | python = "^3.12" 16 | toml = "^0.10.2" 17 | mock = "^5.1.0" 18 | typer = "^0.15.1" 19 | rich = "^13.9.4" 20 | requests = "^2.31.0" 21 | pyfakefs = "^5.7.2" 22 | black = "==25.1.0" 23 | pytest = "==8.3.4" 24 | 25 | [tool.poetry.dev-dependencies] 26 | pytest = ">=7.0.0" 27 | 28 | [tool.poetry.scripts] 29 | devcli = "devcli.core.cli:cli" 30 | 31 | 32 | [build-system] 33 | requires = ["poetry-core"] 34 | build-backend = "poetry.core.masonry.api" 35 | -------------------------------------------------------------------------------- /tests/commands/test_config.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture(autouse=True) 7 | def setup(devcli_cmd): 8 | from devcli.config import Config 9 | from devcli.core import project_root 10 | 11 | # loads testing configuration 12 | Config().add_config(project_root("tests/fixtures/general.toml")) 13 | 14 | global config 15 | config = devcli_cmd("config") 16 | 17 | 18 | def test_get(): 19 | result = config("get", "devcli.key") 20 | assert "value" in result.output 21 | -------------------------------------------------------------------------------- /tests/commands/test_edit.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture(autouse=True) 5 | def setup(devcli_cmd): 6 | global edit 7 | edit = devcli_cmd("edit") 8 | 9 | 10 | def test_simple(): 11 | result = edit("config") 12 | -------------------------------------------------------------------------------- /tests/commands/test_op.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/commands/test_url.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | 5 | from devcli.framework.errors import MissConfError 6 | 7 | 8 | @pytest.fixture(autouse=True) 9 | def setup(devcli_cmd): 10 | from devcli.config import Config 11 | from devcli.core import project_root 12 | 13 | # loads testing configuration 14 | Config().add_config(project_root("tests/fixtures/general.toml")) 15 | 16 | global url 17 | url = devcli_cmd("url") 18 | 19 | 20 | def test_list(): 21 | result = url("list") 22 | assert "https://github.com/mvaltas/devcli" in result.output 23 | assert "https://duckduckgo.com" in result.output 24 | 25 | 26 | def test_basic_search(): 27 | result = url("search", "duck") 28 | assert "duck: https://duckduckgo.com" in result.output 29 | 30 | 31 | def test_partial_match_search(): 32 | result = url("search", "ck") 33 | assert "duck: https://duckduckgo.com" in result.output 34 | 35 | 36 | def test_exact_match_open(): 37 | with patch("devcli.utils.shell.run") as mock_run: 38 | url("open", "duck") 39 | mock_run.assert_called_once_with("open 'https://duckduckgo.com'") 40 | 41 | 42 | def test_partial_match_multiple_first_alphabetical_wins(): 43 | with patch("devcli.utils.shell.run") as mock_run: 44 | url("open", "g") 45 | mock_run.assert_called_once_with("open 'https://bing.com'") 46 | 47 | 48 | def test_raises_error_if_no_key_is_found(): 49 | result = url("open", "undefined-key-in-configuration") 50 | assert result.exit_code != 0 51 | assert isinstance(result.exception, MissConfError) 52 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from typer.testing import CliRunner 3 | 4 | from devcli.core.cli import cli 5 | 6 | runner = CliRunner() 7 | 8 | 9 | def invoke_command(cmd: str = None): 10 | # pre-prepare a command that will be invoked several times 11 | # with different parameters 12 | return lambda *params: runner.invoke(cli, ([cmd] if cmd else []) + list(params)) 13 | 14 | 15 | @pytest.fixture 16 | def devcli_cmd(): 17 | """ 18 | This fixture allows for reusing of invoke_command on Typer tests, in the tests 19 | you can set up without having to worry about the invoke or CliRunner() 20 | 21 | Example:: 22 | 23 | @pytest.fixture(autouse=True) 24 | def setup(setup_cmd): 25 | global cmd_under_test 26 | cmd_under_test = setup_cmd("cmd-under-test") 27 | 28 | def test_something(): 29 | result = cmd_under_test("options", "parameters") 30 | assert "expected output" == result.output 31 | """ 32 | return invoke_command 33 | -------------------------------------------------------------------------------- /tests/fixtures/general.toml: -------------------------------------------------------------------------------- 1 | [devcli] 2 | key = "value" 3 | 4 | [overridable_configuration] 5 | key = "general_value" 6 | 7 | [devcli.commands.url] 8 | 9 | devcli = "https://github.com/mvaltas/devcli" 10 | google = "https://google.com" 11 | duck = "https://duckduckgo.com" 12 | bing = "https://bing.com" 13 | 14 | [decli.commands.edit] 15 | 16 | -------------------------------------------------------------------------------- /tests/fixtures/specific.toml: -------------------------------------------------------------------------------- 1 | [a_specific_configuration] 2 | key = "value" 3 | 4 | [overridable_configuration] 5 | key = "specific_value" 6 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | from typer import Typer 2 | 3 | from unittest.mock import patch 4 | import devcli.framework.base as base 5 | 6 | 7 | def test_new_creates_typer_instance(): 8 | typer_instance = base.new("Test description") 9 | assert isinstance(typer_instance, Typer) 10 | 11 | 12 | def test_stop_prints_error_and_exits(): 13 | with patch("devcli.framework.console.error") as mock_error, patch( 14 | "sys.exit" 15 | ) as mock_exit: 16 | base.stop("Custom error message", 2) 17 | mock_error.assert_called_once_with("Custom error message") 18 | mock_exit.assert_called_once_with(2) 19 | 20 | 21 | def test_stop_has_default_error_message(): 22 | with patch("devcli.framework.console.error") as mock_error, patch( 23 | "sys.exit" 24 | ) as mock_exit: 25 | base.stop(exit_code=2) 26 | mock_error.assert_called_once_with("Error") 27 | mock_exit.assert_called_once_with(2) 28 | 29 | 30 | def test_returns_logger_with_caller_file_name(): 31 | with patch("inspect.stack") as mock_stack: 32 | mock_stack.return_value = [ 33 | None, 34 | ( 35 | "frame", 36 | ".devcli/filename", 37 | "lineno", 38 | "function", 39 | "code_context", 40 | "index", 41 | ), 42 | ] 43 | log = base.logger() 44 | assert log.name == "command:filename" 45 | 46 | 47 | def test_return_logger_with_name_given(): 48 | log = base.logger("test-logger") 49 | assert log.name == "test-logger" 50 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture(autouse=True) 7 | def setup(devcli_cmd): 8 | global devcli 9 | devcli = devcli_cmd() 10 | 11 | 12 | def test_version(): 13 | result = devcli("show-version") 14 | assert re.match(r".*version\s\d+\.\d+\.\d+$", result.output) is not None 15 | 16 | 17 | def test_default_to_help_if_command_not_found(): 18 | result = devcli() 19 | assert "Usage: devcli [OPTIONS] COMMAND [ARGS]" in result.output 20 | 21 | 22 | def test_passing_debug_changes_loglevel_to_debug(caplog): 23 | devcli("--debug", "show-version") 24 | assert any(record.levelname == "DEBUG" for record in caplog.records) 25 | 26 | 27 | def test_passing_debug_short_flag_changes_loglevel_to_debug(caplog): 28 | devcli("-d", "show-version") 29 | assert any(record.levelname == "DEBUG" for record in caplog.records) 30 | 31 | 32 | def test_passing_verbose_changes_loglevel_to_info(caplog): 33 | devcli("--verbose", "show-version") 34 | assert any(record.levelname == "INFO" for record in caplog.records) 35 | 36 | 37 | def test_passing_verbose_short_flag_changes_loglevel_to_info(caplog): 38 | devcli("-v", "show-version") 39 | assert any(record.levelname == "INFO" for record in caplog.records) 40 | 41 | 42 | def test_should_load_dynamic_commands(): 43 | # example subcommand works because we have a .devcli in the root 44 | # of the project, tests of the subcommand 'example' itself 45 | # are in test_example.py 46 | result = devcli("example") 47 | assert "ping" in result.output 48 | 49 | 50 | def test_show_config(): 51 | result = devcli("show-config") 52 | assert "[devcli]" in result.output 53 | 54 | 55 | def test_show_config_audit_log(): 56 | result = devcli("show-config", "--explain") 57 | assert re.search(r"conf/defaults\.toml", result.output) 58 | assert re.search(r"\.config/devcli/devcli\.toml", result.output) 59 | assert re.search(r"\.devcli/devcli\.toml", result.output) 60 | -------------------------------------------------------------------------------- /tests/test_conf.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from devcli.core import project_root 4 | 5 | 6 | @pytest.fixture(autouse=True) 7 | def setup(): 8 | from devcli.config import Config 9 | 10 | # force reset of singleton for testing 11 | Config._instance = None 12 | global conf 13 | conf = Config().add_config(project_root("tests/fixtures/general.toml")) 14 | 15 | 16 | def test_it_parses_one_conf_file(): 17 | assert conf["devcli"]["key"] == "value" 18 | 19 | 20 | def test_it_adds_configurations_of_other_files(): 21 | assert conf["devcli"]["key"] == "value" 22 | assert conf["a_specific_configuration"] is None 23 | 24 | conf.add_config(project_root("tests/fixtures/specific.toml")) 25 | 26 | assert conf["devcli"]["key"] == "value" 27 | assert conf["a_specific_configuration"]["key"] == "value" 28 | 29 | 30 | def test_last_added_configuration_overrides_values(): 31 | # from the initialization of general.toml 32 | assert conf["overridable_configuration"]["key"] == "general_value" 33 | 34 | # load specific configuration 35 | conf.add_config(project_root("tests/fixtures/specific.toml")) 36 | assert conf["overridable_configuration"]["key"] == "specific_value" 37 | 38 | # load general configuration again 39 | conf.add_config(project_root("tests/fixtures/general.toml")) 40 | assert conf["overridable_configuration"]["key"] == "general_value" 41 | 42 | 43 | def test_it_ignores_if_asked_to_load_non_existent_file(): 44 | # non-existent file 45 | conf.add_config("this_file_does_not_exists") 46 | # does not affect the configuration 47 | assert conf["devcli"]["key"] == "value" 48 | 49 | 50 | def test_it_accepts_fetch_through_path_str(): 51 | assert conf["devcli.key"] == "value" 52 | assert conf["overridable_configuration.key"] == "general_value" 53 | 54 | 55 | def test_audit_should_list_files_loaded(): 56 | assert conf.audit()[project_root("tests/fixtures/general.toml")] is not None 57 | 58 | 59 | def test_audit_should_be_immutable(): 60 | config_key = project_root("tests/fixtures/general.toml") 61 | audit = conf.audit() 62 | # config is listed in audit 63 | assert audit[config_key] is not None 64 | # dict has value altered 65 | audit[config_key] = None 66 | # fetching audit again does not alter the original dict 67 | assert conf.audit()[config_key] is not None 68 | 69 | 70 | def test_files_returns_files_loaded(): 71 | gen_config = project_root("tests/fixtures/general.toml") 72 | spec_config = project_root("tests/fixtures/specific.toml") 73 | # general was loaded in setup, but specific not 74 | assert gen_config in conf.files() 75 | assert spec_config not in conf.files() 76 | # load specific 77 | conf.add_config(spec_config) 78 | # and now it is listed in files() 79 | assert spec_config in conf.files() 80 | -------------------------------------------------------------------------------- /tests/test_console.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from unittest.mock import patch 4 | import devcli.framework.console as console 5 | 6 | MESSAGE = "Hello, World!" 7 | 8 | 9 | @pytest.fixture 10 | def mock_print(): 11 | with patch("devcli.framework.console.print") as mock: 12 | yield mock 13 | 14 | 15 | def assert_print(mock, color=None): 16 | if color: 17 | mock.assert_called_once_with(f"[{color}]{MESSAGE}[/{color}]") 18 | else: 19 | mock.assert_called_once_with(f"{MESSAGE}") 20 | 21 | 22 | def test_echo(mock_print): 23 | console.echo(MESSAGE) 24 | assert_print(mock_print) 25 | 26 | 27 | def test_info(mock_print): 28 | console.info(MESSAGE) 29 | assert_print(mock_print, color="cyan") 30 | 31 | 32 | def test_warn(mock_print): 33 | console.warn(MESSAGE) 34 | assert_print(mock_print, color="yellow") 35 | 36 | 37 | def test_error(mock_print): 38 | console.error(MESSAGE) 39 | assert_print(mock_print, color="red") 40 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import pytest 5 | from pyfakefs.fake_filesystem import PatchMode 6 | from pyfakefs.fake_filesystem_unittest import Patcher 7 | from typer import Typer 8 | from typer.testing import CliRunner 9 | 10 | from devcli.core import ( 11 | project_root, 12 | traverse_search, 13 | traverse_load_dynamic_commands, 14 | load_dynamic_commands, 15 | ) 16 | 17 | DYN_COMMAND = """ 18 | import devcli.framework as cmd 19 | cli = cmd.new("test") 20 | @cli.command() 21 | def hello(): 22 | pass 23 | """ 24 | 25 | runner = CliRunner() 26 | 27 | 28 | @pytest.fixture 29 | def fs_patch_open(): 30 | with Patcher(patch_open_code=PatchMode.AUTO) as p: 31 | yield p.fs 32 | 33 | 34 | def test_project_root_resolves_to_right_directory(): 35 | assert str(project_root()) == os.path.abspath( 36 | os.path.join(os.path.dirname(__file__), "..") 37 | ) 38 | 39 | 40 | def test_project_root_resolves_to_right_directory_with_filename(): 41 | assert str(project_root(__file__)) == os.path.abspath(__file__) 42 | 43 | 44 | def test_traverse_search_for_file_name(fs): 45 | files = [Path("/org/dept/project/module/file.txt"), Path("/org/file.txt")] 46 | for f in files: 47 | fs.create_file(f) 48 | 49 | assert traverse_search("file.txt", "/org/dept/project/module") == files 50 | 51 | 52 | def test_traverse_search_for_directory(fs): 53 | expected = [ 54 | Path("/org/dept/project/module/.devcli"), 55 | Path("/org/dept/project/.devcli"), 56 | Path("/org/dept/.devcli"), 57 | Path("/org/.devcli"), 58 | Path("/.devcli"), 59 | ] 60 | for d in expected: 61 | fs.create_dir(d) 62 | 63 | assert traverse_search(".devcli", "/org/dept/project/module") == expected 64 | 65 | 66 | def test_load_commands_dynamically(): 67 | app = Typer() 68 | # This should load the example.py subcommand on .devcli 69 | load_dynamic_commands(app, project_root(".devcli")) 70 | # Executed the example command just loaded 71 | result = runner.invoke(app, ["example", "ping"]) 72 | 73 | assert "PONG!" in result.output 74 | 75 | 76 | def test_traverse_load_commands_dynamically(fs_patch_open): 77 | """ 78 | This test uses PatchMode.AUTO from pyfakefs as we load commands 79 | dynamic using module loading which user ``open_code()`` function. 80 | This function is not by default faked by pyfakefs. 81 | 82 | see: . 83 | """ 84 | # the user is current in the following directory 85 | fs_patch_open.create_dir("/org/dept/project/module") 86 | fs_patch_open.cwd = "/org/dept/project/module" 87 | # and two different commands are defined, one at module level and another at project level 88 | fs_patch_open.create_file( 89 | "/org/dept/project/module/.devcli/module.py", contents=DYN_COMMAND 90 | ) 91 | fs_patch_open.create_file( 92 | "/org/dept/project/.devcli/project.py", contents=DYN_COMMAND 93 | ) 94 | 95 | app = Typer() 96 | # we start our search from module directory 97 | traverse_load_dynamic_commands(app, ".devcli", Path.cwd()) 98 | 99 | # both, project and module commands are available to run 100 | result = runner.invoke(app, ["project"]) 101 | assert "Usage: root project" in result.output 102 | result = runner.invoke(app, ["module"]) 103 | assert "Usage: root module" in result.output 104 | -------------------------------------------------------------------------------- /tests/test_error.py: -------------------------------------------------------------------------------- 1 | from devcli.framework.errors import MissConfError 2 | 3 | 4 | def test_miss_conf_error_message(): 5 | error = MissConfError("subcommand", "configuration_key", "example_value") 6 | expected_message = ( 7 | "Missing entry 'configuration_key' on 'subcommand'.\n" 8 | "Ensure you have the following in your configuration:\n\n" 9 | "[subcommand]\n" 10 | "configuration_key = example_value\n" 11 | ) 12 | assert str(error) == expected_message 13 | -------------------------------------------------------------------------------- /tests/test_example.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture(autouse=True) 5 | def setup(devcli_cmd): 6 | global example 7 | example = devcli_cmd("example") 8 | 9 | 10 | def test_ping(): 11 | result = example("ping") 12 | assert result.output == "PONG!\n" 13 | 14 | 15 | def test_hello(): 16 | result = example("hello", "Daniel") 17 | assert result.output == "Hello, Daniel!\n" 18 | 19 | 20 | def test_hello_with_rainbow(): 21 | # it will accept the flag but won't actually use colors 22 | # given the test environment (I think). 23 | result = example("hello", "Marco", "--rainbow") 24 | assert result.output == "Hello, Marco!\n" 25 | 26 | 27 | def test_hello_text_example(): 28 | result = example("text") 29 | expected_output = ( 30 | "This is a cmd.echo(msg)\n" 31 | "This is a cmd.info(msg)\n" 32 | "This is a cmd.warn(msg)\n" 33 | "This is a cmd.error(msg)\n" 34 | ) 35 | assert result.output in expected_output 36 | 37 | 38 | def test_config_example(): 39 | result = example("config") 40 | assert "Default devcli config:" in result.output 41 | -------------------------------------------------------------------------------- /tests/utils/test_one_password.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvaltas/devcli/a5d501e4f993bee98c8d7a5de9b2da70a1482596/tests/utils/test_one_password.py -------------------------------------------------------------------------------- /tests/utils/test_shell.py: -------------------------------------------------------------------------------- 1 | import devcli.utils.shell as shell 2 | 3 | 4 | def _expand(obj: enumerate[str]): 5 | return {index: value for index, value in obj} 6 | 7 | 8 | def test_cmds_as_list_transformed_into_dict(): 9 | cmds = shell.iter_for(["cmd1", "cmd2"]) 10 | assert _expand(enumerate(["cmd1", "cmd2"])) == _expand(cmds) 11 | 12 | 13 | def test_cmds_with_alias(): 14 | cmds_dict = {"cmd1": "run_cmd1", "cmd2": "run_cmd2"} 15 | cmds = shell.iter_for(cmds_dict) 16 | assert cmds == cmds_dict.items() 17 | 18 | 19 | def test_single_command_will_return_itself_as_alias(): 20 | cmds = shell.iter_for("cmd with some arguments") 21 | assert cmds == {"cmd": "cmd with some arguments"}.items() 22 | 23 | 24 | def test_capture_runs_command_return_results(): 25 | result = shell.capture("echo -n $((1 + 1))") 26 | assert result == "2" 27 | 28 | 29 | def test_it_is_possible_to_access_return_code(): 30 | results = shell.run({"cmd1": "exit 126", "cmd2": "exit 255"}) 31 | assert results["cmd1"]["exitcode"] == 126 32 | assert results["cmd2"]["exitcode"] == 255 33 | 34 | 35 | def test_in_a_single_run_is_possible_to_collect_exitcode(): 36 | results = shell.run("exit 111") 37 | assert results["exit"]["exitcode"] == 111 38 | 39 | 40 | def test_in_multi_run_is_possible_to_collect_exitcode(): 41 | results = shell.run(["exit 111", "exit 222"]) 42 | assert results[0]["exitcode"] == 111 43 | assert results[1]["exitcode"] == 222 44 | 45 | 46 | def test_result_transform_dict(): 47 | orig = { 48 | 123: {"alias": "cmd1", "code": "111"}, 49 | 456: {"alias": "cmd2", "code": "222"}, 50 | } 51 | expect = {"cmd1": {"pid": 123, "code": "111"}, "cmd2": {"pid": 456, "code": "222"}} 52 | 53 | assert shell.promote_value_to_key(orig, new_key="alias", new_value="pid") == expect 54 | --------------------------------------------------------------------------------