🚀 免费试用 Zilliz Cloud,完全托管的 Milvus,体验 10 倍的性能提升!立即试用>

milvus-logo
LFAI

项目概述

  • Engineering
September 15, 2021
Zhen Chen

项目概述

项目 URL: https://github.com/milvus-io/milvus_cli

准备工作Python3.8, Click 8.0.x

分组命令

创建命令

import click
from utils import PyOrm

@click.group(no_args_is_help=False, add_help_option=False, invoke_without_command=True)
@click.pass_context
def cli(ctx):
    """Milvus CLI"""
    ctx.obj = PyOrm() # PyOrm is a util class which wraps the milvus python SDK. You can pass any class instance here. Any command function passed by @click.obj can call it.

if __name__ == '__main__':
    cli()

如上代码所示,我们使用@click.group() 创建一个命令组cli 作为入口点。为了实现提示式 CLI,我们需要禁用入口的帮助信息,因此我们添加了no_args_is_help=Falseadd_help_option=Falseinvoke_without_command=True 。如果只在终端中输入cli ,则不会打印任何信息。

此外,我们使用@click.pass_context 将上下文传递给该命令组,以便进一步使用。

创建命令组的子命令

然后,我们在cli 下添加第一个子命令help

# Print the help message of specified command.
def print_help_msg(command):
    with click.Context(command) as ctx:
        click.echo(command.get_help(ctx))


# Use @cli.command() to create a sub command of cli.
@cli.command()
def help():
    """Show help messages."""
    # Print help message of cli.
    click.echo(print_help_msg(cli))

现在我们可以在终端中使用cli help

$ python milvus_cli/scripts/milvus_cli.py help

创建命令组的子命令组

我们不仅要有cli help 这样的子命令,还需要cli list collectioncli list partitioncli list indexes 这样的子命令组。

首先,我们创建一个子命令组list ,在这里,我们可以将@cli.group 的第一个参数作为命令名,而不是使用 defult 函数名,这样可以减少重复的函数名。

注意,这里我们使用@cli.group() 而不是@click.group ,这样我们就创建了 origin 组的子组。

我们使用@click.pass_objcontext.obj 传递给这个子组的子命令。

@cli.group('list', no_args_is_help=False)
@click.pass_obj
def listDetails(obj):
    """List collections, partitions and indexes."""
    pass

然后,我们通过@listDetails.command() (而不是@cli.command() )向该子组添加一些子命令。这里只是一个例子,你可以忽略它的实现,我们稍后再讨论。

@listDetails.command()
@click.option('--timeout', 'timeout', help="[Optional] - An optional duration of time in seconds to allow for the RPC. When timeout is set to None, client waits until server response or error occur.", default=None)
@click.option('--show-loaded', 'showLoaded', help="[Optional] - Only show loaded collections.", default=False)
@click.pass_obj
def collections(obj, timeout, showLoaded):
    """List all collections."""
    try:
        obj.checkConnection()
        click.echo(obj.listCollections(timeout, showLoaded))
    except Exception as e:
        click.echo(message=e, err=True)


@listDetails.command()
@click.option('-c', '--collection', 'collection', help='The name of collection.', default='')
@click.pass_obj
def partitions(obj, collection):
    """List all partitions of the specified collection."""
    try:
        obj.checkConnection()
        validateParamsByCustomFunc(
            obj.getTargetCollection, 'Collection Name Error!', collection)
        click.echo(obj.listPartitions(collection))
    except Exception as e:
        click.echo(message=e, err=True)

完成所有这些工作后,我们就得到了一个类似 miltigroup 的命令:

image 图像

自定义命令

添加选项

你可以在命令中添加一些选项,这些选项将像cli --test-option value 一样使用。

下面是一个例子,我们添加了三个选项alias,hostport 来指定连接到 milvus 的地址。

前两个参数定义了选项的短名和全名,第三个参数定义了变量名,help 参数指定了简短的帮助信息,default 参数指定了默认值,type 指定了值类型。

所有选项的值将按定义顺序传入函数。

@cli.command(no_args_is_help=False)
@click.option('-a', '--alias', 'alias', help="Milvus link alias name, default is `default`.", default='default', type=str)
@click.option('-h', '--host', 'host', help="Host name, default is `127.0.0.1`.", default='127.0.0.1', type=str)
@click.option('-p', '--port', 'port', help="Port, default is `19530`.", default=19530, type=int)
@click.pass_obj
def connect(obj, alias, host, port):
    pass

添加标志选项

上面我们使用选项传递值,但有时我们只需要一个布尔值的标志。

如下面的例子,选项autoId 是一个标志选项,不向函数传递任何数据,因此我们可以像使用cli create collection -c c_name -p p_name -a 一样使用它。

@createDetails.command('collection')
@click.option('-c', '--collection-name', 'collectionName', help='Collection name to be created.', default='')
@click.option('-p', '--schema-primary-field', 'primaryField', help='Primary field name.', default='')
@click.option('-a', '--schema-auto-id', 'autoId', help='Enable auto id.', default=False, is_flag=True)
@click.pass_obj
def createCollection(obj, collectionName, primaryField, autoId, description, fields):
    pass

添加参数

在本项目中,我们用选项取代了所有参数用法。但我们仍然在此介绍参数用法。与选项不同,arguments 的用法与cli COMMAND [OPTIONS] ARGUEMENTS 相同。如果我们把上面的例子转换成参数用法,就会像这样:

@createDetails.command('collection')
@click.argument('collectionName')
@click.option('-p', '--schema-primary-field', 'primaryField', help='Primary field name.', default='')
@click.option('-a', '--schema-auto-id', 'autoId', help='Enable auto id.', default=False, is_flag=True)
@click.pass_obj
def createCollection(obj, collectionName, primaryField, autoId, description, fields):
    pass

那么用法应该是cli create collection c_name -p p_name -a

添加完整的帮助信息

正如我们在上面定义了简短的帮助信息,我们也可以在函数中定义完整的帮助信息:

@cli.command(no_args_is_help=False)
@click.option('-a', '--alias', 'alias', help="Milvus link alias name, default is `default`.", default='default', type=str)
@click.option('-h', '--host', 'host', help="Host name, default is `127.0.0.1`.", default='127.0.0.1', type=str)
@click.option('-p', '--port', 'port', help="Port, default is `19530`.", default=19530, type=int)
@click.pass_obj
def connect(obj, alias, host, port):
    """
    Connect to Milvus.

    Example:

        milvus_cli > connect -h 127.0.0.1 -p 19530 -a default
    """
    try:
        obj.connect(alias, host, port)
    except Exception as e:
        click.echo(message=e, err=True)
    else:
        click.echo("Connect Milvus successfully!")
        click.echo(obj.showConnection(alias))

函数内部的第一个块就是帮助信息,我们输入cli connect --help 后,它将被打印出来。

milvus_cli > connect --help
Usage: milvus_cli.py connect [OPTIONS]

  Connect to Milvus.

  Example:

      milvus_cli > connect -h 127.0.0.1 -p 19530 -a default

Options:
  -a, --alias TEXT    Milvus link alias name, default is `default`.
  -h, --host TEXT     Host name, default is `127.0.0.1`.
  -p, --port INTEGER  Port, default is `19530`.
  --help              Show this message and exit.

添加确认

有时我们需要用户确认某些操作,尤其是删除某些内容。我们可以添加click.confirm 以暂停并要求用户确认:

@deleteSth.command('collection')
@click.option('-c', '--collection', 'collectionName', help='The name of collection to be deleted.', default='')
@click.option('-t', '--timeout', 'timeout', help='An optional duration of time in seconds to allow for the RPC. If timeout is set to None, the client keeps waiting until the server responds or an error occurs.', default=None, type=int)
@click.pass_obj
def deleteCollection(obj, collectionName, timeout):
    """
    Drops the collection together with its index files.

    Example:

        milvus_cli > delete collection -c car
    """
    click.echo(
        "Warning!\nYou are trying to delete the collection with data. This action cannot be undone!\n")
    if not click.confirm('Do you want to continue?'):
        return
    pass

如上例,确认对话将显示为Aborted!ant to continue? [y/N]:

添加提示

要实现提示功能,我们只需添加click.prompt

@cli.command()
@click.pass_obj
def query(obj):
    """
    Query with a set of criteria, and results in a list of records that match the query exactly.
    """
    collectionName = click.prompt(
        'Collection name', type=click.Choice(obj._list_collection_names()))
    expr = click.prompt('The query expression(field_name in [x,y])')
    partitionNames = click.prompt(
        f'The names of partitions to search(split by "," if multiple) {obj._list_partition_names(collectionName)}', default='')
    outputFields = click.prompt(
        f'Fields to return(split by "," if multiple) {obj._list_field_names(collectionName)}', default='')
    timeout = click.prompt('timeout', default='')
    pass

每次click.prompt 时都会显示提示。我们使用几个提示串联起来,这样看起来就像一个连续的对话。这样可以确保用户按照我们希望的顺序输入数据。在本例中,我们需要用户先选择一个 Collections,然后获取该 Collections 下的所有分区,再显示给用户选择。

添加选择

有时您希望用户只输入有限范围/类型的值,您可以将type=click.Choice([<any>]) 添加到click.promptclick.options 等。

例如

collectionName = click.prompt(
        'Collection name', type=click.Choice(['collection_1', 'collection_2']))

然后用户只能输入collection_1collection_2 ,如果输入其他内容,则会出错。

添加清除屏幕

您可以使用click.clear() 来实现。

@cli.command()
def clear():
    """Clear screen."""
    click.clear()

附加提示

  • 默认值为None ,因此如果指定默认值为None 则毫无意义。如果要跳过一个空值,默认值None 会导致click.prompt 持续显示。

实施提示 CLI 供用户输入

为什么要提示 CLI

在操作数据库时,我们需要持续连接到一个实例。如果我们使用 origin 命令行模式,每次执行命令后连接都会中断。我们还希望在使用 CLI 时存储一些数据,并在退出后清理它们。

执行

  1. 使用while True 持续监听用户输入。
def runCliPrompt():
    while True:
        astr = input('milvus_cli > ')
        try:
            cli(astr.split())
        except SystemExit:
            # trap argparse error message
            # print('error', SystemExit)
            continue


if __name__ == '__main__':
    runCliPrompt()
  1. 仅使用input 会导致up,down,left,right 方向键、tab 键和其他一些键自动转换为 Acsii 字符串。此外,历史命令不能从会话读取。因此,我们将readline 添加到runCliPrompt 函数中。
def runCliPrompt():
    while True:
            import readline
        readline.set_completer_delims(' \t\n;')
        astr = input('milvus_cli > ')
        try:
            cli(astr.split())
        except SystemExit:
            # trap argparse error message
            # print('error', SystemExit)
            continue
  1. 添加quit CLI。
@cli.command('exit')
def quitapp():
    """Exit the CLI."""
    global quitapp
    quitapp = True


quitapp = False  # global flag


def runCliPrompt():
    while not quitapp:
            import readline
        readline.set_completer_delims(' \t\n;')
        astr = input('milvus_cli > ')
        try:
            cli(astr.split())
        except SystemExit:
            # trap argparse error message
            # print('error', SystemExit)
            continue
  1. 当使用ctrl C 退出时,捕捉KeyboardInterrupt 的错误。
def runCliPrompt():
    try:
        while not quitapp:
            import readline
            readline.set_completer_delims(' \t\n;')
            astr = input('milvus_cli > ')
            try:
                cli(astr.split())
            except SystemExit:
                # trap argparse error message
                # print('error', SystemExit)
                continue
    except KeyboardInterrupt:
        sys.exit(0)
  1. 全部完成后,CLI 现在看起来就像这样了:
milvus_cli >
milvus_cli > connect
+-------+-----------+
| Host  | 127.0.0.1 |
| Port  |   19530   |
| Alias |  default  |
+-------+-----------+

milvus_cli > help
Usage:  [OPTIONS] COMMAND [ARGS]...

  Milvus CLI

Commands:
  clear     Clear screen.
  connect   Connect to Milvus.
  create    Create collection, partition and index.
  delete    Delete specified collection, partition and index.
  describe  Describe collection or partition.
  exit      Exit the CLI.
  help      Show help messages.
  import    Import data from csv file with headers and insert into target...
  list      List collections, partitions and indexes.
  load      Load specified collection.
  query     Query with a set of criteria, and results in a list of...
  release   Release specified collection.
  search    Conducts a vector similarity search with an optional boolean...
  show      Show connection, loading_progress and index_progress.
  version   Get Milvus CLI version.

milvus_cli > exit

手动实现自动完成

与 click 的 shell 自动完成不同,我们的项目对命令行进行包装,并使用循环来获取用户输入,以实现提示命令行。因此,我们需要在readline 中绑定一个完成器。

class Completer(object):
    RE_SPACE = re.compile('.*\s+$', re.M)
    CMDS_DICT = {
        'clear': [],
        'connect': [],
        'create': ['collection', 'partition', 'index'],
        'delete': ['collection', 'partition', 'index'],
        'describe': ['collection', 'partition'],
        'exit': [],
        'help': [],
        'import': [],
        'list': ['collections', 'partitions', 'indexes'],
        'load': [],
        'query': [],
        'release': [],
        'search': [],
        'show': ['connection', 'index_progress', 'loading_progress'],
        'version': [],
    }

    def __init__(self) -> None:
        super().__init__()
        self.COMMANDS = list(self.CMDS_DICT.keys())
        self.createCompleteFuncs(self.CMDS_DICT)

    def createCompleteFuncs(self, cmdDict):
        for cmd in cmdDict:
            sub_cmds = cmdDict[cmd]
            complete_example = self.makeComplete(cmd, sub_cmds)
            setattr(self, 'complete_%s' % cmd, complete_example)

    def makeComplete(self, cmd, sub_cmds):
        def f_complete(args):
            f"Completions for the {cmd} command."
            if not args:
                return self._complete_path('.')
            if len(args) <= 1 and not cmd == 'import':
                return self._complete_2nd_level(sub_cmds, args[-1])
            return self._complete_path(args[-1])
        return f_complete

    def _listdir(self, root):
        "List directory 'root' appending the path separator to subdirs."
        res = []
        for name in os.listdir(root):
            path = os.path.join(root, name)
            if os.path.isdir(path):
                name += os.sep
            res.append(name)
        return res

    def _complete_path(self, path=None):
        "Perform completion of filesystem path."
        if not path:
            return self._listdir('.')
        dirname, rest = os.path.split(path)
        tmp = dirname if dirname else '.'
        res = [os.path.join(dirname, p)
               for p in self._listdir(tmp) if p.startswith(rest)]
        # more than one match, or single match which does not exist (typo)
        if len(res) > 1 or not os.path.exists(path):
            return res
        # resolved to a single directory, so return list of files below it
        if os.path.isdir(path):
            return [os.path.join(path, p) for p in self._listdir(path)]
        # exact file match terminates this completion
        return [path + ' ']

    def _complete_2nd_level(self, SUB_COMMANDS=[], cmd=None):
        if not cmd:
            return [c + ' ' for c in SUB_COMMANDS]
        res = [c for c in SUB_COMMANDS if c.startswith(cmd)]
        if len(res) > 1 or not (cmd in SUB_COMMANDS):
            return res
        return [cmd + ' ']

    def complete(self, text, state):
        "Generic readline completion entry point."
        buffer = readline.get_line_buffer()
        line = readline.get_line_buffer().split()
        # show all commands
        if not line:
            return [c + ' ' for c in self.COMMANDS][state]
        # account for last argument ending in a space
        if self.RE_SPACE.match(buffer):
            line.append('')
        # resolve command to the implementation function
        cmd = line[0].strip()
        if cmd in self.COMMANDS:
            impl = getattr(self, 'complete_%s' % cmd)
            args = line[1:]
            if args:
                return (impl(args) + [None])[state]
            return [cmd + ' '][state]
        results = [
            c + ' ' for c in self.COMMANDS if c.startswith(cmd)] + [None]
        return results[state]

定义Completer 后,我们可以将其与 readline 绑定:

comp = Completer()


def runCliPrompt():
    try:
        while not quitapp:
            import readline
            readline.set_completer_delims(' \t\n;')
            readline.parse_and_bind("tab: complete")
            readline.set_completer(comp.complete)
            astr = input('milvus_cli > ')
            try:
                cli(astr.split())
            except SystemExit:
                # trap argparse error message
                # print('error', SystemExit)
                continue
    except KeyboardInterrupt:
        sys.exit(0)

添加一次性选项

对于提示命令行,有时我们不想完全运行脚本来获取一些信息,如版本。Python 就是一个很好的例子,当你在终端输入python 时,promtp 命令行会显示出来,但它只返回版本信息,如果你输入python -V ,它不会进入提示脚本。因此,我们可以在代码中使用sys.args 来实现。

def runCliPrompt():
    args = sys.argv
    if args and (args[-1] == '--version'):
        print(f"Milvus Cli v{getPackageVersion()}")
        return
    try:
        while not quitapp:
            import readline
            readline.set_completer_delims(' \t\n;')
            readline.parse_and_bind("tab: complete")
            readline.set_completer(comp.complete)
            astr = input('milvus_cli > ')
            try:
                cli(astr.split())
            except SystemExit:
                # trap argparse error message
                # print('error', SystemExit)
                continue
    except KeyboardInterrupt:
        sys.exit(0)


if __name__ == '__main__':
    runCliPrompt()

当第一次运行 CLI 脚本时,我们会在循环前得到sys.args 。如果最后一个参数是--version ,代码将返回软件包版本,而不会进入循环。

在我们将代码构建为软件包后,这将很有帮助。用户可以键入milvus_cli 跳转到 CLI 提示,或键入milvus_cli --version 只获取版本。

构建和发布

最后,我们要通过PYPI构建一个软件包并发布。这样用户就可以使用pip install <package name> 进行安装。

安装到本地进行测试

在将软件包发布到PYPI之前,您可能想在本地安装,以便进行一些测试。

在这种情况下,只需将cd 存入软件包目录并运行pip install -e . 即可(不要忘记. )。

创建软件包文件

参考: https://packaging.python.org/tutorials/packaging-projects/

软件包的结构如下

package_example/
├── LICENSE
├── README.md
├── setup.py
├── src/
│   ├── __init__.py
│   ├── main.py
│   └── scripts/
│       ├── __init__.py
│       └── example.py
└── tests/

创建软件包目录

创建Milvus_cli 目录,结构如下:

Milvus_cli/
├── LICENSE
├── README.md
├── setup.py
├── milvus_cli/
│   ├── __init__.py
│   ├── main.py
│   ├── utils.py
│   └── scripts/
│       ├── __init__.py
│       └── milvus_cli.py
└── dist/

编写入口代码

脚本的入口应在Milvus_cli/milvus_cli/scripts 中,而Milvus_cli/milvus_cli/scripts/milvus_cli.py 应如下所示:

import sys
import os
import click
from utils import PyOrm, Completer


pass_context = click.make_pass_decorator(PyOrm, ensure=True)


@click.group(no_args_is_help=False, add_help_option=False, invoke_without_command=True)
@click.pass_context
def cli(ctx):
    """Milvus CLI"""
    ctx.obj = PyOrm()

"""
...
Here your code.
...
"""

@cli.command('exit')
def quitapp():
    """Exit the CLI."""
    global quitapp
    quitapp = True


quitapp = False  # global flag
comp = Completer()


def runCliPrompt():
    args = sys.argv
    if args and (args[-1] == '--version'):
        print(f"Milvus Cli v{getPackageVersion()}")
        return
    try:
        while not quitapp:
            import readline
            readline.set_completer_delims(' \t\n;')
            readline.parse_and_bind("tab: complete")
            readline.set_completer(comp.complete)
            astr = input('milvus_cli > ')
            try:
                cli(astr.split())
            except SystemExit:
                # trap argparse error message
                # print('error', SystemExit)
                continue
            except Exception as e:
                click.echo(
                    message=f"Error occurred!\n{str(e)}", err=True)
    except KeyboardInterrupt:
        sys.exit(0)


if __name__ == '__main__':
    runCliPrompt()

编辑setup.py

from setuptools import setup, find_packages

with open("README.md", "r", encoding="utf-8") as fh:
    long_description = fh.read()

setup(
    name='milvus_cli',
    version='0.1.6',
    author='Milvus Team',
    author_email='milvus-team@zilliz.com',
    url='https://github.com/milvus-io/milvus_cli',
    description='CLI for Milvus',
    long_description=long_description,
    long_description_content_type='text/markdown',
    license='Apache-2.0',
    packages=find_packages(),
    include_package_data=True,
    install_requires=[
        'Click==8.0.1',
        'pymilvus==2.0.0rc5',
        'tabulate==0.8.9'
    ],
    entry_points={
        'console_scripts': [
            'milvus_cli = milvus_cli.scripts.milvus_cli:runCliPrompt',
        ],
    },
    python_requires='>=3.8'
)

这里有一些提示:

  1. 我们使用README.md 内容作为软件包的长描述。
  2. 将所有依赖项添加到install_requires
  3. 指定entry_points 。在本例中,我们将milvus_cli 设置为console_scripts 的子目录,这样我们就可以在安装此软件包后直接输入milvus_cli 作为命令。而milvus_cli 的入口点是milvus_cli/scripts/milvus_cli.py 中的runCliPrompt 函数。

构建

  1. 升级build 软件包:python3 -m pip install --upgrade build

  2. 运行 build:python -m build --sdist --wheel --outdir dist/ .

  3. dist/ 目录下将生成两个文件:

dist/
  example_package_YOUR_USERNAME_HERE-0.0.1-py3-none-any.whl
  example_package_YOUR_USERNAME_HERE-0.0.1.tar.gz

发布版本

参考: https://packaging.python.org/tutorials/packaging-projects/#uploading-the-distribution-archives

  1. 升级twine 软件包:python3 -m pip install --upgrade twine
  2. 上传至PYPI 测试环境:python3 -m twine upload --repository testpypi dist/*
  3. 上传至PYPIpython3 -m twine upload dist/*

通过 Github 工作流程实现 CI/CD

参考: https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/

我们希望有一种自动上传资产的方法,它可以构建软件包并将其上传到 Github 发行版和PYPI。

(出于某种原因,我们只希望工作流程只发布测试PYPI的版本)。

# This is a basic workflow to help you get started with Actions

name: Update the release's assets after it published

# Controls when the workflow will run
on:
  release:
    # The workflow will run after release published
    types: [published]

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  # This workflow contains a single job called "build"
  build:
    # The type of runner that the job will run on
    runs-on: ubuntu-latest

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
      - uses: actions/checkout@v2
      - uses: actions/setup-python@v2
        with:
          python-version: '3.8'
          architecture: 'x64'
      - name: Install pypa/build
        run: >-
          python -m
          pip install
          build
          --user
      - name: Clean dist/
        run: |
          sudo rm -fr dist/*
      - name: Build a binary wheel and a source tarball
        run: >-
          python -m
          build
          --sdist
          --wheel
          --outdir dist/
          .
      # Update target github release's assets
      - name: Update assets
        uses: softprops/action-gh-release@v1
        if: startsWith(github.ref, 'refs/tags/')
        with:
          files: ./dist/*
      - name: Publish distribution 📦 to Test PyPI
        if: contains(github.ref, 'beta') && startsWith(github.ref, 'refs/tags')
        uses: pypa/gh-action-pypi-publish@release/v1
        with:
          user: __token__
          password: ${{ secrets.TEST_PYPI_API_TOKEN }}
          repository_url: https://test.pypi.org/legacy/
          packages_dir: dist/
          verify_metadata: false

进一步了解 Milvus

Milvus 是一款强大的工具,能够为大量人工智能和向量相似性搜索应用提供动力。要了解有关该项目的更多信息,请查看以下资源:

  • 阅读我们的博客
  • Slack 上与我们的开源社区互动。
  • GitHub 上使用或贡献世界上最流行的向量数据库。
  • 使用我们的新启动训练营快速测试和部署人工智能应用。

Like the article? Spread the word

扩展阅读