├── .editorconfig ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CHANGES.rst ├── LICENSE ├── Makefile ├── README.rst ├── pyproject.toml ├── pytest.ini ├── requirements ├── base.in ├── base.txt ├── ci.in ├── ci.txt ├── local.in └── local.txt ├── setup.cfg ├── src └── openai_cli │ ├── __init__.py │ ├── cli.py │ ├── client.py │ ├── config.py │ ├── test_cli.py │ ├── test_client.py │ └── test_config.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | insert_final_newline = true 8 | end_of_line = lf 9 | 10 | [*.{yml,yaml}] 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | # Environment variables to support color support (jaraco/skeleton#66): 7 | # Request colored output from CLI tools supporting it. Different tools 8 | # interpret the value differently. For some, just being set is sufficient. 9 | # For others, it must be a non-zero integer. For yet others, being set 10 | # to a non-empty value is sufficient. 11 | FORCE_COLOR: -106 12 | # MyPy's color enforcement (must be a non-zero number) 13 | MYPY_FORCE_COLOR: -42 14 | # Recognized by the `py` package, dependency of `pytest` (must be "1") 15 | PY_COLORS: 1 16 | # Make tox-wrapped tools see color requests 17 | TOX_TESTENV_PASSENV: >- 18 | FORCE_COLOR 19 | MYPY_FORCE_COLOR 20 | NO_COLOR 21 | PY_COLORS 22 | PYTEST_THEME 23 | PYTEST_THEME_MODE 24 | 25 | # Suppress noisy pip warnings 26 | PIP_DISABLE_PIP_VERSION_CHECK: 'true' 27 | PIP_NO_PYTHON_VERSION_WARNING: 'true' 28 | PIP_NO_WARN_SCRIPT_LOCATION: 'true' 29 | 30 | # Disable the spinner, noise in GHA; TODO(webknjaz): Fix this upstream 31 | # Must be "1". 32 | TOX_PARALLEL_NO_SPINNER: 1 33 | 34 | 35 | jobs: 36 | test: 37 | strategy: 38 | matrix: 39 | python: 40 | - "3.12" 41 | dev: 42 | - -dev 43 | platform: 44 | - ubuntu-latest 45 | - macos-latest 46 | - windows-latest 47 | include: 48 | - python: "3.12" 49 | platform: ubuntu-latest 50 | runs-on: ${{ matrix.platform }} 51 | steps: 52 | - uses: actions/checkout@v3 53 | - name: Setup Python 54 | uses: actions/setup-python@v4 55 | with: 56 | python-version: ${{ matrix.python }}${{ matrix.dev }} 57 | - name: Install dependencies 58 | run: | 59 | python -m pip install -r requirements/local.txt 60 | - name: Run tests 61 | run: 62 | pytest --cov=openai_cli src/openai_cli 63 | 64 | check: # This job does nothing and is only used for the branch protection 65 | if: always() 66 | 67 | needs: 68 | - test 69 | 70 | runs-on: ubuntu-latest 71 | 72 | steps: 73 | - name: Decide whether the needed jobs succeeded or failed 74 | uses: re-actors/alls-green@release/v1 75 | with: 76 | jobs: ${{ toJSON(needs) }} 77 | 78 | release: 79 | needs: 80 | - check 81 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 82 | runs-on: ubuntu-latest 83 | 84 | steps: 85 | - uses: actions/checkout@v3 86 | - name: Setup Python 87 | uses: actions/setup-python@v4 88 | with: 89 | python-version: 3.12 90 | - name: Install tox 91 | run: | 92 | python -m pip install tox 93 | - name: Release 94 | run: tox -e release 95 | env: 96 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 97 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Session.vim 2 | *.swp 3 | *.egg-info/ 4 | __pycache__/ 5 | /build/ 6 | /dist/ 7 | .coverage 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: isort 5 | name: isort 6 | entry: isort 7 | language: system 8 | require_serial: true 9 | types_or: [cython, pyi, python] 10 | args: ['--filter-files'] 11 | 12 | - id: black 13 | name: black 14 | entry: black 15 | language: system 16 | require_serial: true 17 | types_or: [python, pyi] 18 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | python: 3 | install: 4 | - path: . 5 | extra_requirements: 6 | - docs 7 | 8 | # workaround for readthedocs/readthedocs.org#9623 9 | build: 10 | # workaround for readthedocs/readthedocs.org#9635 11 | os: ubuntu-22.04 12 | tools: 13 | python: "3" 14 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | 1.0.0 - Sep 4, 2024 2 | ------------------- 3 | 4 | * Complete rewrite by `Tevfik Kadan`_, `PR #13`_. 5 | 6 | .. _`Tevfik Kadan`: https://github.com/ktevfik 7 | 8 | 0.0.3 - Feb 15, 2023 9 | -------------------- 10 | 11 | * Allow overriding API URL through ``OPENAI_API_URL`` environment variable. 12 | Thanks to `Stefano d'Antonio`_, `Issue #5`, `PR #6`_. 13 | 14 | .. _`Stefano d'Antonio`: https://github.com/UnoSD 15 | .. _`Issue #5`: https://github.com/peterdemin/openai-cli/issues/5 16 | .. _`PR #6`: https://github.com/peterdemin/openai-cli/pull/6 17 | 18 | 0.0.2 - Dec 29, 2022 19 | -------------------- 20 | 21 | * Add command line option -m/--model. Thanks to `Alex Zhuang`_, `PR #1`_. 22 | 23 | .. _`Alex Zhuang`: https://github.com/azhx 24 | .. _`PR #1`: https://github.com/peterdemin/openai-cli/pull/1 25 | 26 | 0.0.1 - Dec 3, 2022 27 | ------------------- 28 | 29 | * Initial release 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Jason R. Coombs 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to 5 | deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | sell copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 19 | IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | PEX := openai 4 | PROJ := openai_cli 5 | PROJ_ROOT := src/$(PROJ) 6 | 7 | define PRINT_HELP_PYSCRIPT 8 | import re, sys 9 | 10 | for line in sys.stdin: 11 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 12 | if match: 13 | target, help = match.groups() 14 | print("%-10s %s" % (target, help)) 15 | endef 16 | export PRINT_HELP_PYSCRIPT 17 | 18 | .PHONY: virtual_env_set 19 | virtual_env_set: 20 | ifndef VIRTUAL_ENV 21 | $(error VIRTUAL_ENV not set) 22 | endif 23 | 24 | .PHONY: help 25 | help: 26 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 27 | 28 | .PHONY: clean 29 | clean: ## remove build artifacts 30 | rm -rf build/ \ 31 | dist/ \ 32 | .eggs/ 33 | rm -f $(PEX) 34 | find . -name '.eggs' -type d -exec rm -rf {} + 35 | find . -name '*.egg-info' -exec rm -rf {} + 36 | find . -name '*.egg' -exec rm -f {} + 37 | find . -name '*.pyc' -exec rm -f {} + 38 | find . -name '*.pyo' -exec rm -f {} + 39 | find . -name '__pycache__' -exec rm -fr {} + 40 | 41 | .PHONY: dist 42 | dist: clean ## builds source and wheel package 43 | python -m build -n 44 | 45 | .PHONY: release 46 | release: dist ## package and upload a release 47 | twine upload dist/* 48 | 49 | $(PEX) pex: 50 | pex . -e $(PROJ).cli:cli --validate-entry-point -o $(PEX) 51 | 52 | .PHONY: lint 53 | lint: ## check style with pylint 54 | pylint $(PROJ_ROOT) 55 | mypy $(PROJ_ROOT) 56 | 57 | .PHONY: test 58 | test: ## run test suite 59 | pytest --cov=$(PROJ) $(PROJ_ROOT) 60 | 61 | .PHONY: install 62 | install: ## install the package with dev dependencies 63 | pip install -e . -r requirements/local.txt 64 | 65 | .PHONY: sync 66 | sync: ## completely sync installed packages with dev dependencies 67 | pip-sync requirements/local.txt 68 | pip install -e . 69 | 70 | .PHONY: lock 71 | lock: ## lock versions of third-party dependencies 72 | pip-compile-multi \ 73 | --allow-unsafe \ 74 | --use-cache \ 75 | --no-upgrade 76 | 77 | .PHONY: upgrade 78 | upgrade: ## upgrade versions of third-party dependencies 79 | pip-compile-multi \ 80 | --allow-unsafe \ 81 | --use-cache 82 | 83 | .PHONY: fmt 84 | fmt: ## Reformat all Python files 85 | isort $(PROJ_ROOT) 86 | black $(PROJ_ROOT) 87 | 88 | ## Skeleton initialization 89 | .PHONY: init 90 | init: virtual_env_set install 91 | pre-commit install 92 | 93 | .PHONY: rename 94 | rename: 95 | @python -c "$$RENAME_PROJECT_PYSCRIPT" 96 | $(MAKE) init 97 | git add -A . 98 | git commit -am "Initialize the project" 99 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | OpenAI command-line client 2 | ========================== 3 | 4 | Installation 5 | ------------ 6 | 7 | To install OpenAI CLI in Python virtual environment, run:: 8 | 9 | pip install openai-cli 10 | 11 | Token authentication 12 | -------------------- 13 | 14 | OpenAI API requires authentication token, which can be obtained on this page: 15 | https://beta.openai.com/account/api-keys 16 | 17 | Provide token to the CLI either through a command-line argument (``-t/--token ``) 18 | or through an environment variable (``OPENAI_API_KEY``). 19 | 20 | Usage 21 | ----- 22 | 23 | Currently only text completion API is supported. 24 | 25 | Example usage:: 26 | 27 | $ echo "Are cats faster than dogs?" | openai complete - 28 | It depends on the breed of the cat and dog. Generally, 29 | cats are faster than dogs over short distances, 30 | but dogs are better at sustained running. 31 | 32 | Interactive mode supported (Press Ctrl+C to exit):: 33 | 34 | $ openai repl 35 | Prompt: Can generative AI replace humans? 36 | 37 | No, generative AI cannot replace humans. 38 | While generative AI can be used to automate certain tasks, 39 | it cannot replace the creativity, intuition, and problem-solving 40 | skills that humans possess. 41 | Generative AI can be used to supplement human efforts, 42 | but it cannot replace them. 43 | 44 | Prompt: ^C 45 | 46 | Run without arguments to get a short help message:: 47 | 48 | $ openai 49 | Usage: openai [OPTIONS] COMMAND [ARGS]... 50 | 51 | Options: 52 | --help Show this message and exit. 53 | 54 | Commands: 55 | complete Return OpenAI completion for a prompt from SOURCE. 56 | repl Start interactive shell session for OpenAI completion API. 57 | 58 | Build a standalone binary using pex and move it into PATH:: 59 | 60 | $ make openai && mv openai ~/bin/ 61 | $ openai repl 62 | Prompt: 63 | 64 | Alternative API URL 65 | ------------------- 66 | 67 | CLI invokes https://api.openai.com/v1/completions by default. 68 | To override the endpoint URL, set ``OPENAI_API_URL`` environment variable. 69 | 70 | Example usage 71 | ------------- 72 | 73 | Here's an example usage scenario, where we first create a Python module 74 | with a Fibonacci function implementation, and then generate a unit test for it: 75 | 76 | .. code:: bash 77 | 78 | $ mkdir examples 79 | $ touch examples/__init__.py 80 | $ echo "Write Python function to calculate Fibonacci numbers" | openai complete - | black - > examples/fib.py 81 | $ (echo 'Write unit tests for this Python module named "fib":\n'; cat examples/fib.py) | openai complete - | black - > examples/test_fib.py 82 | $ pytest -v examples/test_fib.py 83 | ============================== test session starts ============================== 84 | 85 | examples/test_fib.py::TestFibonacci::test_eighth_fibonacci_number PASSED [ 10%] 86 | examples/test_fib.py::TestFibonacci::test_fifth_fibonacci_number PASSED [ 20%] 87 | examples/test_fib.py::TestFibonacci::test_first_fibonacci_number PASSED [ 30%] 88 | examples/test_fib.py::TestFibonacci::test_fourth_fibonacci_number PASSED [ 40%] 89 | examples/test_fib.py::TestFibonacci::test_negative_input PASSED [ 50%] 90 | examples/test_fib.py::TestFibonacci::test_ninth_fibonacci_number PASSED [ 60%] 91 | examples/test_fib.py::TestFibonacci::test_second_fibonacci_number PASSED [ 70%] 92 | examples/test_fib.py::TestFibonacci::test_seventh_fibonacci_number PASSED [ 80%] 93 | examples/test_fib.py::TestFibonacci::test_sixth_fibonacci_number PASSED [ 90%] 94 | examples/test_fib.py::TestFibonacci::test_third_fibonacci_number PASSED [100%] 95 | 96 | =============================== 10 passed in 0.02s ============================== 97 | 98 | $ cat examples/fib.py 99 | 100 | .. code:: python 101 | 102 | def Fibonacci(n): 103 | if n < 0: 104 | print("Incorrect input") 105 | # First Fibonacci number is 0 106 | elif n == 1: 107 | return 0 108 | # Second Fibonacci number is 1 109 | elif n == 2: 110 | return 1 111 | else: 112 | return Fibonacci(n - 1) + Fibonacci(n - 2) 113 | 114 | .. code:: bash 115 | 116 | $ cat examples/test_fib.py 117 | 118 | .. code:: python 119 | 120 | import unittest 121 | from .fib import Fibonacci 122 | 123 | 124 | class TestFibonacci(unittest.TestCase): 125 | def test_negative_input(self): 126 | self.assertEqual(Fibonacci(-1), None) 127 | 128 | def test_first_fibonacci_number(self): 129 | self.assertEqual(Fibonacci(1), 0) 130 | 131 | def test_second_fibonacci_number(self): 132 | self.assertEqual(Fibonacci(2), 1) 133 | 134 | def test_third_fibonacci_number(self): 135 | self.assertEqual(Fibonacci(3), 1) 136 | 137 | def test_fourth_fibonacci_number(self): 138 | self.assertEqual(Fibonacci(4), 2) 139 | 140 | def test_fifth_fibonacci_number(self): 141 | self.assertEqual(Fibonacci(5), 3) 142 | 143 | def test_sixth_fibonacci_number(self): 144 | self.assertEqual(Fibonacci(6), 5) 145 | 146 | def test_seventh_fibonacci_number(self): 147 | self.assertEqual(Fibonacci(7), 8) 148 | 149 | def test_eighth_fibonacci_number(self): 150 | self.assertEqual(Fibonacci(8), 13) 151 | 152 | def test_ninth_fibonacci_number(self): 153 | self.assertEqual(Fibonacci(9), 21) 154 | 155 | 156 | if __name__ == "__main__": 157 | unittest.main() 158 | 159 | .. code:: bash 160 | 161 | $ (echo "Add type annotations for this Python code"; cat examples/fib.py) | openai complete - | black - | tee tmp && mv tmp examples/fib.py 162 | 163 | .. code:: python 164 | 165 | def Fibonacci(n: int) -> int: 166 | if n < 0: 167 | print("Incorrect input") 168 | # First Fibonacci number is 0 169 | elif n == 1: 170 | return 0 171 | # Second Fibonacci number is 1 172 | elif n == 2: 173 | return 1 174 | else: 175 | return Fibonacci(n - 1) + Fibonacci(n - 2) 176 | 177 | .. code:: bash 178 | 179 | $ mypy examples/fib.py 180 | examples/fib.py:1: error: Missing return statement [return] 181 | Found 1 error in 1 file (checked 1 source file) 182 | 183 | .. code:: bash 184 | 185 | $ (echo "Fix mypy warnings in this Python code"; cat examples/fib.py; mypy examples/fib.py) | openai complete - | black - | tee tmp && mv tmp examples/fib.py 186 | 187 | .. code:: python 188 | 189 | def Fibonacci(n: int) -> int: 190 | if n < 0: 191 | print("Incorrect input") 192 | # First Fibonacci number is 0 193 | elif n == 1: 194 | return 0 195 | # Second Fibonacci number is 1 196 | elif n == 2: 197 | return 1 198 | else: 199 | return Fibonacci(n - 1) + Fibonacci(n - 2) 200 | return None # Added return statement 201 | 202 | .. code:: bash 203 | 204 | $ mypy examples/fib.py 205 | examples/fib.py:12: error: Incompatible return value type (got "None", expected "int") [return-value] 206 | Found 1 error in 1 file (checked 1 source file) 207 | 208 | .. code:: bash 209 | 210 | $ (echo "Fix mypy warnings in this Python code"; cat examples/fib.py; mypy examples/fib.py) | openai complete - | black - | tee tmp && mv tmp examples/fib.py 211 | 212 | .. code:: python 213 | 214 | def Fibonacci(n: int) -> int: 215 | if n < 0: 216 | print("Incorrect input") 217 | # First Fibonacci number is 0 218 | elif n == 1: 219 | return 0 220 | # Second Fibonacci number is 1 221 | elif n == 2: 222 | return 1 223 | else: 224 | return Fibonacci(n - 1) + Fibonacci(n - 2) 225 | return 0 # Changed return statement to return 0 226 | 227 | .. code:: bash 228 | 229 | $ mypy examples/fib.py 230 | Success: no issues found in 1 source file 231 | 232 | .. code:: bash 233 | 234 | $ (echo "Rewrite these tests to use pytest.parametrized"; cat examples/test_fib.py) | openai complete - | black - | tee tmp && mv tmp examples/test_fib.py 235 | 236 | .. code:: python 237 | 238 | import pytest 239 | from .fib import Fibonacci 240 | 241 | 242 | @pytest.mark.parametrize( 243 | "n, expected", 244 | [(1, 0), (2, 1), (3, 1), (4, 2), (5, 3), (6, 5), (7, 8), (8, 13), (9, 21), (10, 34)], 245 | ) 246 | def test_fibonacci(n, expected): 247 | assert Fibonacci(n) == expected 248 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=56"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | line-length = 100 7 | target-version = ['py312'] 8 | 9 | [tool.isort] 10 | profile = 'black' 11 | multi_line_output = 3 12 | include_trailing_comma = true 13 | force_grid_wrap = 0 14 | use_parentheses = true 15 | line_length = 100 16 | 17 | [tool.flake8] 18 | max-line-length = 100 19 | max-complexity = 10 20 | 21 | [tool.pylint.main] 22 | # Analyse import fallback blocks. This can be used to support both Python 2 and 3 23 | # compatible code, which means that the block might have code that exists only in 24 | # one or another interpreter, leading to false positives when analysed. 25 | # analyse-fallback-blocks = 26 | 27 | # Always return a 0 (non-error) status code, even if lint errors are found. This 28 | # is primarily useful in continuous integration scripts. 29 | # exit-zero = 30 | 31 | # A comma-separated list of package or module names from where C extensions may 32 | # be loaded. Extensions are loading into the active Python interpreter and may 33 | # run arbitrary code. 34 | extension-pkg-allow-list = "lxml" 35 | 36 | # A comma-separated list of package or module names from where C extensions may 37 | # be loaded. Extensions are loading into the active Python interpreter and may 38 | # run arbitrary code. (This is an alternative name to extension-pkg-allow-list 39 | # for backward compatibility.) 40 | # extension-pkg-whitelist = 41 | 42 | # Return non-zero exit code if any of these messages/categories are detected, 43 | # even if score is above --fail-under value. Syntax same as enable. Messages 44 | # specified are enabled, while categories only check already-enabled messages. 45 | # fail-on = 46 | 47 | # Specify a score threshold under which the program will exit with error. 48 | fail-under = 10 49 | 50 | # Interpret the stdin as a python script, whose filename needs to be passed as 51 | # the module_or_package argument. 52 | # from-stdin = 53 | 54 | # Files or directories to be skipped. They should be base names, not paths. 55 | ignore = ["CVS"] 56 | 57 | # Add files or directories matching the regular expressions patterns to the 58 | # ignore-list. The regex matches against paths and can be in Posix or Windows 59 | # format. Because '\' represents the directory delimiter on Windows systems, it 60 | # can't be used as an escape character. 61 | # ignore-paths = 62 | 63 | # Files or directories matching the regular expression patterns are skipped. The 64 | # regex matches against base names, not paths. The default value ignores Emacs 65 | # file locks 66 | ignore-patterns = ["^\\.#"] 67 | 68 | # List of module names for which member attributes should not be checked (useful 69 | # for modules/projects where namespaces are manipulated during runtime and thus 70 | # existing member attributes cannot be deduced by static analysis). It supports 71 | # qualified module names, as well as Unix pattern matching. 72 | # ignored-modules = 73 | 74 | # Python code to execute, usually for sys.path manipulation such as 75 | # pygtk.require(). 76 | # init-hook = 77 | 78 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 79 | # number of processors available to use, and will cap the count on Windows to 80 | # avoid hangs. 81 | jobs = 1 82 | 83 | # Control the amount of potential inferred values when inferring a single object. 84 | # This can help the performance when dealing with large functions or complex, 85 | # nested conditions. 86 | limit-inference-results = 100 87 | 88 | # List of plugins (as comma separated values of python module names) to load, 89 | # usually to register additional checkers. 90 | # load-plugins = 91 | 92 | # Pickle collected data for later comparisons. 93 | persistent = true 94 | 95 | # Minimum Python version to use for version dependent checks. Will default to the 96 | # version used to run pylint. 97 | py-version = "3.8" 98 | 99 | # Discover python modules and packages in the file system subtree. 100 | # recursive = 101 | 102 | # When enabled, pylint would attempt to guess common misconfiguration and emit 103 | # user-friendly hints instead of false-positive error messages. 104 | suggestion-mode = true 105 | 106 | # Allow loading of arbitrary C extensions. Extensions are imported into the 107 | # active Python interpreter and may run arbitrary code. 108 | # unsafe-load-any-extension = 109 | 110 | [tool.pylint.basic] 111 | # Naming style matching correct argument names. 112 | argument-naming-style = "snake_case" 113 | 114 | # Regular expression matching correct argument names. Overrides argument-naming- 115 | # style. If left empty, argument names will be checked with the set naming style. 116 | # argument-rgx = 117 | 118 | # Naming style matching correct attribute names. 119 | attr-naming-style = "snake_case" 120 | 121 | # Regular expression matching correct attribute names. Overrides attr-naming- 122 | # style. If left empty, attribute names will be checked with the set naming 123 | # style. 124 | # attr-rgx = 125 | 126 | # Bad variable names which should always be refused, separated by a comma. 127 | bad-names = ["foo", "bar", "baz", "toto", "tutu", "tata"] 128 | 129 | # Bad variable names regexes, separated by a comma. If names match any regex, 130 | # they will always be refused 131 | # bad-names-rgxs = 132 | 133 | # Naming style matching correct class attribute names. 134 | class-attribute-naming-style = "any" 135 | 136 | # Regular expression matching correct class attribute names. Overrides class- 137 | # attribute-naming-style. If left empty, class attribute names will be checked 138 | # with the set naming style. 139 | # class-attribute-rgx = 140 | 141 | # Naming style matching correct class constant names. 142 | class-const-naming-style = "UPPER_CASE" 143 | 144 | # Regular expression matching correct class constant names. Overrides class- 145 | # const-naming-style. If left empty, class constant names will be checked with 146 | # the set naming style. 147 | # class-const-rgx = 148 | 149 | # Naming style matching correct class names. 150 | class-naming-style = "PascalCase" 151 | 152 | # Regular expression matching correct class names. Overrides class-naming-style. 153 | # If left empty, class names will be checked with the set naming style. 154 | # class-rgx = 155 | 156 | # Naming style matching correct constant names. 157 | const-naming-style = "UPPER_CASE" 158 | 159 | # Regular expression matching correct constant names. Overrides const-naming- 160 | # style. If left empty, constant names will be checked with the set naming style. 161 | # const-rgx = 162 | 163 | # Minimum line length for functions/classes that require docstrings, shorter ones 164 | # are exempt. 165 | docstring-min-length = -1 166 | 167 | # Naming style matching correct function names. 168 | function-naming-style = "snake_case" 169 | 170 | # Regular expression matching correct function names. Overrides function-naming- 171 | # style. If left empty, function names will be checked with the set naming style. 172 | # function-rgx = 173 | 174 | # Good variable names which should always be accepted, separated by a comma. 175 | good-names = ["i", "j", "k", "ex", "Run", "_"] 176 | 177 | # Good variable names regexes, separated by a comma. If names match any regex, 178 | # they will always be accepted 179 | # good-names-rgxs = 180 | 181 | # Include a hint for the correct naming format with invalid-name. 182 | # include-naming-hint = 183 | 184 | # Naming style matching correct inline iteration names. 185 | inlinevar-naming-style = "any" 186 | 187 | # Regular expression matching correct inline iteration names. Overrides 188 | # inlinevar-naming-style. If left empty, inline iteration names will be checked 189 | # with the set naming style. 190 | # inlinevar-rgx = 191 | 192 | # Naming style matching correct method names. 193 | method-naming-style = "snake_case" 194 | 195 | # Regular expression matching correct method names. Overrides method-naming- 196 | # style. If left empty, method names will be checked with the set naming style. 197 | # method-rgx = 198 | 199 | # Naming style matching correct module names. 200 | module-naming-style = "snake_case" 201 | 202 | # Regular expression matching correct module names. Overrides module-naming- 203 | # style. If left empty, module names will be checked with the set naming style. 204 | # module-rgx = 205 | 206 | # Colon-delimited sets of names that determine each other's naming style when the 207 | # name regexes allow several styles. 208 | # name-group = 209 | 210 | # Regular expression which should only match function or class names that do not 211 | # require a docstring. 212 | no-docstring-rgx = "^_" 213 | 214 | # List of decorators that produce properties, such as abc.abstractproperty. Add 215 | # to this list to register other decorators that produce valid properties. These 216 | # decorators are taken in consideration only for invalid-name. 217 | property-classes = ["abc.abstractproperty"] 218 | 219 | # Regular expression matching correct type variable names. If left empty, type 220 | # variable names will be checked with the set naming style. 221 | # typevar-rgx = 222 | 223 | # Naming style matching correct variable names. 224 | variable-naming-style = "snake_case" 225 | 226 | # Regular expression matching correct variable names. Overrides variable-naming- 227 | # style. If left empty, variable names will be checked with the set naming style. 228 | # variable-rgx = 229 | 230 | [tool.pylint.classes] 231 | # Warn about protected attribute access inside special methods 232 | # check-protected-access-in-special-methods = 233 | 234 | # List of method names used to declare (i.e. assign) instance attributes. 235 | defining-attr-methods = ["__init__", "__new__", "setUp", "__post_init__"] 236 | 237 | # List of member names, which should be excluded from the protected access 238 | # warning. 239 | exclude-protected = ["_asdict", "_fields", "_replace", "_source", "_make"] 240 | 241 | # List of valid names for the first argument in a class method. 242 | valid-classmethod-first-arg = ["cls"] 243 | 244 | # List of valid names for the first argument in a metaclass class method. 245 | valid-metaclass-classmethod-first-arg = ["cls"] 246 | 247 | [tool.pylint.design] 248 | # List of regular expressions of class ancestor names to ignore when counting 249 | # public methods (see R0903) 250 | # exclude-too-few-public-methods = 251 | 252 | # List of qualified class names to ignore when counting class parents (see R0901) 253 | # ignored-parents = 254 | 255 | # Maximum number of arguments for function / method. 256 | max-args = 5 257 | 258 | # Maximum number of attributes for a class (see R0902). 259 | max-attributes = 7 260 | 261 | # Maximum number of boolean expressions in an if statement (see R0916). 262 | max-bool-expr = 5 263 | 264 | # Maximum number of branch for function / method body. 265 | max-branches = 12 266 | 267 | # Maximum number of locals for function / method body. 268 | max-locals = 15 269 | 270 | # Maximum number of parents for a class (see R0901). 271 | max-parents = 7 272 | 273 | # Maximum number of public methods for a class (see R0904). 274 | max-public-methods = 20 275 | 276 | # Maximum number of return / yield for function / method body. 277 | max-returns = 6 278 | 279 | # Maximum number of statements in function / method body. 280 | max-statements = 50 281 | 282 | # Minimum number of public methods for a class (see R0903). 283 | min-public-methods = 2 284 | 285 | [tool.pylint.exceptions] 286 | # Exceptions that will emit a warning when caught. 287 | overgeneral-exceptions = ["BaseException", "Exception"] 288 | 289 | [tool.pylint.format] 290 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 291 | # expected-line-ending-format = 292 | 293 | # Regexp for a line that is allowed to be longer than the limit. 294 | ignore-long-lines = "^\\s*(# )??$" 295 | 296 | # Number of spaces of indent required inside a hanging or continued line. 297 | indent-after-paren = 4 298 | 299 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 300 | # tab). 301 | indent-string = " " 302 | 303 | # Maximum number of characters on a single line. 304 | max-line-length = 100 305 | 306 | # Maximum number of lines in a module. 307 | max-module-lines = 1000 308 | 309 | # Allow the body of a class to be on the same line as the declaration if body 310 | # contains single statement. 311 | # single-line-class-stmt = 312 | 313 | # Allow the body of an if to be on the same line as the test if there is no else. 314 | # single-line-if-stmt = 315 | 316 | [tool.pylint.imports] 317 | # List of modules that can be imported at any level, not just the top level one. 318 | # allow-any-import-level = 319 | 320 | # Allow wildcard imports from modules that define __all__. 321 | # allow-wildcard-with-all = 322 | 323 | # Deprecated modules which should not be used, separated by a comma. 324 | # deprecated-modules = 325 | 326 | # Output a graph (.gv or any supported image format) of external dependencies to 327 | # the given file (report RP0402 must not be disabled). 328 | # ext-import-graph = 329 | 330 | # Output a graph (.gv or any supported image format) of all (i.e. internal and 331 | # external) dependencies to the given file (report RP0402 must not be disabled). 332 | # import-graph = 333 | 334 | # Output a graph (.gv or any supported image format) of internal dependencies to 335 | # the given file (report RP0402 must not be disabled). 336 | # int-import-graph = 337 | 338 | # Force import order to recognize a module as part of the standard compatibility 339 | # libraries. 340 | # known-standard-library = 341 | 342 | # Force import order to recognize a module as part of a third party library. 343 | known-third-party = ["enchant"] 344 | 345 | # Couples of modules and preferred modules, separated by a comma. 346 | # preferred-modules = 347 | 348 | [tool.pylint.logging] 349 | # The type of string formatting that logging methods do. `old` means using % 350 | # formatting, `new` is for `{}` formatting. 351 | logging-format-style = "old" 352 | 353 | # Logging modules to check that the string format arguments are in logging 354 | # function parameter format. 355 | logging-modules = ["logging"] 356 | 357 | [tool.pylint."messages control"] 358 | # Only show warnings with the listed confidence levels. Leave empty to show all. 359 | # Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, UNDEFINED. 360 | confidence = ["HIGH", "CONTROL_FLOW", "INFERENCE", "INFERENCE_FAILURE", "UNDEFINED"] 361 | 362 | # Disable the message, report, category or checker with the given id(s). You can 363 | # either give multiple identifiers separated by comma (,) or put this option 364 | # multiple times (only on the command line, not in the configuration file where 365 | # it should appear only once). You can also use "--disable=all" to disable 366 | # everything first and then re-enable specific checks. For example, if you want 367 | # to run only the similarities checker, you can use "--disable=all 368 | # --enable=similarities". If you want to run only the classes checker, but have 369 | # no Warning level messages displayed, use "--disable=all --enable=classes 370 | # --disable=W". 371 | disable = ["raw-checker-failed", "bad-inline-option", "locally-disabled", "file-ignored", "suppressed-message", "useless-suppression", "deprecated-pragma", "use-symbolic-message-instead", "missing-function-docstring", "missing-module-docstring", "missing-class-docstring", "too-few-public-methods", "fixme", "duplicate-code"] 372 | 373 | # Enable the message, report, category or checker with the given id(s). You can 374 | # either give multiple identifier separated by comma (,) or put this option 375 | # multiple time (only on the command line, not in the configuration file where it 376 | # should appear only once). See also the "--disable" option for examples. 377 | enable = ["c-extension-no-member"] 378 | 379 | [tool.pylint.method_args] 380 | # List of qualified names (i.e., library.method) which require a timeout 381 | # parameter e.g. 'requests.api.get,requests.api.post' 382 | timeout-methods = ["requests.api.delete", "requests.api.get", "requests.api.head", "requests.api.options", "requests.api.patch", "requests.api.post", "requests.api.put", "requests.api.request"] 383 | 384 | [tool.pylint.miscellaneous] 385 | # List of note tags to take in consideration, separated by a comma. 386 | notes = ["FIXME", "XXX", "TODO"] 387 | 388 | # Regular expression of note tags to take in consideration. 389 | # notes-rgx = 390 | 391 | [tool.pylint.refactoring] 392 | # Maximum number of nested blocks for function / method body 393 | max-nested-blocks = 5 394 | 395 | # Complete name of functions that never returns. When checking for inconsistent- 396 | # return-statements if a never returning function is called then it will be 397 | # considered as an explicit return statement and no message will be printed. 398 | never-returning-functions = ["sys.exit", "argparse.parse_error"] 399 | 400 | [tool.pylint.reports] 401 | # Python expression which should return a score less than or equal to 10. You 402 | # have access to the variables 'fatal', 'error', 'warning', 'refactor', 403 | # 'convention', and 'info' which contain the number of messages in each category, 404 | # as well as 'statement' which is the total number of statements analyzed. This 405 | # score is used by the global evaluation report (RP0004). 406 | evaluation = "max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))" 407 | 408 | # Template used to display messages. This is a python new-style format string 409 | # used to format the message information. See doc for all details. 410 | # msg-template = 411 | 412 | # Set the output format. Available formats are text, parseable, colorized, json 413 | # and msvs (visual studio). You can also give a reporter class, e.g. 414 | # mypackage.mymodule.MyReporterClass. 415 | # output-format = 416 | 417 | # Tells whether to display a full report or only the messages. 418 | # reports = 419 | 420 | # Activate the evaluation score. 421 | score = true 422 | 423 | [tool.pylint.similarities] 424 | # Comments are removed from the similarity computation 425 | ignore-comments = true 426 | 427 | # Docstrings are removed from the similarity computation 428 | ignore-docstrings = true 429 | 430 | # Imports are removed from the similarity computation 431 | ignore-imports = true 432 | 433 | # Signatures are removed from the similarity computation 434 | ignore-signatures = true 435 | 436 | # Minimum lines number of a similarity. 437 | min-similarity-lines = 4 438 | 439 | [tool.pylint.spelling] 440 | # Limits count of emitted suggestions for spelling mistakes. 441 | max-spelling-suggestions = 4 442 | 443 | # Spelling dictionary name. Available dictionaries: none. To make it work, 444 | # install the 'python-enchant' package. 445 | # spelling-dict = 446 | 447 | # List of comma separated words that should be considered directives if they 448 | # appear at the beginning of a comment and should not be checked. 449 | spelling-ignore-comment-directives = "fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:" 450 | 451 | # List of comma separated words that should not be checked. 452 | # spelling-ignore-words = 453 | 454 | # A path to a file that contains the private dictionary; one word per line. 455 | # spelling-private-dict-file = 456 | 457 | # Tells whether to store unknown words to the private dictionary (see the 458 | # --spelling-private-dict-file option) instead of raising a message. 459 | # spelling-store-unknown-words = 460 | 461 | [tool.pylint.string] 462 | # This flag controls whether inconsistent-quotes generates a warning when the 463 | # character used as a quote delimiter is used inconsistently within a module. 464 | # check-quote-consistency = 465 | 466 | # This flag controls whether the implicit-str-concat should generate a warning on 467 | # implicit string concatenation in sequences defined over several lines. 468 | # check-str-concat-over-line-jumps = 469 | 470 | [tool.pylint.typecheck] 471 | # List of decorators that produce context managers, such as 472 | # contextlib.contextmanager. Add to this list to register other decorators that 473 | # produce valid context managers. 474 | contextmanager-decorators = ["contextlib.contextmanager"] 475 | 476 | # List of members which are set dynamically and missed by pylint inference 477 | # system, and so shouldn't trigger E1101 when accessed. Python regular 478 | # expressions are accepted. 479 | # generated-members = 480 | 481 | # Tells whether missing members accessed in mixin class should be ignored. A 482 | # class is considered mixin if its name matches the mixin-class-rgx option. 483 | # Tells whether to warn about missing members when the owner of the attribute is 484 | # inferred to be None. 485 | ignore-none = true 486 | 487 | # This flag controls whether pylint should warn about no-member and similar 488 | # checks whenever an opaque object is returned when inferring. The inference can 489 | # return multiple potential results while evaluating a Python object, but some 490 | # branches might not be evaluated, which results in partial inference. In that 491 | # case, it might be useful to still emit no-member and other checks for the rest 492 | # of the inferred objects. 493 | ignore-on-opaque-inference = true 494 | 495 | # List of symbolic message names to ignore for Mixin members. 496 | ignored-checks-for-mixins = ["no-member", "not-async-context-manager", "not-context-manager", "attribute-defined-outside-init"] 497 | 498 | # List of class names for which member attributes should not be checked (useful 499 | # for classes with dynamically set attributes). This supports the use of 500 | # qualified names. 501 | ignored-classes = ["optparse.Values", "thread._local", "_thread._local", "argparse.Namespace"] 502 | 503 | # Show a hint with possible names when a member name was not found. The aspect of 504 | # finding the hint is based on edit distance. 505 | missing-member-hint = true 506 | 507 | # The minimum edit distance a name should have in order to be considered a 508 | # similar match for a missing member name. 509 | missing-member-hint-distance = 1 510 | 511 | # The total number of similar names that should be taken in consideration when 512 | # showing a hint for a missing member. 513 | missing-member-max-choices = 1 514 | 515 | # Regex pattern to define which classes are considered mixins. 516 | mixin-class-rgx = ".*[Mm]ixin" 517 | 518 | # List of decorators that change the signature of a decorated function. 519 | # signature-mutators = 520 | 521 | [tool.pylint.variables] 522 | # List of additional names supposed to be defined in builtins. Remember that you 523 | # should avoid defining new builtins when possible. 524 | # additional-builtins = 525 | 526 | # Tells whether unused global variables should be treated as a violation. 527 | allow-global-unused-variables = true 528 | 529 | # List of names allowed to shadow builtins 530 | # allowed-redefined-builtins = 531 | 532 | # List of strings which can identify a callback function by name. A callback name 533 | # must start or end with one of those strings. 534 | callbacks = ["cb_", "_cb"] 535 | 536 | # A regular expression matching the name of dummy variables (i.e. expected to not 537 | # be used). 538 | dummy-variables-rgx = "_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_" 539 | 540 | # Argument names that match this expression will be ignored. 541 | ignored-argument-names = "_.*|^ignored_|^unused_" 542 | 543 | # Tells whether we should check for unused import in __init__ files. 544 | # init-import = 545 | 546 | # List of qualified module names which can have objects that can redefine 547 | # builtins. 548 | redefining-builtins-modules = ["six.moves", "past.builtins", "future.builtins", "builtins", "io"] 549 | 550 | 551 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs=dist build .tox .eggs 3 | addopts=--doctest-modules 4 | doctest_optionflags=ALLOW_UNICODE ELLIPSIS 5 | filterwarnings= 6 | # Suppress deprecation warning in flake8 7 | ignore:SelectableGroups dict interface is deprecated::flake8 8 | 9 | # shopkeep/pytest-black#55 10 | ignore: is not using a cooperative constructor:pytest.PytestDeprecationWarning 11 | ignore:The \(fspath. py.path.local\) argument to BlackItem is deprecated.:pytest.PytestDeprecationWarning 12 | ignore:BlackItem is an Item subclass and should not be a collector:pytest.PytestWarning 13 | 14 | # tholo/pytest-flake8#83 15 | ignore: is not using a cooperative constructor:pytest.PytestDeprecationWarning 16 | ignore:The \(fspath. py.path.local\) argument to Flake8Item is deprecated.:pytest.PytestDeprecationWarning 17 | ignore:Flake8Item is an Item subclass and should not be a collector:pytest.PytestWarning 18 | -------------------------------------------------------------------------------- /requirements/base.in: -------------------------------------------------------------------------------- 1 | requests 2 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | # SHA1:54faec366d11efdac0f9d2da560e273f92288c2a 2 | # 3 | # This file is autogenerated by pip-compile-multi 4 | # To update, run: 5 | # 6 | # pip-compile-multi 7 | # 8 | certifi==2025.1.31 9 | # via requests 10 | charset-normalizer==3.4.1 11 | # via requests 12 | idna==3.10 13 | # via requests 14 | requests==2.32.3 15 | # via -r requirements/base.in 16 | urllib3==2.3.0 17 | # via requests 18 | -------------------------------------------------------------------------------- /requirements/ci.in: -------------------------------------------------------------------------------- 1 | -r base.in 2 | 3 | pytest 4 | pytest-checkdocs 5 | pytest-cov 6 | 7 | flake8 8 | Flake8-pyproject 9 | 10 | mypy 11 | pylint 12 | types-requests 13 | -------------------------------------------------------------------------------- /requirements/ci.txt: -------------------------------------------------------------------------------- 1 | # SHA1:c9e539596bc2fbcffc9073aa78e4a7d1bb185e34 2 | # 3 | # This file is autogenerated by pip-compile-multi 4 | # To update, run: 5 | # 6 | # pip-compile-multi 7 | # 8 | -r base.txt 9 | alabaster==1.0.0 10 | # via sphinx 11 | astroid==3.3.8 12 | # via pylint 13 | babel==2.17.0 14 | # via sphinx 15 | build[virtualenv]==1.2.2.post1 16 | # via jaraco-packaging 17 | coverage[toml]==7.6.12 18 | # via pytest-cov 19 | dill==0.3.9 20 | # via pylint 21 | distlib==0.3.9 22 | # via virtualenv 23 | docutils==0.21.2 24 | # via 25 | # pytest-checkdocs 26 | # sphinx 27 | domdf-python-tools==3.9.0 28 | # via jaraco-packaging 29 | filelock==3.17.0 30 | # via virtualenv 31 | flake8==7.1.1 32 | # via 33 | # -r requirements/ci.in 34 | # flake8-pyproject 35 | flake8-pyproject==1.2.3 36 | # via -r requirements/ci.in 37 | imagesize==1.4.1 38 | # via sphinx 39 | iniconfig==2.0.0 40 | # via pytest 41 | isort==6.0.0 42 | # via pylint 43 | jaraco-context==6.0.1 44 | # via jaraco-packaging 45 | jaraco-packaging==10.2.3 46 | # via pytest-checkdocs 47 | jinja2==3.1.5 48 | # via sphinx 49 | markupsafe==3.0.2 50 | # via jinja2 51 | mccabe==0.7.0 52 | # via 53 | # flake8 54 | # pylint 55 | mypy==1.15.0 56 | # via -r requirements/ci.in 57 | mypy-extensions==1.0.0 58 | # via mypy 59 | natsort==8.4.0 60 | # via domdf-python-tools 61 | packaging==24.2 62 | # via 63 | # build 64 | # pytest 65 | # sphinx 66 | platformdirs==4.3.6 67 | # via 68 | # pylint 69 | # virtualenv 70 | pluggy==1.5.0 71 | # via pytest 72 | pycodestyle==2.12.1 73 | # via flake8 74 | pyflakes==3.2.0 75 | # via flake8 76 | pygments==2.19.1 77 | # via sphinx 78 | pylint==3.3.4 79 | # via -r requirements/ci.in 80 | pyproject-hooks==1.2.0 81 | # via build 82 | pytest==8.3.4 83 | # via 84 | # -r requirements/ci.in 85 | # pytest-cov 86 | pytest-checkdocs==2.13.0 87 | # via -r requirements/ci.in 88 | pytest-cov==6.0.0 89 | # via -r requirements/ci.in 90 | snowballstemmer==2.2.0 91 | # via sphinx 92 | sphinx==8.1.3 93 | # via jaraco-packaging 94 | sphinxcontrib-applehelp==2.0.0 95 | # via sphinx 96 | sphinxcontrib-devhelp==2.0.0 97 | # via sphinx 98 | sphinxcontrib-htmlhelp==2.1.0 99 | # via sphinx 100 | sphinxcontrib-jsmath==1.0.1 101 | # via sphinx 102 | sphinxcontrib-qthelp==2.0.0 103 | # via sphinx 104 | sphinxcontrib-serializinghtml==2.0.0 105 | # via sphinx 106 | tomlkit==0.13.2 107 | # via pylint 108 | types-requests==2.32.0.20241016 109 | # via -r requirements/ci.in 110 | typing-extensions==4.12.2 111 | # via 112 | # domdf-python-tools 113 | # mypy 114 | virtualenv==20.29.2 115 | # via build 116 | -------------------------------------------------------------------------------- /requirements/local.in: -------------------------------------------------------------------------------- 1 | -r ci.in 2 | 3 | # fmt 4 | isort 5 | black 6 | pre-commit 7 | 8 | # packages 9 | pip-compile-multi 10 | # twine 11 | pex 12 | 13 | # debugging 14 | ipython 15 | ipdb 16 | -------------------------------------------------------------------------------- /requirements/local.txt: -------------------------------------------------------------------------------- 1 | # SHA1:344cade73658f99aab1dd0104334b3d1061922ab 2 | # 3 | # This file is autogenerated by pip-compile-multi 4 | # To update, run: 5 | # 6 | # pip-compile-multi 7 | # 8 | -r ci.txt 9 | asttokens==3.0.0 10 | # via stack-data 11 | black==25.1.0 12 | # via -r requirements/local.in 13 | cfgv==3.4.0 14 | # via pre-commit 15 | click==8.1.8 16 | # via 17 | # black 18 | # pip-compile-multi 19 | # pip-tools 20 | decorator==5.1.1 21 | # via 22 | # ipdb 23 | # ipython 24 | executing==2.2.0 25 | # via stack-data 26 | identify==2.6.7 27 | # via pre-commit 28 | ipdb==0.13.13 29 | # via -r requirements/local.in 30 | ipython==8.32.0 31 | # via 32 | # -r requirements/local.in 33 | # ipdb 34 | jedi==0.19.2 35 | # via ipython 36 | matplotlib-inline==0.1.7 37 | # via ipython 38 | nodeenv==1.9.1 39 | # via pre-commit 40 | parso==0.8.4 41 | # via jedi 42 | pathspec==0.12.1 43 | # via black 44 | pex==2.33.0 45 | # via -r requirements/local.in 46 | pexpect==4.9.0 47 | # via ipython 48 | pip-compile-multi==2.7.1 49 | # via -r requirements/local.in 50 | pip-tools==7.4.1 51 | # via pip-compile-multi 52 | pre-commit==4.1.0 53 | # via -r requirements/local.in 54 | prompt-toolkit==3.0.50 55 | # via ipython 56 | ptyprocess==0.7.0 57 | # via pexpect 58 | pure-eval==0.2.3 59 | # via stack-data 60 | pyyaml==6.0.2 61 | # via pre-commit 62 | stack-data==0.6.3 63 | # via ipython 64 | toposort==1.10 65 | # via pip-compile-multi 66 | traitlets==5.14.3 67 | # via 68 | # ipython 69 | # matplotlib-inline 70 | wcwidth==0.2.13 71 | # via prompt-toolkit 72 | wheel==0.45.1 73 | # via pip-tools 74 | 75 | # The following packages are considered to be unsafe in a requirements file: 76 | pip==25.0.1 77 | # via pip-tools 78 | setuptools==75.8.0 79 | # via pip-tools 80 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = openai-cli 3 | version = 1.0.1 4 | author = Peter Demin 5 | author_email = peterdemin@gmail.com 6 | description = Command-line client for OpenAI API 7 | long_description = file:README.rst 8 | url = https://github.com/peterdemin/openai-cli 9 | classifiers = 10 | Development Status :: 4 - Beta 11 | Intended Audience :: Developers 12 | License :: OSI Approved :: MIT License 13 | Programming Language :: Python :: 3 14 | Programming Language :: Python :: 3 :: Only 15 | 16 | [options] 17 | packages = find: 18 | include_package_data = true 19 | python_requires = >=3.12 20 | install_requires = 21 | requests 22 | click 23 | 24 | [options.packages.find] 25 | where=src 26 | 27 | [options.entry_points] 28 | console_scripts = 29 | openai = openai_cli.cli:cli 30 | 31 | [bdist_wheel] 32 | universal = 1 33 | -------------------------------------------------------------------------------- /src/openai_cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterdemin/openai-cli/458d44b9f213b34a86a2a17dc703e90ef98439e1/src/openai_cli/__init__.py -------------------------------------------------------------------------------- /src/openai_cli/cli.py: -------------------------------------------------------------------------------- 1 | import io 2 | from typing import Optional 3 | 4 | import click 5 | 6 | from openai_cli.client import OpenAIError, generate_response 7 | from openai_cli.config import DEFAULT_MODEL, MAX_TOKENS, TEMPERATURE, set_openai_api_key 8 | 9 | 10 | @click.group() 11 | @click.option( 12 | "-m", "--model", default=DEFAULT_MODEL, help=f"OpenAI model option. (default: {DEFAULT_MODEL})" 13 | ) 14 | @click.option( 15 | "-k", 16 | "--max-tokens", 17 | type=int, 18 | default=MAX_TOKENS, 19 | help=f"Maximum number of tokens in the response. (default: {MAX_TOKENS})", 20 | ) 21 | @click.option( 22 | "-p", 23 | "--temperature", 24 | type=float, 25 | default=TEMPERATURE, 26 | help=f"Temperature for response generation. (default: {TEMPERATURE})", 27 | ) 28 | @click.option("-t", "--token", help="OpenAI API token") 29 | @click.pass_context 30 | def cli(ctx, model: str, max_tokens: int, temperature: float, token: Optional[str]): 31 | """CLI for interacting with OpenAI's completion API.""" 32 | ctx.ensure_object(dict) 33 | ctx.obj["model"] = model 34 | ctx.obj["max_tokens"] = max_tokens 35 | ctx.obj["temperature"] = temperature 36 | ctx.obj["conversation_history"] = [] 37 | if token: 38 | set_openai_api_key(token) 39 | 40 | 41 | @cli.command() 42 | @click.argument("source", type=click.File("rt", encoding="utf-8")) 43 | @click.pass_context 44 | def complete(ctx, source: io.TextIOWrapper) -> None: 45 | """Return OpenAI completion for a prompt from SOURCE.""" 46 | prompt = source.read() 47 | try: 48 | result = generate_response( 49 | prompt, 50 | conversation_history=ctx.obj["conversation_history"], 51 | model=ctx.obj["model"], 52 | max_tokens=ctx.obj["max_tokens"], 53 | temperature=ctx.obj["temperature"], 54 | ) 55 | click.echo(result) 56 | ctx.obj["conversation_history"].extend( 57 | [{"role": "user", "content": prompt}, {"role": "assistant", "content": result}] 58 | ) 59 | except OpenAIError as e: 60 | click.echo(f"An error occurred: {str(e)}", err=True) 61 | 62 | 63 | @cli.command() 64 | @click.pass_context 65 | def repl(ctx) -> None: 66 | """Start interactive shell session for OpenAI completion API.""" 67 | click.echo(f"Interactive shell started. Using model: {ctx.obj['model']}") 68 | click.echo(f"Max tokens: {ctx.obj['max_tokens']}, Temperature: {ctx.obj['temperature']}") 69 | click.echo("Type 'exit' or use Ctrl-D to exit.") 70 | 71 | while True: 72 | try: 73 | prompt = click.prompt("Prompt", type=str) 74 | if prompt.lower() == "exit": 75 | break 76 | result = generate_response( 77 | prompt, 78 | conversation_history=ctx.obj["conversation_history"], 79 | model=ctx.obj["model"], 80 | max_tokens=ctx.obj["max_tokens"], 81 | temperature=ctx.obj["temperature"], 82 | ) 83 | click.echo(f"\nResponse:\n{result}\n") 84 | ctx.obj["conversation_history"].extend( 85 | [{"role": "user", "content": prompt}, {"role": "assistant", "content": result}] 86 | ) 87 | except click.exceptions.Abort: 88 | break 89 | except OpenAIError as e: 90 | click.echo(f"An error occurred: {str(e)}", err=True) 91 | 92 | click.echo("Interactive shell ended.") 93 | 94 | 95 | if __name__ == "__main__": 96 | cli() 97 | -------------------------------------------------------------------------------- /src/openai_cli/client.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, Dict, List, Optional 3 | 4 | import requests 5 | 6 | from .config import ( 7 | DEFAULT_MODEL, 8 | MAX_TOKENS, 9 | SYSTEM_MESSAGE, 10 | TEMPERATURE, 11 | get_openai_api_key, 12 | get_openai_api_url, 13 | ) 14 | 15 | 16 | class OpenAIError(Exception): 17 | pass 18 | 19 | 20 | def initialize_session() -> requests.Session: 21 | """ 22 | Initialize a requests Session with the API key from the environment. 23 | 24 | Returns: 25 | requests.Session: Initialized session with API key in headers. 26 | 27 | Raises: 28 | OpenAIError: If no API key is found in the environment. 29 | """ 30 | api_key = get_openai_api_key() 31 | if not api_key: 32 | raise OpenAIError("The API key must be set in the OPENAI_API_KEY environment variable") 33 | 34 | session = requests.Session() 35 | session.headers.update( 36 | {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} 37 | ) 38 | return session 39 | 40 | 41 | def generate_response( 42 | prompt: str, 43 | conversation_history: Optional[List[Dict[str, str]]] = None, 44 | model: str = DEFAULT_MODEL, 45 | max_tokens: int = MAX_TOKENS, 46 | temperature: float = TEMPERATURE, 47 | system_message: str = SYSTEM_MESSAGE, 48 | ) -> str: 49 | """ 50 | Generates a response from a given prompt using a specified model. 51 | 52 | Args: 53 | prompt (str): The prompt to generate a response for. 54 | conversation_history (Optional[List[Dict[str, str]]]): Previous conversation messages. 55 | model (str): The model to use for generating the response. 56 | max_tokens (int): The maximum number of tokens in the response. 57 | temperature (float): Controls randomness in the response. 58 | system_message (str): The system message to set the context. 59 | 60 | Returns: 61 | str: The generated response. 62 | 63 | Raises: 64 | OpenAIError: If there's an error with the OpenAI API call. 65 | """ 66 | session = initialize_session() 67 | 68 | messages = [{"role": "system", "content": system_message}] 69 | if conversation_history: 70 | messages.extend(conversation_history) 71 | messages.append({"role": "user", "content": prompt}) 72 | 73 | payload = { 74 | "model": model, 75 | "messages": messages, 76 | "max_tokens": max_tokens, 77 | "temperature": temperature, 78 | } 79 | 80 | try: 81 | response = session.post(get_openai_api_url(), data=json.dumps(payload)) 82 | response.raise_for_status() 83 | return _extract_content(response.json()) 84 | except requests.RequestException as e: 85 | raise OpenAIError(f"Error generating response: {str(e)}") from e 86 | 87 | 88 | def _extract_content(response: Dict[str, Any]) -> str: 89 | """ 90 | Extracts the content from the API response. 91 | 92 | Args: 93 | response (Dict[str, Any]): The API response object. 94 | 95 | Returns: 96 | str: The extracted content. 97 | 98 | Raises: 99 | ValueError: If the response format is unexpected. 100 | """ 101 | try: 102 | return response["choices"][0]["message"]["content"].strip() 103 | except (KeyError, IndexError) as e: 104 | raise ValueError(f"Unexpected response format: {str(e)}") from e 105 | -------------------------------------------------------------------------------- /src/openai_cli/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | DEFAULT_MODEL = "gpt-4o-mini" 4 | MAX_TOKENS = 500 5 | TEMPERATURE = 0.23 6 | SYSTEM_MESSAGE = "You are a helpful assistant." 7 | DEFAULT_API_BASE_URL = "https://api.openai.com/v1/chat/completions" 8 | 9 | 10 | def get_openai_api_key() -> str: 11 | """ 12 | Retrieves the OpenAI API key from the environment. 13 | 14 | Returns: 15 | str: The OpenAI API key, or an empty string if not set. 16 | """ 17 | return os.getenv("OPENAI_API_KEY", "") 18 | 19 | 20 | def set_openai_api_key(api_key: str) -> None: 21 | """ 22 | Sets the OpenAI API key in the environment. 23 | 24 | Args: 25 | api_key (str): The API key to set. 26 | """ 27 | os.environ["OPENAI_API_KEY"] = api_key 28 | 29 | 30 | def get_openai_api_url() -> str: 31 | """ 32 | Retrieves the OpenAI API URL from the environment. 33 | 34 | Returns: 35 | str: The OpenAI API URL, or the default URL if not set. 36 | """ 37 | return os.getenv("OPENAI_API_URL") or DEFAULT_API_BASE_URL 38 | 39 | 40 | def set_openai_api_url(api_url: str) -> None: 41 | """ 42 | Sets the OpenAI API URL in the environment. 43 | 44 | Args: 45 | api_url (str): The API URL to set. 46 | """ 47 | os.environ["OPENAI_API_URL"] = api_url 48 | -------------------------------------------------------------------------------- /src/openai_cli/test_cli.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import MagicMock, patch 3 | 4 | from click.testing import CliRunner 5 | 6 | from openai_cli.cli import cli 7 | from openai_cli.config import DEFAULT_MODEL, MAX_TOKENS, TEMPERATURE 8 | 9 | 10 | @patch("openai_cli.client.get_openai_api_url", return_value="http://mock-api-url") 11 | @patch("openai_cli.client.requests.Session", autospec=True) 12 | class TestCLI(unittest.TestCase): 13 | def setUp(self): 14 | self.runner = CliRunner() 15 | 16 | @patch("openai_cli.cli.generate_response") 17 | def test_complete_command(self, mock_generate, mock_session, mock_url): 18 | mock_generate.return_value = "Mocked response" 19 | result = self.runner.invoke(cli, ["complete", "-"], input="Test prompt") 20 | self.assertEqual(result.exit_code, 0) 21 | self.assertIn("Mocked response", result.output) 22 | mock_generate.assert_called_once_with( 23 | "Test prompt", 24 | conversation_history=[ 25 | {"role": "user", "content": "Test prompt"}, 26 | {"role": "assistant", "content": "Mocked response"}, 27 | ], 28 | model=DEFAULT_MODEL, 29 | max_tokens=MAX_TOKENS, 30 | temperature=TEMPERATURE, 31 | ) 32 | 33 | @patch("openai_cli.cli.generate_response") 34 | def test_repl_command(self, mock_generate, mock_session, mock_url): 35 | mock_generate.return_value = "Mocked response" 36 | result = self.runner.invoke(cli, ["repl"], input="Test prompt\nexit\n") 37 | self.assertEqual(result.exit_code, 0) 38 | self.assertIn("Mocked response", result.output) 39 | self.assertIn("Interactive shell ended.", result.output) 40 | mock_generate.assert_called_once_with( 41 | "Test prompt", 42 | conversation_history=[ 43 | {"role": "user", "content": "Test prompt"}, 44 | {"role": "assistant", "content": "Mocked response"}, 45 | ], 46 | model=DEFAULT_MODEL, 47 | max_tokens=MAX_TOKENS, 48 | temperature=TEMPERATURE, 49 | ) 50 | 51 | @patch("openai_cli.cli.generate_response") 52 | def test_model_option(self, mock_generate, mock_session, mock_url): 53 | mock_generate.return_value = "Mocked response" 54 | result = self.runner.invoke( 55 | cli, ["-m", "gpt-3.5-turbo", "complete", "-"], input="Test prompt" 56 | ) 57 | self.assertEqual(result.exit_code, 0) 58 | mock_generate.assert_called_once_with( 59 | "Test prompt", 60 | conversation_history=[ 61 | {"role": "user", "content": "Test prompt"}, 62 | {"role": "assistant", "content": "Mocked response"}, 63 | ], 64 | model="gpt-3.5-turbo", 65 | max_tokens=MAX_TOKENS, 66 | temperature=TEMPERATURE, 67 | ) 68 | 69 | @patch("openai_cli.cli.generate_response") 70 | def test_max_tokens_option(self, mock_generate, mock_session, mock_url): 71 | mock_generate.return_value = "Mocked response" 72 | result = self.runner.invoke(cli, ["-k", "100", "complete", "-"], input="Test prompt") 73 | self.assertEqual(result.exit_code, 0) 74 | mock_generate.assert_called_once_with( 75 | "Test prompt", 76 | conversation_history=[ 77 | {"role": "user", "content": "Test prompt"}, 78 | {"role": "assistant", "content": "Mocked response"}, 79 | ], 80 | model=DEFAULT_MODEL, 81 | max_tokens=100, 82 | temperature=TEMPERATURE, 83 | ) 84 | 85 | @patch("openai_cli.cli.generate_response") 86 | def test_temperature_option(self, mock_generate, mock_session, mock_url): 87 | mock_generate.return_value = "Mocked response" 88 | result = self.runner.invoke(cli, ["-p", "0.8", "complete", "-"], input="Test prompt") 89 | self.assertEqual(result.exit_code, 0) 90 | mock_generate.assert_called_once_with( 91 | "Test prompt", 92 | conversation_history=[ 93 | {"role": "user", "content": "Test prompt"}, 94 | {"role": "assistant", "content": "Mocked response"}, 95 | ], 96 | model=DEFAULT_MODEL, 97 | max_tokens=MAX_TOKENS, 98 | temperature=0.8, 99 | ) 100 | 101 | @patch("openai_cli.cli.set_openai_api_key") 102 | @patch("openai_cli.cli.generate_response") 103 | def test_token_option(self, mock_generate, mock_set_key, mock_session, mock_url): 104 | mock_generate.return_value = "Mocked response" 105 | result = self.runner.invoke(cli, ["-t", "test_token", "complete", "-"], input="Test prompt") 106 | self.assertEqual(result.exit_code, 0) 107 | mock_set_key.assert_called_once_with("test_token") 108 | mock_generate.assert_called_once_with( 109 | "Test prompt", 110 | conversation_history=[ 111 | {"role": "user", "content": "Test prompt"}, 112 | {"role": "assistant", "content": "Mocked response"}, 113 | ], 114 | model=DEFAULT_MODEL, 115 | max_tokens=MAX_TOKENS, 116 | temperature=TEMPERATURE, 117 | ) 118 | self.assertIn("Mocked response", result.output) 119 | 120 | 121 | if __name__ == "__main__": 122 | unittest.main() 123 | -------------------------------------------------------------------------------- /src/openai_cli/test_client.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import MagicMock, PropertyMock, patch 3 | 4 | import requests 5 | 6 | from openai_cli.client import OpenAIError, generate_response, initialize_session 7 | from openai_cli.config import DEFAULT_API_BASE_URL 8 | 9 | 10 | class TestClient(unittest.TestCase): 11 | 12 | @patch("openai_cli.client.requests.Session.post") 13 | @patch("openai_cli.client.get_openai_api_url", return_value=DEFAULT_API_BASE_URL) 14 | @patch("openai_cli.client.get_openai_api_key", return_value="test_api_key") 15 | @patch( 16 | "openai_cli.client.requests.Session", autospec=True 17 | ) # Mocking the Session itself to prevent any real network interaction 18 | def test_generate_response_success(self, mock_session_cls, mock_get_key, mock_get_url, mock_post): 19 | mock_response = MagicMock() 20 | mock_response.json.return_value = {"choices": [{"message": {"content": "Mocked response"}}]} 21 | mock_response.status_code = 200 22 | mock_post.return_value = mock_response 23 | 24 | mock_session = mock_session_cls.return_value 25 | mock_session.post = mock_post 26 | 27 | type(mock_session).headers = PropertyMock(return_value={}) 28 | 29 | response = generate_response("Test prompt") 30 | self.assertEqual(response, "Mocked response") 31 | mock_post.assert_called_once_with( 32 | DEFAULT_API_BASE_URL, 33 | data=unittest.mock.ANY 34 | ) 35 | 36 | @patch("openai_cli.client.requests.Session.post") 37 | @patch("openai_cli.client.get_openai_api_url", return_value=DEFAULT_API_BASE_URL) 38 | @patch("openai_cli.client.get_openai_api_key", return_value="test_api_key") 39 | @patch("openai_cli.client.requests.Session", autospec=True) 40 | def test_generate_response_error(self, mock_session_cls, mock_get_key, mock_get_url, mock_post): 41 | mock_post.side_effect = requests.RequestException("API Error") 42 | 43 | mock_session = mock_session_cls.return_value 44 | mock_session.post = mock_post 45 | 46 | type(mock_session).headers = PropertyMock(return_value={}) 47 | 48 | with self.assertRaises(OpenAIError): 49 | generate_response("Test prompt") 50 | 51 | @patch("openai_cli.client.get_openai_api_url", return_value="https://custom.api/v1") 52 | @patch("openai_cli.client.requests.Session.post") 53 | @patch("openai_cli.client.get_openai_api_key", return_value="test_api_key") 54 | @patch("openai_cli.client.requests.Session", autospec=True) 55 | def test_generate_response_custom_url(self, mock_session_cls, mock_get_key, mock_post, mock_get_url): 56 | mock_response = MagicMock() 57 | mock_response.json.return_value = {"choices": [{"message": {"content": "Mocked response"}}]} 58 | mock_response.status_code = 200 59 | mock_post.return_value = mock_response 60 | 61 | mock_session = mock_session_cls.return_value 62 | mock_session.post = mock_post 63 | 64 | type(mock_session).headers = PropertyMock(return_value={}) 65 | 66 | response = generate_response("Test prompt") 67 | self.assertEqual(response, "Mocked response") 68 | mock_post.assert_called_once_with( 69 | "https://custom.api/v1", 70 | data=unittest.mock.ANY 71 | ) 72 | 73 | @patch("openai_cli.client.get_openai_api_key") 74 | @patch("openai_cli.client.requests.Session", autospec=True) 75 | def test_initialize_session_success(self, mock_session_cls, mock_get_key): 76 | mock_get_key.return_value = "test_api_key" 77 | 78 | mock_session = mock_session_cls.return_value 79 | mock_session.headers = {} 80 | 81 | session = initialize_session() 82 | mock_session_cls.assert_called_once() 83 | self.assertEqual(session.headers["Authorization"], "Bearer test_api_key") 84 | 85 | @patch("openai_cli.client.get_openai_api_key") 86 | def test_initialize_session_no_key(self, mock_get_key): 87 | mock_get_key.return_value = "" 88 | 89 | with self.assertRaises(OpenAIError): 90 | initialize_session() 91 | 92 | 93 | if __name__ == "__main__": 94 | unittest.main() 95 | -------------------------------------------------------------------------------- /src/openai_cli/test_config.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch 3 | 4 | from openai_cli.config import ( 5 | DEFAULT_API_BASE_URL, 6 | get_openai_api_key, 7 | get_openai_api_url, 8 | set_openai_api_key, 9 | set_openai_api_url, 10 | ) 11 | 12 | 13 | class TestConfig(unittest.TestCase): 14 | @patch("os.getenv") 15 | def test_get_openai_api_key_set(self, mock_getenv): 16 | mock_getenv.return_value = "test_api_key" 17 | self.assertEqual(get_openai_api_key(), "test_api_key") 18 | 19 | @patch("os.getenv") 20 | def test_get_openai_api_key_not_set(self, mock_getenv): 21 | mock_getenv.return_value = "" 22 | self.assertEqual(get_openai_api_key(), "") 23 | 24 | @patch("os.environ") 25 | def test_set_openai_api_key(self, mock_environ): 26 | set_openai_api_key("new_api_key") 27 | mock_environ.__setitem__.assert_called_once_with("OPENAI_API_KEY", "new_api_key") 28 | 29 | @patch("os.getenv") 30 | def test_get_openai_api_url_set(self, mock_getenv): 31 | custom_url = "https://custom.openai.api/v1" 32 | mock_getenv.return_value = custom_url 33 | self.assertEqual(get_openai_api_url(), custom_url) 34 | 35 | @patch("os.getenv") 36 | def test_get_openai_api_url_not_set(self, mock_getenv): 37 | mock_getenv.return_value = None 38 | self.assertEqual(get_openai_api_url(), DEFAULT_API_BASE_URL) 39 | 40 | @patch("os.environ") 41 | def test_set_openai_api_url(self, mock_environ): 42 | custom_url = "https://custom.openai.api/v1" 43 | set_openai_api_url(custom_url) 44 | mock_environ.__setitem__.assert_called_once_with("OPENAI_API_URL", custom_url) 45 | 46 | 47 | if __name__ == "__main__": 48 | unittest.main() 49 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = python 3 | minversion = 3.12 4 | tox_pip_extensions_ext_venv_update = true 5 | toxworkdir={env:TOX_WORK_DIR:.tox} 6 | 7 | [testenv] 8 | deps = 9 | commands = 10 | pytest {posargs} 11 | usedevelop = True 12 | extras = testing 13 | allowlist_externals = 14 | pytest 15 | setenv= 16 | OPENAI_API_KEY='' 17 | 18 | [testenv:docs] 19 | extras = 20 | docs 21 | testing 22 | changedir = docs 23 | commands = 24 | python -m sphinx -W --keep-going . {toxinidir}/build/html 25 | 26 | [testenv:release] 27 | skip_install = True 28 | deps = 29 | build 30 | twine>=3 31 | passenv = 32 | TWINE_PASSWORD 33 | GITHUB_TOKEN 34 | setenv = 35 | TWINE_USERNAME = {env:TWINE_USERNAME:__token__} 36 | commands = 37 | python -c "import shutil; shutil.rmtree('dist', ignore_errors=True)" 38 | python -m build 39 | python -m twine upload dist/* 40 | --------------------------------------------------------------------------------