diff --git a/Honkai_Star_Rail.py b/Honkai_Star_Rail.py
index fcde46d7..f6546f05 100644
--- a/Honkai_Star_Rail.py
+++ b/Honkai_Star_Rail.py
@@ -357,14 +357,13 @@ def select():
sra.set_config(False)
elif option == None:
...
+ elif option == _('退出脚本'):
+ if questionary.select(_("请问要退出脚本吗?"), [_("退出"), _("返回主菜单")]).ask() == _("返回主菜单"):
+ select()
else:
- if option:
- is_loop = sra.main(option)
- if is_loop:
- select()
- else:
- if questionary.select(_("请问要退出脚本吗?"), [_("退出"), _("返回主菜单")]).ask() == _("返回主菜单"):
- select()
+ is_loop = sra.main(option)
+ if is_loop:
+ select()
serial_map = args.get("--map") if args.get("--map") != "default" else "1-1_1" # 地图编号
select() if not serial_map else sra.main(start=serial_map)
sra.end()
diff --git a/requirements.txt b/requirements.txt
index 9922074b..fa58ac5d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -30,3 +30,4 @@ pluggy
httpcore
pydantic
jsonschema
+prompt-toolkit<=3.0.36,>=2.0
diff --git a/utils/calculated.py b/utils/calculated.py
index f85510f3..399b5625 100644
--- a/utils/calculated.py
+++ b/utils/calculated.py
@@ -602,7 +602,7 @@ def is_blackscreen(self, threshold = 30):
screenshot = cv.cvtColor(self.take_screenshot()[0], cv.COLOR_BGR2GRAY)
return cv.mean(screenshot)[0] < threshold
- def ocr_pos(self, characters:str = None, points = (0,0,0,0)):
+ def ocr_pos(self, characters: Optional[str]=None, points = (0,0,0,0)):
"""
说明:
获取指定文字的坐标
@@ -625,7 +625,8 @@ def ocr_pos(self, characters:str = None, points = (0,0,0,0)):
pos = data[characters] if characters in data else None
return characters, pos
- def ocr_pos_for_single_line(self, characters_list:list[str] = None, points = (0,0,0,0), number = False, debug = False, img_pk:tuple = None) -> Union[int, str]:
+ def ocr_pos_for_single_line(self, characters_list: Optional[List[str]]=None, points=(0,0,0,0), number=False, debug=False, img_pk: Optional[Tuple]=None
+ ) -> Optional[Union[int, str]]:
"""
说明:
获取指定坐标的单行文字
@@ -661,7 +662,7 @@ def read_img(self, path, prefix='./picture/pc/'):
"""
return cv.imread(f'{prefix}{path}')
- def part_ocr(self, points = (0,0,0,0), debug=False, left=False, number = False, img_pk:tuple = None, is_single_line = False, only_white=False
+ def part_ocr(self, points = (0,0,0,0), debug=False, left=False, number=False, img_pk: Optional[tuple]=None, is_single_line=False, only_white=False
) -> Union[str, dict[str, tuple[int, int]]]:
"""
说明:
@@ -992,7 +993,7 @@ def change_team(self):
class Array2dict:
- def __init__(self, arr:np.ndarray, key_index:int = -1, value_index:int = None):
+ def __init__(self, arr: np.ndarray, key_index: int = -1, value_index: Optional[int]=None):
"""
说明:
将np数组转化为字典暂住内存,用于对数组短时间内的频繁查找
@@ -1008,12 +1009,12 @@ def __init__(self, arr:np.ndarray, key_index:int = -1, value_index:int = None):
self.data_dict = {row[key_index]: idx for idx, row in enumerate(arr)}
else:
self.data_dict = {row[key_index]: row[value_index] for row in arr}
- log.debug(self.data_dict)
+ # log.debug(self.data_dict)
- def __getitem__(self, key):
+ def __getitem__(self, key: Any) -> Any:
return self.data_dict[key]
-def get_data_hash(data:Any, key_filter:list[str] = None) -> str:
+def get_data_hash(data: Any, key_filter: Optional[List[str]]=None, speed_modified=False) -> str:
"""
说明:
求任意类型数据 (包括list和dict) 的哈希值
@@ -1021,19 +1022,20 @@ def get_data_hash(data:Any, key_filter:list[str] = None) -> str:
参数:
:param data: 任意类型数据
:param key_filter: 键值过滤器
+ :param speed_modified: 是否对速度属性进行修饰 (忽略小数位数值)
"""
if not key_filter:
tmp_data = data
- elif type(data) is dict:
- tmp_data = dict(data).copy() # 注意dict'='为引用传递,此处需拷贝副本
- [tmp_data.pop(key) if key in tmp_data else None for key in key_filter]
+ elif isinstance(data, dict):
+ tmp_data = {key: value for key, value in data.items() if key not in key_filter}
+ if speed_modified and _("速度") in tmp_data["subs_stats"]:
+ tmp_data["subs_stats"][_("速度")] = float(int(tmp_data["subs_stats"][_("速度")])) # 去除小数部分
else:
- log.eror(_("不支持dict以外类型的类型使用键值过滤器"))
- return None
+ raise ValueError(_("不支持dict以外类型的类型使用键值过滤器"))
# pprint默认sort_dicts=True,对键值进行排序,以确保字典类型的唯一性
return hashlib.md5(pprint.pformat(tmp_data).encode('utf-8')).hexdigest()
-def str_just(text:str, width:int, left = True):
+def str_just(text: str, width: int, left=True):
"""
说明:
封装str.rjust()&str.ljust(),以适应中文字符的实际宽度
diff --git a/utils/config.py b/utils/config.py
index 30ce5346..dc333ee7 100644
--- a/utils/config.py
+++ b/utils/config.py
@@ -53,7 +53,7 @@ def read_json_file(filename: str, path=False, schema:dict=None) -> dict:
try:
jsonschema.validate(data, schema)
except jsonschema.exceptions.ValidationError as e:
- log.error(_(f"JSON 数据不符合格式规范: {e}"))
+ raise Exception(_(f"JSON 数据不符合格式规范: {e}"))
if path:
return data, file_path
else:
@@ -326,6 +326,8 @@ class SRAData(metaclass=SRADataMeta):
"""是否在遗器OCR时开启对副词条的数据验证"""
detail_for_relic: bool = True
"""是否在打印遗器信息时显示拓展信息"""
+ ndigits_for_relic: int = 2
+ """在打印遗器信息时的小数精度"""
def __init__(self) -> None:
diff --git a/utils/questionary/NOTICE b/utils/questionary/NOTICE
new file mode 100644
index 00000000..051758ad
--- /dev/null
+++ b/utils/questionary/NOTICE
@@ -0,0 +1,51 @@
+Tom Bocklisch
+Copyright 2019 Tom Bocklisch
+
+----
+
+This product includes software from PyInquirer (https://github.com/CITGuru/PyInquirer),
+under the MIT License.
+
+Copyright 2018 Oyetoke Toby and contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+----
+
+This product includes software from whaaaaat (https://github.com/finklabs/whaaaaat),
+under the MIT License.
+
+Copyright 2016 Fink Labs GmbH and inquirerpy contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
diff --git a/utils/questionary/README.md b/utils/questionary/README.md
new file mode 100644
index 00000000..90d04d3c
--- /dev/null
+++ b/utils/questionary/README.md
@@ -0,0 +1,114 @@
+# Questionary
+
+[![Version](https://img.shields.io/pypi/v/questionary.svg)](https://pypi.org/project/questionary/)
+[![License](https://img.shields.io/pypi/l/questionary.svg)](#)
+[![Continuous Integration](https://github.com/tmbo/questionary/workflows/Continuous%20Integration/badge.svg)](#)
+[![Coverage](https://coveralls.io/repos/github/tmbo/questionary/badge.svg?branch=master)](https://coveralls.io/github/tmbo/questionary?branch=master)
+[![Supported Python Versions](https://img.shields.io/pypi/pyversions/questionary.svg)](https://pypi.python.org/pypi/questionary)
+[![Documentation](https://readthedocs.org/projects/questionary/badge/?version=latest)](https://questionary.readthedocs.io/en/latest/?badge=latest)
+
+✨ Questionary is a Python library for effortlessly building pretty command line interfaces ✨
+
+* [Features](#features)
+* [Installation](#installation)
+* [Usage](#usage)
+* [Documentation](#documentation)
+* [Support](#support)
+
+
+![Example](https://raw.githubusercontent.com/tmbo/questionary/master/docs/images/example.gif)
+
+```python3
+import questionary
+
+questionary.text("What's your first name").ask()
+questionary.password("What's your secret?").ask()
+questionary.confirm("Are you amazed?").ask()
+
+questionary.select(
+ "What do you want to do?",
+ choices=["Order a pizza", "Make a reservation", "Ask for opening hours"],
+).ask()
+
+questionary.rawselect(
+ "What do you want to do?",
+ choices=["Order a pizza", "Make a reservation", "Ask for opening hours"],
+).ask()
+
+questionary.checkbox(
+ "Select toppings", choices=["foo", "bar", "bazz"]
+).ask()
+
+questionary.path("Path to the projects version file").ask()
+```
+
+Used and supported by
+
+[](https://github.com/RasaHQ/rasa)
+
+## Features
+
+Questionary supports the following input prompts:
+
+ * [Text](https://questionary.readthedocs.io/en/stable/pages/types.html#text)
+ * [Password](https://questionary.readthedocs.io/en/stable/pages/types.html#password)
+ * [File Path](https://questionary.readthedocs.io/en/stable/pages/types.html#file-path)
+ * [Confirmation](https://questionary.readthedocs.io/en/stable/pages/types.html#confirmation)
+ * [Select](https://questionary.readthedocs.io/en/stable/pages/types.html#select)
+ * [Raw select](https://questionary.readthedocs.io/en/stable/pages/types.html#raw-select)
+ * [Checkbox](https://questionary.readthedocs.io/en/stable/pages/types.html#checkbox)
+ * [Autocomplete](https://questionary.readthedocs.io/en/stable/pages/types.html#autocomplete)
+
+There is also a helper to [print formatted text](https://questionary.readthedocs.io/en/stable/pages/types.html#printing-formatted-text)
+for when you want to spice up your printed messages a bit.
+
+## Installation
+
+Use the package manager [pip](https://pip.pypa.io/en/stable/) to install Questionary:
+
+```bash
+$ pip install questionary
+✨🎂✨
+```
+
+## Usage
+
+```python
+import questionary
+
+questionary.select(
+ "What do you want to do?",
+ choices=[
+ 'Order a pizza',
+ 'Make a reservation',
+ 'Ask for opening hours'
+ ]).ask() # returns value of selection
+```
+
+That's all it takes to create a prompt! Have a [look at the documentation](https://questionary.readthedocs.io/)
+for some more examples.
+
+## Documentation
+
+Documentation for Questionary is available [here](https://questionary.readthedocs.io/).
+
+## Support
+
+Please [open an issue](https://github.com/tmbo/questionary/issues/new)
+with enough information for us to reproduce your problem.
+A [minimal, reproducible example](https://stackoverflow.com/help/minimal-reproducible-example)
+would be very helpful.
+
+## Contributing
+
+Contributions are very much welcomed and appreciated. Head over to the documentation on [how to contribute](https://questionary.readthedocs.io/en/stable/pages/contributors.html#steps-for-submitting-code).
+
+## Authors and Acknowledgment
+
+Questionary is written and maintained by Tom Bocklisch and Kian Cross.
+
+It is based on the great work by [Oyetoke Toby](https://github.com/CITGuru/PyInquirer)
+and [Mark Fink](https://github.com/finklabs/whaaaaat).
+
+## License
+Licensed under the [MIT License](https://github.com/tmbo/questionary/blob/master/LICENSE). Copyright 2021 Tom Bocklisch.
diff --git a/utils/questionary/questionary/__init__.py b/utils/questionary/questionary/__init__.py
new file mode 100644
index 00000000..1d14551f
--- /dev/null
+++ b/utils/questionary/questionary/__init__.py
@@ -0,0 +1,54 @@
+# noinspection PyUnresolvedReferences
+from prompt_toolkit.styles import Style
+from prompt_toolkit.validation import ValidationError
+from prompt_toolkit.validation import Validator
+
+from .version import __version__
+from .form import Form
+from .form import FormField
+from .form import form
+from .prompt import prompt
+from .prompt import unsafe_prompt
+
+# import the shortcuts to create single question prompts
+from .prompts.autocomplete import autocomplete
+from .prompts.checkbox import checkbox
+from .prompts.common import Choice
+from .prompts.common import Separator
+from .prompts.common import print_formatted_text as print
+from .prompts.confirm import confirm
+from .prompts.password import password
+from .prompts.path import path
+from .prompts.press_any_key_to_continue import press_any_key_to_continue
+from .prompts.rawselect import rawselect
+from .prompts.select import select
+from .prompts.text import text
+from .question import Question
+
+__all__ = [
+ "__version__",
+ # question types
+ "autocomplete",
+ "checkbox",
+ "confirm",
+ "password",
+ "path",
+ "press_any_key_to_continue",
+ "rawselect",
+ "select",
+ "text",
+ # utility methods
+ "print",
+ "form",
+ "prompt",
+ "unsafe_prompt",
+ # commonly used classes
+ "Form",
+ "FormField",
+ "Question",
+ "Choice",
+ "Style",
+ "Separator",
+ "Validator",
+ "ValidationError",
+]
diff --git a/utils/questionary/questionary/constants.py b/utils/questionary/questionary/constants.py
new file mode 100644
index 00000000..768c481f
--- /dev/null
+++ b/utils/questionary/questionary/constants.py
@@ -0,0 +1,49 @@
+from . import Style
+
+# Value to display as an answer when "affirming" a confirmation question
+YES = "Yes"
+
+# Value to display as an answer when "denying" a confirmation question
+NO = "No"
+
+# Instruction text for a confirmation question (yes is default)
+YES_OR_NO = "(Y/n)"
+
+# Instruction text for a confirmation question (no is default)
+NO_OR_YES = "(y/N)"
+
+# Instruction for multiline input
+INSTRUCTION_MULTILINE = "(Finish with 'Alt+Enter' or 'Esc then Enter')\n>"
+
+# Selection token used to indicate the selection cursor in a list
+DEFAULT_SELECTED_POINTER = "»"
+
+# Item prefix to identify selected items in a checkbox list
+INDICATOR_SELECTED = "●"
+
+# Item prefix to identify unselected items in a checkbox list
+INDICATOR_UNSELECTED = "○"
+
+# Prefix displayed in front of questions
+DEFAULT_QUESTION_PREFIX = "?"
+
+# Message shown when a user aborts a question prompt using CTRL-C
+DEFAULT_KBI_MESSAGE = "Cancelled by user"
+
+# Default text shown when the input is invalid
+INVALID_INPUT = "Invalid input"
+
+# Default message style
+DEFAULT_STYLE = Style(
+ [
+ ("qmark", "fg:#5f819d"), # token in front of the question
+ ("question", "bold"), # question text
+ ("answer", "fg:#FF9D00 bold"), # submitted answer text behind the question
+ ("pointer", ""), # pointer used in select and checkbox prompts
+ ("selected", ""), # style for a selected item of a checkbox
+ ("separator", ""), # separator in lists
+ ("instruction", ""), # user instructions for select, rawselect, checkbox
+ ("text", ""), # any other text
+ ("instruction", ""), # user instructions for select, rawselect, checkbox
+ ]
+)
diff --git a/utils/questionary/questionary/form.py b/utils/questionary/questionary/form.py
new file mode 100644
index 00000000..b5d865f2
--- /dev/null
+++ b/utils/questionary/questionary/form.py
@@ -0,0 +1,122 @@
+
+from typing import Any
+from typing import Dict
+from typing import NamedTuple
+from typing import Sequence
+
+from .constants import DEFAULT_KBI_MESSAGE
+from .question import Question
+
+
+class FormField(NamedTuple):
+ """
+ Represents a question within a form
+
+ Args:
+ key: The name of the form field.
+ question: The question to ask in the form field.
+ """
+
+ key: str
+ question: Question
+
+
+def form(**kwargs: Question) -> "Form":
+ """Create a form with multiple questions.
+
+ The parameter name of a question will be the key for the answer in
+ the returned dict.
+
+ Args:
+ kwargs: Questions to ask in the form.
+ """
+ return Form(*(FormField(k, q) for k, q in kwargs.items()))
+
+
+class Form:
+ """Multi question prompts. Questions are asked one after another.
+
+ All the answers are returned as a dict with one entry per question.
+
+ This class should not be invoked directly, instead use :func:`form`.
+ """
+
+ form_fields: Sequence[FormField]
+
+ def __init__(self, *form_fields: FormField) -> None:
+ self.form_fields = form_fields
+
+ def unsafe_ask(self, patch_stdout: bool = False) -> Dict[str, Any]:
+ """Ask the questions synchronously and return user response.
+
+ Does not catch keyboard interrupts.
+
+ Args:
+ patch_stdout: Ensure that the prompt renders correctly if other threads
+ are printing to stdout.
+
+ Returns:
+ The answers from the form.
+ """
+ return {f.key: f.question.unsafe_ask(patch_stdout) for f in self.form_fields}
+
+ async def unsafe_ask_async(self, patch_stdout: bool = False) -> Dict[str, Any]:
+ """Ask the questions using asyncio and return user response.
+
+ Does not catch keyboard interrupts.
+
+ Args:
+ patch_stdout: Ensure that the prompt renders correctly if other threads
+ are printing to stdout.
+
+ Returns:
+ The answers from the form.
+ """
+ return {
+ f.key: await f.question.unsafe_ask_async(patch_stdout)
+ for f in self.form_fields
+ }
+
+ def ask(
+ self, patch_stdout: bool = False, kbi_msg: str = DEFAULT_KBI_MESSAGE
+ ) -> Dict[str, Any]:
+ """Ask the questions synchronously and return user response.
+
+ Args:
+ patch_stdout: Ensure that the prompt renders correctly if other threads
+ are printing to stdout.
+
+ kbi_msg: The message to be printed on a keyboard interrupt.
+
+ Returns:
+ The answers from the form.
+ """
+ try:
+ return self.unsafe_ask(patch_stdout)
+ except KeyboardInterrupt:
+ print("")
+ print(kbi_msg)
+ print("")
+ return {}
+
+ async def ask_async(
+ self, patch_stdout: bool = False, kbi_msg: str = DEFAULT_KBI_MESSAGE
+ ) -> Dict[str, Any]:
+ """Ask the questions using asyncio and return user response.
+
+ Args:
+ patch_stdout: Ensure that the prompt renders correctly if other threads
+ are printing to stdout.
+
+ kbi_msg: The message to be printed on a keyboard interrupt.
+
+ Returns:
+ The answers from the form.
+ """
+ try:
+ return await self.unsafe_ask_async(patch_stdout)
+ except KeyboardInterrupt:
+ print("")
+ print(kbi_msg)
+ print("")
+ return {}
diff --git a/utils/questionary/questionary/prompt.py b/utils/questionary/questionary/prompt.py
new file mode 100644
index 00000000..b4d18017
--- /dev/null
+++ b/utils/questionary/questionary/prompt.py
@@ -0,0 +1,236 @@
+from typing import Any
+from typing import Dict
+from typing import Iterable
+from typing import Mapping
+from typing import Optional
+from typing import Union
+
+from prompt_toolkit.output import ColorDepth
+
+from . import utils
+from .constants import DEFAULT_KBI_MESSAGE
+from .prompts import AVAILABLE_PROMPTS
+from .prompts import prompt_by_name
+from .prompts.common import print_formatted_text
+
+
+class PromptParameterException(ValueError):
+ """Received a prompt with a missing parameter."""
+
+ def __init__(self, message: str, errors: Optional[BaseException] = None) -> None:
+ # Call the base class constructor with the parameters it needs
+ super().__init__(f"You must provide a `{message}` value", errors)
+
+
+def prompt(
+ questions: Union[Dict[str, Any], Iterable[Mapping[str, Any]]],
+ answers: Optional[Mapping[str, Any]] = None,
+ patch_stdout: bool = False,
+ true_color: bool = False,
+ kbi_msg: str = DEFAULT_KBI_MESSAGE,
+ **kwargs: Any,
+) -> Dict[str, Any]:
+ """Prompt the user for input on all the questions.
+
+ Catches keyboard interrupts and prints a message.
+
+ See :func:`unsafe_prompt` for possible question configurations.
+
+ Args:
+ questions: A list of question configs representing questions to
+ ask. A question config may have the following options:
+
+ * type - The type of question.
+ * name - An ID for the question (to identify it in the answers :obj:`dict`).
+
+ * when - Callable to conditionally show the question. This function
+ takes a :obj:`dict` representing the current answers.
+
+ * filter - Function that the answer is passed to. The return value of this
+ function is saved as the answer.
+
+ Additional options correspond to the parameter names for
+ particular question types.
+
+ answers: Default answers.
+
+ patch_stdout: Ensure that the prompt renders correctly if other threads
+ are printing to stdout.
+
+ kbi_msg: The message to be printed on a keyboard interrupt.
+ true_color: Use true color output.
+
+ color_depth: Color depth to use. If ``true_color`` is set to true then this
+ value is ignored.
+
+ type: Default ``type`` value to use in question config.
+ filter: Default ``filter`` value to use in question config.
+ name: Default ``name`` value to use in question config.
+ when: Default ``when`` value to use in question config.
+ default: Default ``default`` value to use in question config.
+ kwargs: Additional options passed to every question.
+
+ Returns:
+ Dictionary of question answers.
+ """
+
+ try:
+ return unsafe_prompt(questions, answers, patch_stdout, true_color, **kwargs)
+ except KeyboardInterrupt:
+ print("")
+ print(kbi_msg)
+ print("")
+ return {}
+
+
+def unsafe_prompt(
+ questions: Union[Dict[str, Any], Iterable[Mapping[str, Any]]],
+ answers: Optional[Mapping[str, Any]] = None,
+ patch_stdout: bool = False,
+ true_color: bool = False,
+ **kwargs: Any,
+) -> Dict[str, Any]:
+ """Prompt the user for input on all the questions.
+
+ Won't catch keyboard interrupts.
+
+ Args:
+ questions: A list of question configs representing questions to
+ ask. A question config may have the following options:
+
+ * type - The type of question.
+ * name - An ID for the question (to identify it in the answers :obj:`dict`).
+
+ * when - Callable to conditionally show the question. This function
+ takes a :obj:`dict` representing the current answers.
+
+ * filter - Function that the answer is passed to. The return value of this
+ function is saved as the answer.
+
+ Additional options correspond to the parameter names for
+ particular question types.
+
+ answers: Default answers.
+
+ patch_stdout: Ensure that the prompt renders correctly if other threads
+ are printing to stdout.
+
+ true_color: Use true color output.
+
+ color_depth: Color depth to use. If ``true_color`` is set to true then this
+ value is ignored.
+
+ type: Default ``type`` value to use in question config.
+ filter: Default ``filter`` value to use in question config.
+ name: Default ``name`` value to use in question config.
+ when: Default ``when`` value to use in question config.
+ default: Default ``default`` value to use in question config.
+ kwargs: Additional options passed to every question.
+
+ Returns:
+ Dictionary of question answers.
+
+ Raises:
+ KeyboardInterrupt: raised on keyboard interrupt
+ """
+
+ if isinstance(questions, dict):
+ questions = [questions]
+
+ answers = dict(answers or {})
+
+ for question_config in questions:
+ question_config = dict(question_config)
+ # import the question
+ if "type" not in question_config:
+ raise PromptParameterException("type")
+ # every type except 'print' needs a name
+ if "name" not in question_config and question_config["type"] != "print":
+ raise PromptParameterException("name")
+
+ _kwargs = kwargs.copy()
+ _kwargs.update(question_config)
+
+ _type = _kwargs.pop("type")
+ _filter = _kwargs.pop("filter", None)
+ name = _kwargs.pop("name", None) if _type == "print" else _kwargs.pop("name")
+ when = _kwargs.pop("when", None)
+
+ if true_color:
+ _kwargs["color_depth"] = ColorDepth.TRUE_COLOR
+
+ if when:
+ # at least a little sanity check!
+ if callable(question_config["when"]):
+ try:
+ if not question_config["when"](answers):
+ continue
+ except Exception as exception:
+ raise ValueError(
+ f"Problem in 'when' check of " f"{name} question: {exception}"
+ ) from exception
+ else:
+ raise ValueError(
+ "'when' needs to be function that accepts a dict argument"
+ )
+
+ # handle 'print' type
+ if _type == "print":
+ try:
+ message = _kwargs.pop("message")
+ except KeyError as e:
+ raise PromptParameterException("message") from e
+
+ # questions can take 'input' arg but print_formatted_text does not
+ # Remove 'input', if present, to avoid breaking during tests
+ _kwargs.pop("input", None)
+
+ print_formatted_text(message, **_kwargs)
+ if name:
+ answers[name] = None
+ continue
+
+ choices = question_config.get("choices")
+ if choices is not None and callable(choices):
+ calculated_choices = choices(answers)
+ question_config["choices"] = calculated_choices
+ kwargs["choices"] = calculated_choices
+
+ if _filter:
+ # at least a little sanity check!
+ if not callable(_filter):
+ raise ValueError(
+ "'filter' needs to be function that accepts an argument"
+ )
+
+ if callable(question_config.get("default")):
+ _kwargs["default"] = question_config["default"](answers)
+
+ create_question_func = prompt_by_name(_type)
+
+ if not create_question_func:
+ raise ValueError(
+ f"No question type '{_type}' found. "
+ f"Known question types are {', '.join(AVAILABLE_PROMPTS)}."
+ )
+
+ missing_args = list(utils.missing_arguments(create_question_func, _kwargs))
+ if missing_args:
+ raise PromptParameterException(missing_args[0])
+
+ question = create_question_func(**_kwargs)
+
+ answer = question.unsafe_ask(patch_stdout)
+
+ if answer is not None:
+ if _filter:
+ try:
+ answer = _filter(answer)
+ except Exception as exception:
+ raise ValueError(
+ f"Problem processing 'filter' of {name} "
+ f"question: {exception}"
+ ) from exception
+ answers[name] = answer
+
+ return answers
diff --git a/utils/questionary/questionary/prompts/__init__.py b/utils/questionary/questionary/prompts/__init__.py
new file mode 100644
index 00000000..ee7b8f7f
--- /dev/null
+++ b/utils/questionary/questionary/prompts/__init__.py
@@ -0,0 +1,29 @@
+from . import autocomplete
+from . import checkbox
+from . import confirm
+from . import password
+from . import path
+from . import press_any_key_to_continue
+from . import rawselect
+from . import select
+from . import text
+
+AVAILABLE_PROMPTS = {
+ "autocomplete": autocomplete.autocomplete,
+ "confirm": confirm.confirm,
+ "text": text.text,
+ "select": select.select,
+ "rawselect": rawselect.rawselect,
+ "password": password.password,
+ "checkbox": checkbox.checkbox,
+ "path": path.path,
+ "press_any_key_to_continue": press_any_key_to_continue.press_any_key_to_continue,
+ # backwards compatible names
+ "list": select.select,
+ "rawlist": rawselect.rawselect,
+ "input": text.text,
+}
+
+
+def prompt_by_name(name):
+ return AVAILABLE_PROMPTS.get(name)
diff --git a/utils/questionary/questionary/prompts/autocomplete.py b/utils/questionary/questionary/prompts/autocomplete.py
new file mode 100644
index 00000000..79d78baf
--- /dev/null
+++ b/utils/questionary/questionary/prompts/autocomplete.py
@@ -0,0 +1,214 @@
+from typing import Any
+from typing import Callable
+from typing import Dict
+from typing import Iterable
+from typing import List
+from typing import Optional
+from typing import Tuple
+from typing import Union
+
+from prompt_toolkit.completion import CompleteEvent
+from prompt_toolkit.completion import Completer
+from prompt_toolkit.completion import Completion
+from prompt_toolkit.document import Document
+from prompt_toolkit.formatted_text import HTML
+from prompt_toolkit.lexers import SimpleLexer
+from prompt_toolkit.shortcuts.prompt import CompleteStyle
+from prompt_toolkit.shortcuts.prompt import PromptSession
+from prompt_toolkit.styles import Style
+
+from ..constants import DEFAULT_QUESTION_PREFIX
+from ..prompts.common import build_validator
+from ..question import Question
+from ..styles import merge_styles_default
+
+
+class WordCompleter(Completer):
+ choices_source: Union[List[str], Callable[[], List[str]]]
+ ignore_case: bool
+ meta_information: Dict[str, Any]
+ match_middle: bool
+
+ def __init__(
+ self,
+ choices: Union[List[str], Callable[[], List[str]]],
+ ignore_case: bool = True,
+ meta_information: Optional[Dict[str, Any]] = None,
+ match_middle: bool = True,
+ ) -> None:
+ self.choices_source = choices
+ self.ignore_case = ignore_case
+ self.meta_information = meta_information or {}
+ self.match_middle = match_middle
+
+ def _choices(self) -> Iterable[str]:
+ return (
+ self.choices_source()
+ if callable(self.choices_source)
+ else self.choices_source
+ )
+
+ def _choice_matches(self, word_before_cursor: str, choice: str) -> int:
+ """Match index if found, -1 if not."""
+
+ if self.ignore_case:
+ choice = choice.lower()
+
+ if self.match_middle:
+ return choice.find(word_before_cursor)
+ elif choice.startswith(word_before_cursor):
+ return 0
+ else:
+ return -1
+
+ @staticmethod
+ def _display_for_choice(choice: str, index: int, word_before_cursor: str) -> HTML:
+ return HTML("{}{}{}").format(
+ choice[:index],
+ choice[index : index + len(word_before_cursor)], # noqa: E203
+ choice[index + len(word_before_cursor) : len(choice)], # noqa: E203
+ )
+
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ choices = self._choices()
+
+ # Get word/text before cursor.
+ word_before_cursor = document.text_before_cursor
+
+ if self.ignore_case:
+ word_before_cursor = word_before_cursor.lower()
+
+ for choice in choices:
+ index = self._choice_matches(word_before_cursor, choice)
+ if index == -1:
+ # didn't find a match
+ continue
+
+ display_meta = self.meta_information.get(choice, "")
+ display = self._display_for_choice(choice, index, word_before_cursor)
+
+ yield Completion(
+ choice,
+ start_position=-len(choice),
+ display=display.formatted_text,
+ display_meta=display_meta,
+ style="class:answer",
+ selected_style="class:selected",
+ )
+
+
+def autocomplete(
+ message: str,
+ choices: List[str],
+ default: str = "",
+ qmark: str = DEFAULT_QUESTION_PREFIX,
+ completer: Optional[Completer] = None,
+ meta_information: Optional[Dict[str, Any]] = None,
+ ignore_case: bool = True,
+ match_middle: bool = True,
+ complete_style: CompleteStyle = CompleteStyle.COLUMN,
+ validate: Any = None,
+ style: Optional[Style] = None,
+ **kwargs: Any,
+) -> Question:
+ """Prompt the user to enter a message with autocomplete help.
+
+ Example:
+ >>> import questionary
+ >>> questionary.autocomplete(
+ ... 'Choose ant specie',
+ ... choices=[
+ ... 'Camponotus pennsylvanicus',
+ ... 'Linepithema humile',
+ ... 'Eciton burchellii',
+ ... "Atta colombica",
+ ... 'Polyergus lucidus',
+ ... 'Polyergus rufescens',
+ ... ]).ask()
+ ? Choose ant specie Atta colombica
+ 'Atta colombica'
+
+ .. image:: ../images/autocomplete.gif
+
+ This is just a really basic example, the prompt can be customised using the
+ parameters.
+
+
+ Args:
+ message: Question text
+
+ choices: Items shown in the selection, this contains items as strings
+
+ default: Default return value (single value).
+
+ qmark: Question prefix displayed in front of the question.
+ By default this is a ``?``
+
+ completer: A prompt_toolkit :class:`prompt_toolkit.completion.Completion`
+ implementation. If not set, a questionary completer implementation
+ will be used.
+
+ meta_information: A dictionary with information/anything about choices.
+
+ ignore_case: If true autocomplete would ignore case.
+
+ match_middle: If true autocomplete would search in every string position
+ not only in string begin.
+
+ complete_style: How autocomplete menu would be shown, it could be ``COLUMN``
+ ``MULTI_COLUMN`` or ``READLINE_LIKE`` from
+ :class:`prompt_toolkit.shortcuts.CompleteStyle`.
+
+ validate: Require the entered value to pass a validation. The
+ value can not be submitted until the validator accepts
+ it (e.g. to check minimum password length).
+
+ This can either be a function accepting the input and
+ returning a boolean, or an class reference to a
+ subclass of the prompt toolkit Validator class.
+
+ style: A custom color and style for the question parts. You can
+ configure colors as well as font types for different elements.
+
+ Returns:
+ :class:`Question`: Question instance, ready to be prompted (using ``.ask()``).
+ """
+ merged_style = merge_styles_default([style])
+
+ def get_prompt_tokens() -> List[Tuple[str, str]]:
+ return [("class:qmark", qmark), ("class:question", " {} ".format(message))]
+
+ def get_meta_style(meta: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
+ if meta:
+ for key in meta:
+ meta[key] = HTML("{}").format(meta[key])
+
+ return meta
+
+ validator = build_validator(validate)
+
+ if completer is None:
+ if not choices:
+ raise ValueError("No choices is given, you should use Text question.")
+ # use the default completer
+ completer = WordCompleter(
+ choices,
+ ignore_case=ignore_case,
+ meta_information=get_meta_style(meta_information),
+ match_middle=match_middle,
+ )
+
+ p: PromptSession = PromptSession(
+ get_prompt_tokens,
+ lexer=SimpleLexer("class:answer"),
+ style=merged_style,
+ completer=completer,
+ validator=validator,
+ complete_style=complete_style,
+ **kwargs,
+ )
+ p.default_buffer.reset(Document(default))
+
+ return Question(p.app)
diff --git a/utils/questionary/questionary/prompts/checkbox.py b/utils/questionary/questionary/prompts/checkbox.py
new file mode 100644
index 00000000..35db6ff0
--- /dev/null
+++ b/utils/questionary/questionary/prompts/checkbox.py
@@ -0,0 +1,300 @@
+from typing import Any
+from typing import Callable
+from typing import Dict
+from typing import List
+from typing import Optional
+from typing import Sequence
+from typing import Tuple
+from typing import Union
+
+from prompt_toolkit.application import Application
+from prompt_toolkit.formatted_text import FormattedText
+from prompt_toolkit.key_binding import KeyBindings
+from prompt_toolkit.keys import Keys
+from prompt_toolkit.styles import Style
+
+from .. import utils
+from ..constants import DEFAULT_QUESTION_PREFIX
+from ..constants import DEFAULT_SELECTED_POINTER
+from ..constants import INVALID_INPUT
+from ..prompts import common
+from ..prompts.common import Choice
+from ..prompts.common import InquirerControl
+from ..prompts.common import Separator
+from ..question import Question
+from ..styles import merge_styles_default
+
+
+def checkbox(
+ message: str,
+ choices: Sequence[Union[str, Choice, Dict[str, Any]]],
+ default: Optional[str] = None,
+ validate: Callable[[List[str]], Union[bool, str]] = lambda a: True,
+ qmark: str = DEFAULT_QUESTION_PREFIX,
+ pointer: Optional[str] = DEFAULT_SELECTED_POINTER,
+ style: Optional[Style] = None,
+ initial_choice: Optional[Union[str, Choice, Dict[str, Any]]] = None,
+ use_arrow_keys: bool = True,
+ use_jk_keys: bool = True,
+ use_emacs_keys: bool = True,
+ instruction: Optional[str] = None,
+ show_description: bool = True,
+ **kwargs: Any,
+) -> Question:
+ """Ask the user to select from a list of items.
+
+ This is a multiselect, the user can choose one, none or many of the
+ items.
+
+ Example:
+ >>> import questionary
+ >>> questionary.checkbox(
+ ... 'Select toppings',
+ ... choices=[
+ ... "Cheese",
+ ... "Tomato",
+ ... "Pineapple",
+ ... ]).ask()
+ ? Select toppings done (2 selections)
+ ['Cheese', 'Pineapple']
+
+ .. image:: ../images/checkbox.gif
+
+ This is just a really basic example, the prompt can be customised using the
+ parameters.
+
+
+ Args:
+ message: Question text
+
+ choices: Items shown in the selection, this can contain :class:`Choice` or
+ or :class:`Separator` objects or simple items as strings. Passing
+ :class:`Choice` objects, allows you to configure the item more
+ (e.g. preselecting it or disabling it).
+
+ default: Default return value (single value). If you want to preselect
+ multiple items, use ``Choice("foo", checked=True)`` instead.
+
+ validate: Require the entered value to pass a validation. The
+ value can not be submitted until the validator accepts
+ it (e.g. to check minimum password length).
+
+ This should be a function accepting the input and
+ returning a boolean. Alternatively, the return value
+ may be a string (indicating failure), which contains
+ the error message to be displayed.
+
+ qmark: Question prefix displayed in front of the question.
+ By default this is a ``?``.
+
+ pointer: Pointer symbol in front of the currently highlighted element.
+ By default this is a ``»``.
+ Use ``None`` to disable it.
+
+ style: A custom color and style for the question parts. You can
+ configure colors as well as font types for different elements.
+
+ initial_choice: A value corresponding to a selectable item in the choices,
+ to initially set the pointer position to.
+
+ use_arrow_keys: Allow the user to select items from the list using
+ arrow keys.
+
+ use_jk_keys: Allow the user to select items from the list using
+ `j` (down) and `k` (up) keys.
+
+ use_emacs_keys: Allow the user to select items from the list using
+ `Ctrl+N` (down) and `Ctrl+P` (up) keys.
+ instruction: A message describing how to navigate the menu.
+
+ show_description: Display description of current selection if available.
+
+ Returns:
+ :class:`Question`: Question instance, ready to be prompted (using ``.ask()``).
+ """
+
+ if not (use_arrow_keys or use_jk_keys or use_emacs_keys):
+ raise ValueError(
+ "Some option to move the selection is required. Arrow keys or j/k or "
+ "Emacs keys."
+ )
+
+ merged_style = merge_styles_default(
+ [
+ # Disable the default inverted colours bottom-toolbar behaviour (for
+ # the error message). However it can be re-enabled with a custom
+ # style.
+ Style([("bottom-toolbar", "noreverse")]),
+ style,
+ ]
+ )
+
+ if not callable(validate):
+ raise ValueError("validate must be callable")
+
+ ic = InquirerControl(
+ choices,
+ default,
+ pointer=pointer,
+ initial_choice=initial_choice,
+ show_description=show_description,
+ )
+
+ def get_prompt_tokens() -> List[Tuple[str, str]]:
+ tokens = []
+
+ tokens.append(("class:qmark", qmark))
+ tokens.append(("class:question", " {} ".format(message)))
+
+ if ic.is_answered:
+ nbr_selected = len(ic.selected_options)
+ if nbr_selected == 0:
+ tokens.append(("class:answer", "done"))
+ elif nbr_selected == 1:
+ if isinstance(ic.get_selected_values()[0].title, list):
+ ts = ic.get_selected_values()[0].title
+ tokens.append(
+ (
+ "class:answer",
+ "".join([token[1] for token in ts]), # type:ignore
+ )
+ )
+ else:
+ tokens.append(
+ (
+ "class:answer",
+ "[{}]".format(ic.get_selected_values()[0].title),
+ )
+ )
+ else:
+ tokens.append(
+ ("class:answer", "done ({} selections)".format(nbr_selected))
+ )
+ else:
+ if instruction is not None:
+ tokens.append(("class:instruction", instruction))
+ else:
+ tokens.append(
+ (
+ "class:instruction",
+ "(Use arrow keys to move, "
+ " to select, "
+ " to toggle, "
+ " to invert)",
+ )
+ )
+ return tokens
+
+ def get_selected_values() -> List[Any]:
+ return [c.value for c in ic.get_selected_values()]
+
+ def perform_validation(selected_values: List[str]) -> bool:
+ verdict = validate(selected_values)
+ valid = verdict is True
+
+ if not valid:
+ if verdict is False:
+ error_text = INVALID_INPUT
+ else:
+ error_text = str(verdict)
+
+ error_message = FormattedText([("class:validation-toolbar", error_text)])
+
+ ic.error_message = (
+ error_message if not valid and ic.submission_attempted else None # type: ignore[assignment]
+ )
+
+ return valid
+
+ layout = common.create_inquirer_layout(ic, get_prompt_tokens, **kwargs)
+
+ bindings = KeyBindings()
+
+ @bindings.add(Keys.ControlQ, eager=True)
+ @bindings.add(Keys.ControlC, eager=True)
+ def _(event):
+ event.app.exit(exception=KeyboardInterrupt, style="class:aborting")
+
+ @bindings.add(" ", eager=True)
+ def toggle(_event):
+ pointed_choice = ic.get_pointed_at().value
+ if pointed_choice in ic.selected_options:
+ ic.selected_options.remove(pointed_choice)
+ else:
+ ic.selected_options.append(pointed_choice)
+
+ perform_validation(get_selected_values())
+
+ @bindings.add("i", eager=True)
+ def invert(_event):
+ inverted_selection = [
+ c.value
+ for c in ic.choices
+ if not isinstance(c, Separator)
+ and c.value not in ic.selected_options
+ and not c.disabled
+ ]
+ ic.selected_options = inverted_selection
+
+ perform_validation(get_selected_values())
+
+ @bindings.add("a", eager=True)
+ def all(_event):
+ all_selected = True # all choices have been selected
+ for c in ic.choices:
+ if (
+ not isinstance(c, Separator)
+ and c.value not in ic.selected_options
+ and not c.disabled
+ ):
+ # add missing ones
+ ic.selected_options.append(c.value)
+ all_selected = False
+ if all_selected:
+ ic.selected_options = []
+
+ perform_validation(get_selected_values())
+
+ def move_cursor_down(event):
+ ic.select_next()
+ while not ic.is_selection_valid():
+ ic.select_next()
+
+ def move_cursor_up(event):
+ ic.select_previous()
+ while not ic.is_selection_valid():
+ ic.select_previous()
+
+ if use_arrow_keys:
+ bindings.add(Keys.Down, eager=True)(move_cursor_down)
+ bindings.add(Keys.Up, eager=True)(move_cursor_up)
+
+ if use_jk_keys:
+ bindings.add("j", eager=True)(move_cursor_down)
+ bindings.add("k", eager=True)(move_cursor_up)
+
+ if use_emacs_keys:
+ bindings.add(Keys.ControlN, eager=True)(move_cursor_down)
+ bindings.add(Keys.ControlP, eager=True)(move_cursor_up)
+
+ @bindings.add(Keys.ControlM, eager=True)
+ def set_answer(event):
+ selected_values = get_selected_values()
+ ic.submission_attempted = True
+
+ if perform_validation(selected_values):
+ ic.is_answered = True
+ event.app.exit(result=selected_values)
+
+ @bindings.add(Keys.Any)
+ def other(_event):
+ """Disallow inserting other text."""
+
+ return Question(
+ Application(
+ layout=layout,
+ key_bindings=bindings,
+ style=merged_style,
+ **utils.used_kwargs(kwargs, Application.__init__),
+ )
+ )
diff --git a/utils/questionary/questionary/prompts/common.py b/utils/questionary/questionary/prompts/common.py
new file mode 100644
index 00000000..634136d0
--- /dev/null
+++ b/utils/questionary/questionary/prompts/common.py
@@ -0,0 +1,579 @@
+import inspect
+from typing import Any
+from typing import Callable
+from typing import Dict
+from typing import List
+from typing import Optional
+from typing import Sequence
+from typing import Tuple
+from typing import Union
+
+from prompt_toolkit import PromptSession
+from prompt_toolkit.filters import Always
+from prompt_toolkit.filters import Condition
+from prompt_toolkit.filters import IsDone
+from prompt_toolkit.layout import ConditionalContainer
+from prompt_toolkit.layout import FormattedTextControl
+from prompt_toolkit.layout import HSplit
+from prompt_toolkit.layout import Layout
+from prompt_toolkit.layout import Window
+from prompt_toolkit.styles import Style
+from prompt_toolkit.validation import ValidationError
+from prompt_toolkit.validation import Validator
+
+from ..constants import DEFAULT_SELECTED_POINTER
+from ..constants import DEFAULT_STYLE
+from ..constants import INDICATOR_SELECTED
+from ..constants import INDICATOR_UNSELECTED
+from ..constants import INVALID_INPUT
+
+# This is a cut-down version of `prompt_toolkit.formatted_text.AnyFormattedText`
+# which does not exist in v2 of prompt_toolkit
+FormattedText = Union[
+ str,
+ List[Tuple[str, str]],
+ List[Tuple[str, str, Callable[[Any], None]]],
+ None,
+]
+
+
+class Choice:
+ """One choice in a :meth:`select`, :meth:`rawselect` or :meth:`checkbox`.
+
+ Args:
+ title: Text shown in the selection list.
+
+ value: Value returned, when the choice is selected. If this argument
+ is `None` or unset, then the value of `title` is used.
+
+ disabled: If set, the choice can not be selected by the user. The
+ provided text is used to explain, why the selection is
+ disabled.
+
+ checked: Preselect this choice when displaying the options.
+
+ shortcut_key: Key shortcut used to select this item.
+
+ description: Optional description of the item that can be displayed.
+ """
+
+ title: FormattedText
+ """Display string for the choice"""
+
+ value: Optional[Any]
+ """Value of the choice"""
+
+ disabled: Optional[str]
+ """Whether the choice can be selected"""
+
+ checked: Optional[bool]
+ """Whether the choice is initially selected"""
+
+ shortcut_key: Optional[str]
+ """A shortcut key for the choice"""
+
+ description: Optional[str]
+ """Choice description"""
+
+ def __init__(
+ self,
+ title: FormattedText,
+ value: Optional[Any] = None,
+ disabled: Optional[str] = None,
+ checked: Optional[bool] = False,
+ shortcut_key: Optional[Union[str, bool]] = True,
+ description: Optional[str] = None,
+ ) -> None:
+ self.disabled = disabled
+ self.title = title
+ self.checked = checked if checked is not None else False
+ self.description = description
+
+ if value is not None:
+ self.value = value
+ elif isinstance(title, list):
+ self.value = "".join([token[1] for token in title])
+ else:
+ self.value = title
+
+ if shortcut_key is not None:
+ if isinstance(shortcut_key, bool):
+ self.auto_shortcut = shortcut_key
+ self.shortcut_key = None
+ else:
+ self.shortcut_key = str(shortcut_key)
+ self.auto_shortcut = False
+ else:
+ self.shortcut_key = None
+ self.auto_shortcut = True
+
+ @staticmethod
+ def build(c: Union[str, "Choice", Dict[str, Any]]) -> "Choice":
+ """Create a choice object from different representations.
+
+ Args:
+ c: Either a :obj:`str`, :class:`Choice` or :obj:`dict` with
+ ``name``, ``value``, ``disabled``, ``checked`` and
+ ``key`` properties.
+
+ Returns:
+ An instance of the :class:`Choice` object.
+ """
+
+ if isinstance(c, Choice):
+ return c
+ elif isinstance(c, str):
+ return Choice(c, c)
+ else:
+ return Choice(
+ c.get("name"),
+ c.get("value"),
+ c.get("disabled", None),
+ c.get("checked"),
+ c.get("key"),
+ c.get("description", None),
+ )
+
+ def get_shortcut_title(self):
+ if self.shortcut_key is None:
+ return "-) "
+ else:
+ return "{}) ".format(self.shortcut_key)
+
+
+class Separator(Choice):
+ """Used to space/separate choices group."""
+
+ default_separator: str = "-" * 15
+ """The default separator used if none is specified"""
+
+ line: str
+ """The string being used as a separator"""
+
+ def __init__(self, line: Optional[str] = None) -> None:
+ """Create a separator in a list.
+
+ Args:
+ line: Text to be displayed in the list, by default uses ``---``.
+ """
+
+ self.line = line or self.default_separator
+ super().__init__(self.line, None, "-")
+
+
+class InquirerControl(FormattedTextControl):
+ SHORTCUT_KEYS = [
+ "1",
+ "2",
+ "3",
+ "4",
+ "5",
+ "6",
+ "7",
+ "8",
+ "9",
+ "0",
+ "a",
+ "b",
+ "c",
+ "d",
+ "e",
+ "f",
+ "g",
+ "h",
+ "i",
+ "j",
+ "k",
+ "l",
+ "m",
+ "n",
+ "o",
+ "p",
+ "q",
+ "r",
+ "s",
+ "t",
+ "u",
+ "v",
+ "w",
+ "x",
+ "y",
+ "z",
+ ]
+
+ choices: List[Choice]
+ default: Optional[Union[str, Choice, Dict[str, Any]]]
+ selected_options: List[Any]
+ use_indicator: bool
+ use_shortcuts: bool
+ use_arrow_keys: bool
+ pointer: Optional[str]
+ pointed_at: int
+ is_answered: bool
+ show_description: bool
+
+ def __init__(
+ self,
+ choices: Sequence[Union[str, Choice, Dict[str, Any]]],
+ default: Optional[Union[str, Choice, Dict[str, Any]]] = None,
+ pointer: Optional[str] = DEFAULT_SELECTED_POINTER,
+ use_indicator: bool = True,
+ use_shortcuts: bool = False,
+ show_selected: bool = False,
+ show_description: bool = True,
+ use_arrow_keys: bool = True,
+ initial_choice: Optional[Union[str, Choice, Dict[str, Any]]] = None,
+ **kwargs: Any,
+ ):
+ self.use_indicator = use_indicator
+ self.use_shortcuts = use_shortcuts
+ self.show_selected = show_selected
+ self.show_description = show_description
+ self.use_arrow_keys = use_arrow_keys
+ self.default = default
+ self.pointer = pointer
+
+ if isinstance(default, Choice):
+ default = default.value
+
+ choices_values = [
+ choice.value for choice in choices if isinstance(choice, Choice)
+ ]
+
+ if (
+ default is not None
+ and default not in choices
+ and default not in choices_values
+ ):
+ raise ValueError(
+ f"Invalid `default` value passed. The value (`{default}`) "
+ f"does not exist in the set of choices. Please make sure the "
+ f"default value is one of the available choices."
+ )
+
+ if initial_choice is None:
+ pointed_at = None
+ elif initial_choice in choices:
+ pointed_at = choices.index(initial_choice)
+ elif initial_choice in choices_values:
+ for k, choice in enumerate(choices):
+ if isinstance(choice, Choice):
+ if choice.value == initial_choice:
+ pointed_at = k
+ break
+
+ else:
+ raise ValueError(
+ f"Invalid `initial_choice` value passed. The value "
+ f"(`{initial_choice}`) does not exist in "
+ f"the set of choices. Please make sure the initial value is "
+ f"one of the available choices."
+ )
+
+ self.is_answered = False
+ self.choices = []
+ self.submission_attempted = False
+ self.error_message = None
+ self.selected_options = []
+
+ self._init_choices(choices, pointed_at)
+ self._assign_shortcut_keys()
+
+ super().__init__(self._get_choice_tokens, **kwargs)
+
+ if not self.is_selection_valid():
+ raise ValueError(
+ f"Invalid 'initial_choice' value ('{initial_choice}'). "
+ f"It must be a selectable value."
+ )
+
+ def _is_selected(self, choice: Choice):
+ if isinstance(self.default, Choice):
+ compare_default = self.default == choice
+ else:
+ compare_default = self.default == choice.value
+ return choice.checked or compare_default and self.default is not None
+
+ def _assign_shortcut_keys(self):
+ available_shortcuts = self.SHORTCUT_KEYS[:]
+
+ # first, make sure we do not double assign a shortcut
+ for c in self.choices:
+ if c.shortcut_key is not None:
+ if c.shortcut_key in available_shortcuts:
+ available_shortcuts.remove(c.shortcut_key)
+ else:
+ raise ValueError(
+ "Invalid shortcut '{}'"
+ "for choice '{}'. Shortcuts "
+ "should be single characters or numbers. "
+ "Make sure that all your shortcuts are "
+ "unique.".format(c.shortcut_key, c.title)
+ )
+
+ shortcut_idx = 0
+ for c in self.choices:
+ if c.auto_shortcut and not c.disabled:
+ c.shortcut_key = available_shortcuts[shortcut_idx]
+ shortcut_idx += 1
+
+ if shortcut_idx == len(available_shortcuts):
+ break # fail gracefully if we run out of shortcuts
+
+ def _init_choices(
+ self,
+ choices: Sequence[Union[str, Choice, Dict[str, Any]]],
+ pointed_at: Optional[int],
+ ):
+ # helper to convert from question format to internal format
+ self.choices = []
+
+ if pointed_at is not None:
+ self.pointed_at = pointed_at
+
+ for i, c in enumerate(choices):
+ choice = Choice.build(c)
+
+ if self._is_selected(choice):
+ self.selected_options.append(choice.value)
+
+ if pointed_at is None and not choice.disabled:
+ # find the first (available) choice
+ self.pointed_at = pointed_at = i
+
+ self.choices.append(choice)
+
+ @property
+ def choice_count(self) -> int:
+ return len(self.choices)
+
+ def _get_choice_tokens(self):
+ tokens = []
+
+ def append(index: int, choice: Choice):
+ # use value to check if option has been selected
+ selected = choice.value in self.selected_options
+
+ if index == self.pointed_at:
+ if self.pointer is not None:
+ tokens.append(("class:pointer", " {} ".format(self.pointer)))
+ else:
+ tokens.append(("class:text", " " * 3))
+
+ tokens.append(("[SetCursorPosition]", ""))
+ else:
+ pointer_length = len(self.pointer) if self.pointer is not None else 1
+ tokens.append(("class:text", " " * (2 + pointer_length)))
+
+ if isinstance(choice, Separator):
+ tokens.append(("class:separator", "{}".format(choice.title)))
+ elif choice.disabled: # disabled
+ if isinstance(choice.title, list):
+ tokens.append(
+ ("class:selected" if selected else "class:disabled", "- ")
+ )
+ tokens.extend(choice.title)
+ else:
+ tokens.append(
+ (
+ "class:selected" if selected else "class:disabled",
+ "- {}".format(choice.title),
+ )
+ )
+
+ tokens.append(
+ (
+ "class:selected" if selected else "class:disabled",
+ "{}".format(
+ ""
+ if isinstance(choice.disabled, bool)
+ else " ({})".format(choice.disabled)
+ ),
+ )
+ )
+ else:
+ shortcut = choice.get_shortcut_title() if self.use_shortcuts else ""
+
+ if selected:
+ if self.use_indicator:
+ indicator = INDICATOR_SELECTED + " "
+ else:
+ indicator = ""
+
+ tokens.append(("class:selected", "{}".format(indicator)))
+ else:
+ if self.use_indicator:
+ indicator = INDICATOR_UNSELECTED + " "
+ else:
+ indicator = ""
+
+ tokens.append(("class:text", "{}".format(indicator)))
+
+ if isinstance(choice.title, list):
+ tokens.extend(choice.title)
+ elif selected:
+ tokens.append(
+ ("class:selected", "{}{}".format(shortcut, choice.title))
+ )
+ elif index == self.pointed_at:
+ tokens.append(
+ ("class:highlighted", "{}{}".format(shortcut, choice.title))
+ )
+ else:
+ tokens.append(("class:text", "{}{}".format(shortcut, choice.title)))
+
+ tokens.append(("", "\n"))
+
+ # prepare the select choices
+ for i, c in enumerate(self.choices):
+ append(i, c)
+
+ if self.show_selected:
+ current = self.get_pointed_at()
+
+ answer = current.get_shortcut_title() if self.use_shortcuts else ""
+
+ answer += (
+ current.title if isinstance(current.title, str) else current.title[0][1]
+ )
+
+ tokens.append(("class:text", " Answer: {}".format(answer)))
+
+ if self.show_description:
+ current = self.get_pointed_at()
+
+ description = current.description
+
+ if description is not None:
+ tokens.append(("class:text", " Description: {}".format(description)))
+ else:
+ tokens.pop() # Remove last newline.
+ return tokens
+
+ def is_selection_a_separator(self) -> bool:
+ selected = self.choices[self.pointed_at]
+ return isinstance(selected, Separator)
+
+ def is_selection_disabled(self) -> Optional[str]:
+ return self.choices[self.pointed_at].disabled
+
+ def is_selection_valid(self) -> bool:
+ return not self.is_selection_disabled() and not self.is_selection_a_separator()
+
+ def select_previous(self) -> None:
+ self.pointed_at = (self.pointed_at - 1) % self.choice_count
+
+ def select_next(self) -> None:
+ self.pointed_at = (self.pointed_at + 1) % self.choice_count
+
+ def get_pointed_at(self) -> Choice:
+ return self.choices[self.pointed_at]
+
+ def get_selected_values(self) -> List[Choice]:
+ # get values not labels
+ return [
+ c
+ for c in self.choices
+ if (not isinstance(c, Separator) and c.value in self.selected_options)
+ ]
+
+
+def build_validator(validate: Any) -> Optional[Validator]:
+ if validate:
+ if inspect.isclass(validate) and issubclass(validate, Validator):
+ return validate()
+ elif isinstance(validate, Validator):
+ return validate
+ elif callable(validate):
+
+ class _InputValidator(Validator):
+ def validate(self, document):
+ verdict = validate(document.text)
+ if verdict is not True:
+ if verdict is False:
+ verdict = INVALID_INPUT
+ raise ValidationError(
+ message=verdict, cursor_position=len(document.text)
+ )
+
+ return _InputValidator()
+ return None
+
+
+def _fix_unecessary_blank_lines(ps: PromptSession) -> None:
+ """This is a fix for additional empty lines added by prompt toolkit.
+
+ This assumes the layout of the default session doesn't change, if it
+ does, this needs an update."""
+
+ default_container = ps.layout.container
+
+ default_buffer_window = (
+ default_container.get_children()[0].content.get_children()[1].content # type: ignore[attr-defined]
+ )
+
+ assert isinstance(default_buffer_window, Window)
+ # this forces the main window to stay as small as possible, avoiding
+ # empty lines in selections
+ default_buffer_window.dont_extend_height = Always()
+ default_buffer_window.always_hide_cursor = Always()
+
+
+def create_inquirer_layout(
+ ic: InquirerControl,
+ get_prompt_tokens: Callable[[], List[Tuple[str, str]]],
+ **kwargs: Any,
+) -> Layout:
+ """Create a layout combining question and inquirer selection."""
+
+ ps: PromptSession = PromptSession(
+ get_prompt_tokens, reserve_space_for_menu=0, **kwargs
+ )
+ _fix_unecessary_blank_lines(ps)
+
+ validation_prompt: PromptSession = PromptSession(
+ bottom_toolbar=lambda: ic.error_message, **kwargs
+ )
+
+ return Layout(
+ HSplit(
+ [
+ ps.layout.container,
+ ConditionalContainer(Window(ic), filter=~IsDone()),
+ ConditionalContainer(
+ validation_prompt.layout.container,
+ filter=Condition(lambda: ic.error_message is not None),
+ ),
+ ]
+ )
+ )
+
+
+def print_formatted_text(text: str, style: Optional[str] = None, **kwargs: Any) -> None:
+ """Print formatted text.
+
+ Sometimes you want to spice up your printed messages a bit,
+ :meth:`questionary.print` is a helper to do just that.
+
+ Example:
+
+ >>> import questionary
+ >>> questionary.print("Hello World 🦄", style="bold italic fg:darkred")
+ Hello World 🦄
+
+ .. image:: ../images/print.gif
+
+ Args:
+ text: Text to be printed.
+ style: Style used for printing. The style argument uses the
+ prompt :ref:`toolkit style strings `.
+ """
+ from prompt_toolkit import print_formatted_text as pt_print
+ from prompt_toolkit.formatted_text import FormattedText as FText
+
+ if style is not None:
+ text_style = Style([("text", style)])
+ else:
+ text_style = DEFAULT_STYLE
+
+ pt_print(FText([("class:text", text)]), style=text_style, **kwargs)
diff --git a/utils/questionary/questionary/prompts/confirm.py b/utils/questionary/questionary/prompts/confirm.py
new file mode 100644
index 00000000..1fb2c52b
--- /dev/null
+++ b/utils/questionary/questionary/prompts/confirm.py
@@ -0,0 +1,133 @@
+from typing import Any
+from typing import Optional
+
+from prompt_toolkit import PromptSession
+from prompt_toolkit.formatted_text import to_formatted_text
+from prompt_toolkit.key_binding import KeyBindings
+from prompt_toolkit.keys import Keys
+from prompt_toolkit.styles import Style
+
+from ..constants import DEFAULT_QUESTION_PREFIX
+from ..constants import NO
+from ..constants import NO_OR_YES
+from ..constants import YES
+from ..constants import YES_OR_NO
+from ..question import Question
+from ..styles import merge_styles_default
+
+
+def confirm(
+ message: str,
+ default: bool = True,
+ qmark: str = DEFAULT_QUESTION_PREFIX,
+ style: Optional[Style] = None,
+ auto_enter: bool = True,
+ instruction: Optional[str] = None,
+ **kwargs: Any,
+) -> Question:
+ """A yes or no question. The user can either confirm or deny.
+
+ This question type can be used to prompt the user for a confirmation
+ of a yes-or-no question. If the user just hits enter, the default
+ value will be returned.
+
+ Example:
+ >>> import questionary
+ >>> questionary.confirm("Are you amazed?").ask()
+ ? Are you amazed? Yes
+ True
+
+ .. image:: ../images/confirm.gif
+
+ This is just a really basic example, the prompt can be customised using the
+ parameters.
+
+
+ Args:
+ message: Question text.
+
+ default: Default value will be returned if the user just hits
+ enter.
+
+ qmark: Question prefix displayed in front of the question.
+ By default this is a ``?``.
+
+ style: A custom color and style for the question parts. You can
+ configure colors as well as font types for different elements.
+
+ auto_enter: If set to `False`, the user needs to press the 'enter' key to
+ accept their answer. If set to `True`, a valid input will be
+ accepted without the need to press 'Enter'.
+
+ instruction: A message describing how to proceed through the
+ confirmation prompt.
+ Returns:
+ :class:`Question`: Question instance, ready to be prompted (using `.ask()`).
+ """
+ merged_style = merge_styles_default([style])
+
+ status = {"answer": None, "complete": False}
+
+ def get_prompt_tokens():
+ tokens = []
+
+ tokens.append(("class:qmark", qmark))
+ tokens.append(("class:question", " {} ".format(message)))
+
+ if instruction is not None:
+ tokens.append(("class:instruction", instruction))
+ elif not status["complete"]:
+ _instruction = YES_OR_NO if default else NO_OR_YES
+ tokens.append(("class:instruction", "{} ".format(_instruction)))
+
+ if status["answer"] is not None:
+ answer = YES if status["answer"] else NO
+ tokens.append(("class:answer", answer))
+
+ return to_formatted_text(tokens)
+
+ def exit_with_result(event):
+ status["complete"] = True
+ event.app.exit(result=status["answer"])
+
+ bindings = KeyBindings()
+
+ @bindings.add(Keys.ControlQ, eager=True)
+ @bindings.add(Keys.ControlC, eager=True)
+ def _(event):
+ event.app.exit(exception=KeyboardInterrupt, style="class:aborting")
+
+ @bindings.add("n")
+ @bindings.add("N")
+ def key_n(event):
+ status["answer"] = False
+ if auto_enter:
+ exit_with_result(event)
+
+ @bindings.add("y")
+ @bindings.add("Y")
+ def key_y(event):
+ status["answer"] = True
+ if auto_enter:
+ exit_with_result(event)
+
+ @bindings.add(Keys.ControlH)
+ def key_backspace(event):
+ status["answer"] = None
+
+ @bindings.add(Keys.ControlM, eager=True)
+ def set_answer(event):
+ if status["answer"] is None:
+ status["answer"] = default
+
+ exit_with_result(event)
+
+ @bindings.add(Keys.Any)
+ def other(event):
+ """Disallow inserting other text."""
+
+ return Question(
+ PromptSession(
+ get_prompt_tokens, key_bindings=bindings, style=merged_style, **kwargs
+ ).app
+ )
diff --git a/utils/questionary/questionary/prompts/password.py b/utils/questionary/questionary/prompts/password.py
new file mode 100644
index 00000000..35ae893a
--- /dev/null
+++ b/utils/questionary/questionary/prompts/password.py
@@ -0,0 +1,61 @@
+from typing import Any
+from typing import Optional
+
+from .. import Style
+from ..constants import DEFAULT_QUESTION_PREFIX
+from ..prompts import text
+from ..question import Question
+
+
+def password(
+ message: str,
+ default: str = "",
+ validate: Any = None,
+ qmark: str = DEFAULT_QUESTION_PREFIX,
+ style: Optional[Style] = None,
+ **kwargs: Any,
+) -> Question:
+ """A text input where a user can enter a secret which won't be displayed on the CLI.
+
+ This question type can be used to prompt the user for information
+ that should not be shown in the command line. The typed text will be
+ replaced with ``*``.
+
+ Example:
+ >>> import questionary
+ >>> questionary.password("What's your secret?").ask()
+ ? What's your secret? ********
+ 'secret42'
+
+ .. image:: ../images/password.gif
+
+ This is just a really basic example, the prompt can be customised using the
+ parameters.
+
+ Args:
+ message: Question text.
+
+ default: Default value will be returned if the user just hits
+ enter.
+
+ validate: Require the entered value to pass a validation. The
+ value can not be submitted until the validator accepts
+ it (e.g. to check minimum password length).
+
+ This can either be a function accepting the input and
+ returning a boolean, or an class reference to a
+ subclass of the prompt toolkit Validator class.
+
+ qmark: Question prefix displayed in front of the question.
+ By default this is a ``?``.
+
+ style: A custom color and style for the question parts. You can
+ configure colors as well as font types for different elements.
+
+ Returns:
+ :class:`Question`: Question instance, ready to be prompted (using ``.ask()``).
+ """
+
+ return text.text(
+ message, default, validate, qmark, style, is_password=True, **kwargs
+ )
diff --git a/utils/questionary/questionary/prompts/path.py b/utils/questionary/questionary/prompts/path.py
new file mode 100644
index 00000000..e43bd0fd
--- /dev/null
+++ b/utils/questionary/questionary/prompts/path.py
@@ -0,0 +1,243 @@
+import os
+from typing import Any
+from typing import Callable
+from typing import Iterable
+from typing import List
+from typing import Optional
+from typing import Tuple
+
+from prompt_toolkit.completion import CompleteEvent
+from prompt_toolkit.completion import Completion
+from prompt_toolkit.completion import PathCompleter
+from prompt_toolkit.completion.base import Completer
+from prompt_toolkit.document import Document
+from prompt_toolkit.key_binding import KeyBindings
+from prompt_toolkit.key_binding.key_processor import KeyPressEvent
+from prompt_toolkit.keys import Keys
+from prompt_toolkit.lexers import SimpleLexer
+from prompt_toolkit.shortcuts.prompt import CompleteStyle
+from prompt_toolkit.shortcuts.prompt import PromptSession
+from prompt_toolkit.styles import Style
+
+from ..constants import DEFAULT_QUESTION_PREFIX
+from ..prompts.common import build_validator
+from ..question import Question
+from ..styles import merge_styles_default
+
+
+class GreatUXPathCompleter(PathCompleter):
+ """Wraps :class:`prompt_toolkit.completion.PathCompleter`.
+
+ Makes sure completions for directories end with a path separator. Also make sure
+ the right path separator is used. Checks if `get_paths` returns list of existing
+ directories.
+ """
+
+ def __init__(
+ self,
+ only_directories: bool = False,
+ get_paths: Optional[Callable[[], List[str]]] = None,
+ file_filter: Optional[Callable[[str], bool]] = None,
+ min_input_len: int = 0,
+ expanduser: bool = False,
+ ) -> None:
+ """Adds validation of 'get_paths' to :class:`prompt_toolkit.completion.PathCompleter`.
+
+ Args:
+ only_directories (bool): If True, only directories will be
+ returned, but no files. Defaults to False.
+ get_paths (Callable[[], List[str]], optional): Callable which
+ returns a list of directories to look into when the user enters a
+ relative path. If None, set to (lambda: ["."]). Defaults to None.
+ file_filter (Callable[[str], bool], optional): Callable which
+ takes a filename and returns whether this file should show up in the
+ completion. ``None`` when no filtering has to be done. Defaults to None.
+ min_input_len (int): Don't do autocompletion when the input string
+ is shorter. Defaults to 0.
+ expanduser (bool): If True, tilde (~) is expanded. Defaults to
+ False.
+
+ Raises:
+ ValueError: If any of the by `get_paths` returned directories does not
+ exist.
+ """
+ # if get_paths is None, make it return the current working dir
+ get_paths = get_paths or (lambda: ["."])
+ # validation of get_paths
+ for current_path in get_paths():
+ if not os.path.isdir(current_path):
+ raise (
+ ValueError(
+ "\n Completer for file paths 'get_paths' must return only existing directories, but"
+ f" '{current_path}' does not exist."
+ )
+ )
+ # call PathCompleter __init__
+ super().__init__(
+ only_directories=only_directories,
+ get_paths=get_paths,
+ file_filter=file_filter,
+ min_input_len=min_input_len,
+ expanduser=expanduser,
+ )
+
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ """Get completions.
+
+ Wraps :class:`prompt_toolkit.completion.PathCompleter`. Makes sure completions
+ for directories end with a path separator. Also make sure the right path
+ separator is used.
+ """
+ completions = super(GreatUXPathCompleter, self).get_completions(
+ document, complete_event
+ )
+
+ for completion in completions:
+ # check if the display value ends with a path separator.
+ # first check if display is properly set
+ styled_display = completion.display[0]
+ # styled display is a formatted text (a tuple of the text and its style)
+ # second tuple entry is the text
+ if styled_display[1][-1] == "/":
+ # replace separator with the OS specific one
+ display_text = styled_display[1][:-1] + os.path.sep
+ # update the styled display with the modified text
+ completion.display[0] = (styled_display[0], display_text)
+ # append the separator to the text as well - unclear why the normal
+ # path completer omits it from the text. this improves UX for the
+ # user, as they don't need to type the separator after auto-completing
+ # a directory
+ completion.text += os.path.sep
+ yield completion
+
+
+def path(
+ message: str,
+ default: str = "",
+ qmark: str = DEFAULT_QUESTION_PREFIX,
+ validate: Any = None,
+ completer: Optional[Completer] = None,
+ style: Optional[Style] = None,
+ only_directories: bool = False,
+ get_paths: Optional[Callable[[], List[str]]] = None,
+ file_filter: Optional[Callable[[str], bool]] = None,
+ complete_style: CompleteStyle = CompleteStyle.MULTI_COLUMN,
+ **kwargs: Any,
+) -> Question:
+ """A text input for a file or directory path with autocompletion enabled.
+
+ Example:
+ >>> import questionary
+ >>> questionary.path(
+ >>> "What's the path to the projects version file?"
+ >>> ).ask()
+ ? What's the path to the projects version file? ./pyproject.toml
+ './pyproject.toml'
+
+ .. image:: ../images/path.gif
+
+ This is just a really basic example, the prompt can be customized using the
+ parameters.
+
+ Args:
+ message: Question text.
+
+ default: Default return value (single value).
+
+ qmark: Question prefix displayed in front of the question.
+ By default this is a ``?``.
+
+ complete_style: How autocomplete menu would be shown, it could be ``COLUMN``
+ ``MULTI_COLUMN`` or ``READLINE_LIKE`` from
+ :class:`prompt_toolkit.shortcuts.CompleteStyle`.
+
+ validate: Require the entered value to pass a validation. The
+ value can not be submitted until the validator accepts
+ it (e.g. to check minimum password length).
+
+ This can either be a function accepting the input and
+ returning a boolean, or an class reference to a
+ subclass of the prompt toolkit Validator class.
+
+ completer: A custom completer to use in the prompt. For more information,
+ see `this `_.
+
+ style: A custom color and style for the question parts. You can
+ configure colors as well as font types for different elements.
+
+ only_directories: Only show directories in auto completion. This option
+ does not do anything if a custom ``completer`` is
+ passed.
+
+ get_paths: Set a callable to generate paths to traverse for suggestions. This option
+ does not do anything if a custom ``completer`` is
+ passed.
+
+ file_filter: Optional callable to filter suggested paths. Only paths
+ where the passed callable evaluates to ``True`` will show up in
+ the suggested paths. This does not validate the typed path, e.g.
+ it is still possible for the user to enter a path manually, even
+ though this filter evaluates to ``False``. If in addition to
+ filtering suggestions you also want to validate the result, use
+ ``validate`` in combination with the ``file_filter``.
+
+ Returns:
+ :class:`Question`: Question instance, ready to be prompted (using ``.ask()``).
+ """ # noqa: W505, E501
+ merged_style = merge_styles_default([style])
+
+ def get_prompt_tokens() -> List[Tuple[str, str]]:
+ return [("class:qmark", qmark), ("class:question", " {} ".format(message))]
+
+ validator = build_validator(validate)
+
+ completer = completer or GreatUXPathCompleter(
+ get_paths=get_paths,
+ only_directories=only_directories,
+ file_filter=file_filter,
+ expanduser=True,
+ )
+
+ bindings = KeyBindings()
+
+ @bindings.add(Keys.ControlM, eager=True)
+ def set_answer(event: KeyPressEvent):
+ if event.current_buffer.complete_state is not None:
+ event.current_buffer.complete_state = None
+ elif event.app.current_buffer.validate(set_cursor=True):
+ # When the validation succeeded, accept the input.
+ result_path = event.app.current_buffer.document.text
+ if result_path.endswith(os.path.sep):
+ result_path = result_path[:-1]
+
+ event.app.exit(result=result_path)
+ event.app.current_buffer.append_to_history()
+
+ @bindings.add(os.path.sep, eager=True)
+ def next_segment(event: KeyPressEvent):
+ b = event.app.current_buffer
+
+ if b.complete_state:
+ b.complete_state = None
+
+ current_path = b.document.text
+ if not current_path.endswith(os.path.sep):
+ b.insert_text(os.path.sep)
+
+ b.start_completion(select_first=False)
+
+ p: PromptSession = PromptSession(
+ get_prompt_tokens,
+ lexer=SimpleLexer("class:answer"),
+ style=merged_style,
+ completer=completer,
+ validator=validator,
+ complete_style=complete_style,
+ key_bindings=bindings,
+ **kwargs,
+ )
+ p.default_buffer.reset(Document(default))
+
+ return Question(p.app)
diff --git a/utils/questionary/questionary/prompts/press_any_key_to_continue.py b/utils/questionary/questionary/prompts/press_any_key_to_continue.py
new file mode 100644
index 00000000..402eaeaf
--- /dev/null
+++ b/utils/questionary/questionary/prompts/press_any_key_to_continue.py
@@ -0,0 +1,61 @@
+from typing import Any
+from typing import Optional
+
+from prompt_toolkit import PromptSession
+from prompt_toolkit.formatted_text import to_formatted_text
+from prompt_toolkit.key_binding import KeyBindings
+from prompt_toolkit.keys import Keys
+from prompt_toolkit.styles import Style
+
+from ..question import Question
+from ..styles import merge_styles_default
+
+
+def press_any_key_to_continue(
+ message: Optional[str] = None,
+ style: Optional[Style] = None,
+ **kwargs: Any,
+):
+ """Wait until user presses any key to continue.
+
+ Example:
+ >>> import questionary
+ >>> questionary.press_any_key_to_continue().ask()
+ Press any key to continue...
+ ''
+
+ Args:
+ message: Question text. Defaults to ``"Press any key to continue..."``
+
+ style: A custom color and style for the question parts. You can
+ configure colors as well as font types for different elements.
+
+ Returns:
+ :class:`Question`: Question instance, ready to be prompted (using ``.ask()``).
+ """
+ merged_style = merge_styles_default([style])
+
+ if message is None:
+ message = "Press any key to continue..."
+
+ def get_prompt_tokens():
+ tokens = []
+
+ tokens.append(("class:question", f" {message} "))
+
+ return to_formatted_text(tokens)
+
+ def exit_with_result(event):
+ event.app.exit(result=None)
+
+ bindings = KeyBindings()
+
+ @bindings.add(Keys.Any)
+ def any_key(event):
+ exit_with_result(event)
+
+ return Question(
+ PromptSession(
+ get_prompt_tokens, key_bindings=bindings, style=merged_style, **kwargs
+ ).app
+ )
diff --git a/utils/questionary/questionary/prompts/rawselect.py b/utils/questionary/questionary/prompts/rawselect.py
new file mode 100644
index 00000000..3fe74573
--- /dev/null
+++ b/utils/questionary/questionary/prompts/rawselect.py
@@ -0,0 +1,79 @@
+from typing import Any
+from typing import Dict
+from typing import Optional
+from typing import Sequence
+from typing import Union
+
+from prompt_toolkit.styles import Style
+
+from ..constants import DEFAULT_QUESTION_PREFIX
+from ..constants import DEFAULT_SELECTED_POINTER
+from ..prompts import select
+from ..prompts.common import Choice
+from ..question import Question
+
+
+def rawselect(
+ message: str,
+ choices: Sequence[Union[str, Choice, Dict[str, Any]]],
+ default: Optional[str] = None,
+ qmark: str = DEFAULT_QUESTION_PREFIX,
+ pointer: Optional[str] = DEFAULT_SELECTED_POINTER,
+ style: Optional[Style] = None,
+ **kwargs: Any,
+) -> Question:
+ """Ask the user to select one item from a list of choices using shortcuts.
+
+ The user can only select one option.
+
+ Example:
+ >>> import questionary
+ >>> questionary.rawselect(
+ ... "What do you want to do?",
+ ... choices=[
+ ... "Order a pizza",
+ ... "Make a reservation",
+ ... "Ask for opening hours"
+ ... ]).ask()
+ ? What do you want to do? Order a pizza
+ 'Order a pizza'
+
+ .. image:: ../images/rawselect.gif
+
+ This is just a really basic example, the prompt can be customised using the
+ parameters.
+
+ Args:
+ message: Question text.
+
+ choices: Items shown in the selection, this can contain :class:`Choice` or
+ or :class:`Separator` objects or simple items as strings. Passing
+ :class:`Choice` objects, allows you to configure the item more
+ (e.g. preselecting it or disabling it).
+
+ default: Default return value (single value).
+
+ qmark: Question prefix displayed in front of the question.
+ By default this is a ``?``.
+
+ pointer: Pointer symbol in front of the currently highlighted element.
+ By default this is a ``»``.
+ Use ``None`` to disable it.
+
+ style: A custom color and style for the question parts. You can
+ configure colors as well as font types for different elements.
+
+ Returns:
+ :class:`Question`: Question instance, ready to be prompted (using ``.ask()``).
+ """
+ return select.select(
+ message,
+ choices,
+ default,
+ qmark,
+ pointer,
+ style,
+ use_shortcuts=True,
+ use_arrow_keys=False,
+ **kwargs,
+ )
diff --git a/utils/questionary/questionary/prompts/select.py b/utils/questionary/questionary/prompts/select.py
new file mode 100644
index 00000000..6f3ef8a6
--- /dev/null
+++ b/utils/questionary/questionary/prompts/select.py
@@ -0,0 +1,258 @@
+# -*- coding: utf-8 -*-
+
+from typing import Any
+from typing import Dict
+from typing import Optional
+from typing import Sequence
+from typing import Union
+
+from prompt_toolkit.application import Application
+from prompt_toolkit.key_binding import KeyBindings
+from prompt_toolkit.keys import Keys
+from prompt_toolkit.styles import Style
+
+from .. import utils
+from ..constants import DEFAULT_QUESTION_PREFIX
+from ..constants import DEFAULT_SELECTED_POINTER
+from ..prompts import common
+from ..prompts.common import Choice
+from ..prompts.common import InquirerControl
+from ..prompts.common import Separator
+from ..question import Question
+from ..styles import merge_styles_default
+
+
+def select(
+ message: str,
+ choices: Sequence[Union[str, Choice, Dict[str, Any]]],
+ default: Optional[Union[str, Choice, Dict[str, Any]]] = None,
+ qmark: str = DEFAULT_QUESTION_PREFIX,
+ pointer: Optional[str] = DEFAULT_SELECTED_POINTER,
+ style: Optional[Style] = None,
+ use_shortcuts: bool = False,
+ use_arrow_keys: bool = True,
+ use_indicator: bool = False,
+ use_jk_keys: bool = True,
+ use_emacs_keys: bool = True,
+ show_selected: bool = False,
+ show_description: bool = True,
+ instruction: Optional[str] = None,
+ **kwargs: Any,
+) -> Question:
+ """A list of items to select **one** option from.
+
+ The user can pick one option and confirm it (if you want to allow
+ the user to select multiple options, use :meth:`questionary.checkbox` instead).
+
+ Example:
+ >>> import questionary
+ >>> questionary.select(
+ ... "What do you want to do?",
+ ... choices=[
+ ... "Order a pizza",
+ ... "Make a reservation",
+ ... "Ask for opening hours"
+ ... ]).ask()
+ ? What do you want to do? Order a pizza
+ 'Order a pizza'
+
+ .. image:: ../images/select.gif
+
+ This is just a really basic example, the prompt can be customised using the
+ parameters.
+
+
+ Args:
+ message: Question text
+
+ choices: Items shown in the selection, this can contain :class:`Choice` or
+ or :class:`Separator` objects or simple items as strings. Passing
+ :class:`Choice` objects, allows you to configure the item more
+ (e.g. preselecting it or disabling it).
+
+ default: A value corresponding to a selectable item in the choices,
+ to initially set the pointer position to.
+
+ qmark: Question prefix displayed in front of the question.
+ By default this is a ``?``.
+
+ pointer: Pointer symbol in front of the currently highlighted element.
+ By default this is a ``»``.
+ Use ``None`` to disable it.
+
+ instruction: A hint on how to navigate the menu.
+ It's ``(Use shortcuts)`` if only ``use_shortcuts`` is set
+ to True, ``(Use arrow keys or shortcuts)`` if ``use_arrow_keys``
+ & ``use_shortcuts`` are set and ``(Use arrow keys)`` by default.
+
+ style: A custom color and style for the question parts. You can
+ configure colors as well as font types for different elements.
+
+ use_indicator: Flag to enable the small indicator in front of the
+ list highlighting the current location of the selection
+ cursor.
+
+ use_shortcuts: Allow the user to select items from the list using
+ shortcuts. The shortcuts will be displayed in front of
+ the list items. Arrow keys, j/k keys and shortcuts are
+ not mutually exclusive.
+
+ use_arrow_keys: Allow the user to select items from the list using
+ arrow keys. Arrow keys, j/k keys and shortcuts are not
+ mutually exclusive.
+
+ use_jk_keys: Allow the user to select items from the list using
+ `j` (down) and `k` (up) keys. Arrow keys, j/k keys and
+ shortcuts are not mutually exclusive.
+
+ use_emacs_keys: Allow the user to select items from the list using
+ `Ctrl+N` (down) and `Ctrl+P` (up) keys. Arrow keys, j/k keys,
+ emacs keys and shortcuts are not mutually exclusive.
+
+ show_selected: Display current selection choice at the bottom of list.
+
+ show_description: Display description of current selection if available.
+
+ Returns:
+ :class:`Question`: Question instance, ready to be prompted (using ``.ask()``).
+ """
+ if not (use_arrow_keys or use_shortcuts or use_jk_keys or use_emacs_keys):
+ raise ValueError(
+ (
+ "Some option to move the selection is required. "
+ "Arrow keys, j/k keys, emacs keys, or shortcuts."
+ )
+ )
+
+ if use_shortcuts and use_jk_keys:
+ if any(getattr(c, "shortcut_key", "") in ["j", "k"] for c in choices):
+ raise ValueError(
+ "A choice is trying to register j/k as a "
+ "shortcut key when they are in use as arrow keys "
+ "disable one or the other."
+ )
+
+ if choices is None or len(choices) == 0:
+ raise ValueError("A list of choices needs to be provided.")
+
+ if use_shortcuts and len(choices) > len(InquirerControl.SHORTCUT_KEYS):
+ raise ValueError(
+ "A list with shortcuts supports a maximum of {} "
+ "choices as this is the maximum number "
+ "of keyboard shortcuts that are available. You"
+ "provided {} choices!"
+ "".format(len(InquirerControl.SHORTCUT_KEYS), len(choices))
+ )
+
+ merged_style = merge_styles_default([style])
+
+ ic = InquirerControl(
+ choices,
+ default,
+ pointer=pointer,
+ use_indicator=use_indicator,
+ use_shortcuts=use_shortcuts,
+ show_selected=show_selected,
+ show_description=show_description,
+ use_arrow_keys=use_arrow_keys,
+ initial_choice=default,
+ )
+
+ def get_prompt_tokens():
+ # noinspection PyListCreation
+ tokens = [("class:qmark", qmark), ("class:question", " {} ".format(message))]
+
+ if ic.is_answered:
+ if isinstance(ic.get_pointed_at().title, list):
+ tokens.append(
+ (
+ "class:answer",
+ "".join([token[1] for token in ic.get_pointed_at().title]),
+ )
+ )
+ else:
+ tokens.append(("class:answer", ic.get_pointed_at().title))
+ else:
+ if instruction:
+ tokens.append(("class:instruction", instruction))
+ else:
+ if use_shortcuts and use_arrow_keys:
+ instruction_msg = "(Use shortcuts or arrow keys)"
+ elif use_shortcuts and not use_arrow_keys:
+ instruction_msg = "(Use shortcuts)"
+ else:
+ instruction_msg = "(Use arrow keys)"
+ tokens.append(("class:instruction", instruction_msg))
+
+ return tokens
+
+ layout = common.create_inquirer_layout(ic, get_prompt_tokens, **kwargs)
+
+ bindings = KeyBindings()
+
+ @bindings.add(Keys.ControlQ, eager=True)
+ @bindings.add(Keys.ControlC, eager=True)
+ def _(event):
+ event.app.exit(exception=KeyboardInterrupt, style="class:aborting")
+
+ if use_shortcuts:
+ # add key bindings for choices
+ for i, c in enumerate(ic.choices):
+ if c.shortcut_key is None and not c.disabled and not use_arrow_keys:
+ raise RuntimeError(
+ "{} does not have a shortcut and arrow keys "
+ "for movement are disabled. "
+ "This choice is not reachable.".format(c.title)
+ )
+ if isinstance(c, Separator) or c.shortcut_key is None:
+ continue
+
+ # noinspection PyShadowingNames
+ def _reg_binding(i, keys):
+ # trick out late evaluation with a "function factory":
+ # https://stackoverflow.com/a/3431699
+ @bindings.add(keys, eager=True)
+ def select_choice(event):
+ ic.pointed_at = i
+
+ _reg_binding(i, c.shortcut_key)
+
+ def move_cursor_down(event):
+ ic.select_next()
+ while not ic.is_selection_valid():
+ ic.select_next()
+
+ def move_cursor_up(event):
+ ic.select_previous()
+ while not ic.is_selection_valid():
+ ic.select_previous()
+
+ if use_arrow_keys:
+ bindings.add(Keys.Down, eager=True)(move_cursor_down)
+ bindings.add(Keys.Up, eager=True)(move_cursor_up)
+
+ if use_jk_keys:
+ bindings.add("j", eager=True)(move_cursor_down)
+ bindings.add("k", eager=True)(move_cursor_up)
+
+ if use_emacs_keys:
+ bindings.add(Keys.ControlN, eager=True)(move_cursor_down)
+ bindings.add(Keys.ControlP, eager=True)(move_cursor_up)
+
+ @bindings.add(Keys.ControlM, eager=True)
+ def set_answer(event):
+ ic.is_answered = True
+ event.app.exit(result=ic.get_pointed_at().value)
+
+ @bindings.add(Keys.Any)
+ def other(event):
+ """Disallow inserting other text."""
+
+ return Question(
+ Application(
+ layout=layout,
+ key_bindings=bindings,
+ style=merged_style,
+ **utils.used_kwargs(kwargs, Application.__init__),
+ )
+ )
diff --git a/utils/questionary/questionary/prompts/text.py b/utils/questionary/questionary/prompts/text.py
new file mode 100644
index 00000000..0812104c
--- /dev/null
+++ b/utils/questionary/questionary/prompts/text.py
@@ -0,0 +1,101 @@
+from typing import Any
+from typing import List
+from typing import Optional
+from typing import Tuple
+
+from prompt_toolkit.document import Document
+from prompt_toolkit.lexers import Lexer
+from prompt_toolkit.lexers import SimpleLexer
+from prompt_toolkit.shortcuts.prompt import PromptSession
+from prompt_toolkit.styles import Style
+
+from ..constants import DEFAULT_QUESTION_PREFIX
+from ..constants import INSTRUCTION_MULTILINE
+from ..prompts.common import build_validator
+from ..question import Question
+from ..styles import merge_styles_default
+
+
+def text(
+ message: str,
+ default: str = "",
+ validate: Any = None,
+ qmark: str = DEFAULT_QUESTION_PREFIX,
+ style: Optional[Style] = None,
+ multiline: bool = False,
+ instruction: Optional[str] = None,
+ lexer: Optional[Lexer] = None,
+ **kwargs: Any,
+) -> Question:
+ """Prompt the user to enter a free text message.
+
+ This question type can be used to prompt the user for some text input.
+
+ Example:
+ >>> import questionary
+ >>> questionary.text("What's your first name?").ask()
+ ? What's your first name? Tom
+ 'Tom'
+
+ .. image:: ../images/text.gif
+
+ This is just a really basic example, the prompt can be customised using the
+ parameters.
+
+ Args:
+ message: Question text.
+
+ default: Default value will be returned if the user just hits
+ enter.
+
+ validate: Require the entered value to pass a validation. The
+ value can not be submitted until the validator accepts
+ it (e.g. to check minimum password length).
+
+ This can either be a function accepting the input and
+ returning a boolean, or an class reference to a
+ subclass of the prompt toolkit Validator class.
+
+ qmark: Question prefix displayed in front of the question.
+ By default this is a ``?``.
+
+ style: A custom color and style for the question parts. You can
+ configure colors as well as font types for different elements.
+
+ multiline: If ``True``, multiline input will be enabled.
+
+ instruction: Write instructions for the user if needed. If ``None``
+ and ``multiline=True``, some instructions will appear.
+
+ lexer: Supply a valid lexer to style the answer. Leave empty to
+ use a simple one by default.
+
+ kwargs: Additional arguments, they will be passed to prompt toolkit.
+
+ Returns:
+ :class:`Question`: Question instance, ready to be prompted (using ``.ask()``).
+ """
+ merged_style = merge_styles_default([style])
+ lexer = lexer or SimpleLexer("class:answer")
+ validator = build_validator(validate)
+
+ if instruction is None and multiline:
+ instruction = INSTRUCTION_MULTILINE
+
+ def get_prompt_tokens() -> List[Tuple[str, str]]:
+ result = [("class:qmark", qmark), ("class:question", " {} ".format(message))]
+ if instruction:
+ result.append(("class:instruction", " {} ".format(instruction)))
+ return result
+
+ p: PromptSession = PromptSession(
+ get_prompt_tokens,
+ style=merged_style,
+ validator=validator,
+ lexer=lexer,
+ multiline=multiline,
+ **kwargs,
+ )
+ p.default_buffer.reset(Document(default))
+
+ return Question(p.app)
diff --git a/utils/questionary/questionary/py.typed b/utils/questionary/questionary/py.typed
new file mode 100644
index 00000000..e69de29b
diff --git a/utils/questionary/questionary/question.py b/utils/questionary/questionary/question.py
new file mode 100644
index 00000000..28b4a12f
--- /dev/null
+++ b/utils/questionary/questionary/question.py
@@ -0,0 +1,134 @@
+import sys
+from typing import Any
+
+import prompt_toolkit.patch_stdout
+from prompt_toolkit import Application
+
+from . import utils
+from .constants import DEFAULT_KBI_MESSAGE
+
+
+class Question:
+ """A question to be prompted.
+
+ This is an internal class. Questions should be created using the
+ predefined questions (e.g. text or password)."""
+
+ application: "Application[Any]"
+ should_skip_question: bool
+ default: Any
+
+ def __init__(self, application: "Application[Any]") -> None:
+ self.application = application
+ self.should_skip_question = False
+ self.default = None
+
+ async def ask_async(
+ self, patch_stdout: bool = False, kbi_msg: str = DEFAULT_KBI_MESSAGE
+ ) -> Any:
+ """Ask the question using asyncio and return user response.
+
+ Args:
+ patch_stdout: Ensure that the prompt renders correctly if other threads
+ are printing to stdout.
+
+ kbi_msg: The message to be printed on a keyboard interrupt.
+
+ Returns:
+ `Any`: The answer from the question.
+ """
+
+ try:
+ sys.stdout.flush()
+ return await self.unsafe_ask_async(patch_stdout)
+ except KeyboardInterrupt:
+ print("\n{}\n".format(kbi_msg))
+ return None
+
+ def ask(
+ self, patch_stdout: bool = False, kbi_msg: str = DEFAULT_KBI_MESSAGE
+ ) -> Any:
+ """Ask the question synchronously and return user response.
+
+ Args:
+ patch_stdout: Ensure that the prompt renders correctly if other threads
+ are printing to stdout.
+
+ kbi_msg: The message to be printed on a keyboard interrupt.
+
+ Returns:
+ `Any`: The answer from the question.
+ """
+
+ try:
+ return self.unsafe_ask(patch_stdout)
+ except KeyboardInterrupt:
+ print("\n{}\n".format(kbi_msg))
+ return None
+
+ def unsafe_ask(self, patch_stdout: bool = False) -> Any:
+ """Ask the question synchronously and return user response.
+
+ Does not catch keyboard interrupts.
+
+ Args:
+ patch_stdout: Ensure that the prompt renders correctly if other threads
+ are printing to stdout.
+
+ Returns:
+ `Any`: The answer from the question.
+ """
+
+ if self.should_skip_question:
+ return self.default
+
+ if patch_stdout:
+ with prompt_toolkit.patch_stdout.patch_stdout():
+ return self.application.run()
+ else:
+ return self.application.run()
+
+ def skip_if(self, condition: bool, default: Any = None) -> "Question":
+ """Skip the question if flag is set and return the default instead.
+
+ Args:
+ condition: A conditional boolean value.
+ default: The default value to return.
+
+ Returns:
+ :class:`Question`: `self`.
+ """
+
+ self.should_skip_question = condition
+ self.default = default
+ return self
+
+ async def unsafe_ask_async(self, patch_stdout: bool = False) -> Any:
+ """Ask the question using asyncio and return user response.
+
+ Does not catch keyboard interrupts.
+
+ Args:
+ patch_stdout: Ensure that the prompt renders correctly if other threads
+ are printing to stdout.
+
+ Returns:
+ `Any`: The answer from the question.
+ """
+
+ if self.should_skip_question:
+ return self.default
+
+ if not utils.ACTIVATED_ASYNC_MODE:
+ await utils.activate_prompt_toolkit_async_mode()
+
+ if patch_stdout:
+ with prompt_toolkit.patch_stdout.patch_stdout():
+ r = self.application.run_async()
+ else:
+ r = self.application.run_async()
+
+ if utils.is_prompt_toolkit_3():
+ return await r
+ else:
+ return await r.to_asyncio_future() # type: ignore[attr-defined]
diff --git a/utils/questionary/questionary/styles.py b/utils/questionary/questionary/styles.py
new file mode 100644
index 00000000..52adb101
--- /dev/null
+++ b/utils/questionary/questionary/styles.py
@@ -0,0 +1,16 @@
+
+from typing import List
+from typing import Optional
+
+import prompt_toolkit.styles
+
+from .constants import DEFAULT_STYLE
+
+
+def merge_styles_default(styles: List[Optional[prompt_toolkit.styles.Style]]):
+ """Merge a list of styles with the Questionary default style."""
+ filtered_styles: list[prompt_toolkit.styles.BaseStyle] = [DEFAULT_STYLE]
+ # prompt_toolkit's merge_styles works with ``None`` elements, but it's
+ # type-hints says it doesn't.
+ filtered_styles.extend([s for s in styles if s is not None])
+ return prompt_toolkit.styles.merge_styles(filtered_styles)
diff --git a/utils/questionary/questionary/utils.py b/utils/questionary/questionary/utils.py
new file mode 100644
index 00000000..d4fb9419
--- /dev/null
+++ b/utils/questionary/questionary/utils.py
@@ -0,0 +1,78 @@
+import inspect
+from typing import Any
+from typing import Callable
+from typing import Dict
+from typing import List
+from typing import Set
+
+ACTIVATED_ASYNC_MODE = False
+
+
+def is_prompt_toolkit_3() -> bool:
+ from prompt_toolkit import __version__ as ptk_version
+
+ return ptk_version.startswith("3.")
+
+
+def default_values_of(func: Callable[..., Any]) -> List[str]:
+ """Return all parameter names of ``func`` with a default value."""
+
+ signature = inspect.signature(func)
+ return [
+ k
+ for k, v in signature.parameters.items()
+ if v.default is not inspect.Parameter.empty
+ or v.kind != inspect.Parameter.POSITIONAL_OR_KEYWORD
+ ]
+
+
+def arguments_of(func: Callable[..., Any]) -> List[str]:
+ """Return the parameter names of the function ``func``."""
+
+ return list(inspect.signature(func).parameters.keys())
+
+
+def used_kwargs(kwargs: Dict[str, Any], func: Callable[..., Any]) -> Dict[str, Any]:
+ """Returns only the kwargs which can be used by a function.
+
+ Args:
+ kwargs: All available kwargs.
+ func: The function which should be called.
+
+ Returns:
+ Subset of kwargs which are accepted by ``func``.
+ """
+
+ possible_arguments = arguments_of(func)
+
+ return {k: v for k, v in kwargs.items() if k in possible_arguments}
+
+
+def required_arguments(func: Callable[..., Any]) -> List[str]:
+ """Return all arguments of a function that do not have a default value."""
+ defaults = default_values_of(func)
+ args = arguments_of(func)
+
+ if defaults:
+ args = args[: -len(defaults)]
+ return args # all args without default values
+
+
+def missing_arguments(func: Callable[..., Any], argdict: Dict[str, Any]) -> Set[str]:
+ """Return all arguments that are missing to call func."""
+ return set(required_arguments(func)) - set(argdict.keys())
+
+
+async def activate_prompt_toolkit_async_mode() -> None:
+ """Configure prompt toolkit to use the asyncio event loop.
+
+ Needs to be async, so we use the right event loop in py 3.5"""
+ global ACTIVATED_ASYNC_MODE
+
+ if not is_prompt_toolkit_3():
+ # Tell prompt_toolkit to use asyncio for the event loop.
+ import prompt_toolkit as pt
+
+ pt.eventloop.use_asyncio_event_loop() # type: ignore[attr-defined]
+
+ ACTIVATED_ASYNC_MODE = True
diff --git a/utils/questionary/questionary/version.py b/utils/questionary/questionary/version.py
new file mode 100644
index 00000000..cf52fbca
--- /dev/null
+++ b/utils/questionary/questionary/version.py
@@ -0,0 +1 @@
+__version__ = "2.0.2-beta"
diff --git a/utils/relic.py b/utils/relic.py
index f61a13f4..82dd8c5d 100644
--- a/utils/relic.py
+++ b/utils/relic.py
@@ -2,10 +2,14 @@
import time
import math
import pprint
-import questionary
import numpy as np
from collections import Counter
+from typing import Any, Dict, List, Literal, Optional, Tuple, Union
+from .questionary.questionary import select, Choice
+# 改用本地的questionary模块,使之具备show_description功能,基于'tmbo/questionary/pull/330'
+# from questionary import select, Choice # questionary原项目更新并具备当前功能后,可进行替换
+from .relic_constants import *
from .calculated import calculated, Array2dict, get_data_hash, str_just
from .config import (read_json_file, modify_json_file, rewrite_json_file,
RELIC_FILE_NAME, LOADOUT_FILE_NAME, TEAM_FILE_NAME, _, sra_config_obj)
@@ -19,151 +23,54 @@ class Relic:
<<<遗器模块>>>
已完成功能:
1.识别遗器数据 (单次用时约0.5s)
- a.[新增]支持所有稀有度遗器 (识别指定点位色相[黄,紫,蓝,绿])
- 2.保存人物配装
- 3.读取人物配装并装备 (遗器将强制替换,支持精确匹配与模糊匹配)
- 4.[新增]兼容四星遗器:
- a.兼容校验函数 (增加四星遗器副词条档位数据)
- 5.[新增]模糊匹配成功后自动更新相关数据库 (同时会在新旧遗器间建立后继关系)
- 6.[新增]在配装选择界面,打印配装的简要信息
+ a.录入的遗器数据保存在'relics_set.json'文件
+ b.可识别遗器的[部位、套装、稀有度、等级、主词条、副词条]属性
+ c.支持所有稀有度遗器 (识别指定点位色相[黄、紫、蓝、绿])
+ 2.遗器数据匹配
+ a.精确匹配:通过计算与匹配遗器哈希值
+ b.模糊匹配:判断新旧遗器是否存在升级关系,若匹配成功,则新遗器将自动替换配装中的旧遗器,
+ 并在遗器数据中建立后继关系,此功能可通过设置开关
+ 3.遗器数据增强
+ a.支持计算[四星、五星]遗器的副词条的[强化次数、档位总积分、修正数值(提高原数值的小数精度)]
+ 1).对于'速度'属性只能做保守估计,其他属性可做准确计算
+ 2).【新增】可借助其他工具获得'速度'属性的精确值,并手动修改json文件中'速度'属性的小数位,
+ 修改后的数据可永久保留,将不影响遗器哈希值计算与模糊匹配,并用于后续的数值计算
+ b.【新增】支持计算[四星、五星]遗器的主词条的[修正数值]
+ c.【新增】遗器数据打印时的小数精度可通过设置选择,范围为[0,1,2,3]
+ d.基于遗器数据增强的遗器数据校验功能 (可检测出大部分的遗器识别错误),可通过设置开关
+ e.遗器数据增强可通过设置开关
+ 4.保存角色配装
+ a.录入的配装数据保存在'relics_loadout.json'文件
+ b.【新增】可检查配装是否已经存在,存在的配装不重复录入
+ 5.读取角色配装并装备
+ a.基于遗器匹配,遗器将强制'替换',包含[替换己方已装备的遗器、替换对方已装备的遗器]
+ b.自动对遗器的[套装、稀有度]属性进行筛选,加快遗器搜索
+ c.【新增】配装选择时,将会打印配装信息,包含[内外圈套装、遗器主词条名称、属性数值统计]
+ 6.【新增】保存队伍配装
+ a.录入的队伍配装数据保存在'relics_team.json'文件
+ b.录入方式包含[全识别、参考已有的配装数据]
+ c.可检查队伍是否存在冲突遗器
+ 7.【新增】读取队伍配装并装备
+ a.队伍选择时,将会打印队伍信息,包含[角色构成、各角色内外圈套装、各角色遗器主词条名称]
+ b.对当前队伍的角色顺序不做要求
+ b.只支持对已有队伍进行配装,不支持选择相应角色构建队伍
+
待解决问题:
- 1.[已解决]OCR准确率低:
+ 1.【已解决】OCR准确率低:
对于中文识别更换为项目早期的OCR模型;对于数字识别更换为仅包含英文数字的轻量模型
+
待开发功能:
- 1.保存队伍配装
- 2.读取队伍配装并装备
- 3.遗器管理
- a.在模块入口成功识别当前遗器后,可选择进行数据录入、查询可能的遗器历史数据 (基于模糊匹配)
- b.美化遗器打印
- 4.配装管理
- a.可选择对配装重命名
+ 1.配装管理 [删、改] (需考虑队伍配装)
+ 2.对忘却之庭双队配装的保存做额外处理,并检查队伍间的遗器冲突
...
- 相关说明:
- 1.[新增]本模块的所有识别位点均采用百分比相对坐标,以兼容不同平台支持不同分辨率
- 2.[新增]本模块首先会基于安卓模拟器进行测试,再基于PC端测试
+
+ 开发者说明:
+ 1.本模块的所有识别位点均采用百分比相对坐标,以兼容不同平台支持不同分辨率
+ 2.本模块首先会基于安卓模拟器进行测试,再基于PC端测试
+ 3.【新增】本模块的主体功能已全部完成,现转入日常维护与不定时支线功能开发
+ 4.【新增】本模块暂不支持简体中文之外的语言
+ 4.【新增】本模块暂未有开发GUI的计划
"""
- # 静态参数
- equip_set_name = [_("头部"), _("手部"), _("躯干"), _("脚部"), _("位面球"), _("连结绳")]
- """遗器部位名称,已经按游戏界面顺序排序"""
- equip_set_abbr = [_("头"), _("手"), _("衣"), _("鞋"), _("球"), _("绳")]
- """遗器部位简称"""
- relic_set_name = np.array([ # 注:因为数据有时要行取有时要列取,故采用数组存储
- [_("过客"), _("过客"), _("治疗"), _("云无留迹的过客")],
- [_("枪手"), _("枪手"), _("快枪手"), _("野穗伴行的快枪手")],
- [_("圣骑"), _("圣骑"), _("防御"), _("净庭教宗的圣骑士")],
- [_("雪猎"), _("猎人"), _("冰套"), _("密林卧雪的猎人")],
- [_("拳王"), _("拳王"), _("物理"), _("街头出身的拳王")],
- [_("铁卫"), _("铁卫"), _("减伤"), _("戍卫风雪的铁卫")],
- [_("火匠"), _("火匠"), _("火套"), _("熔岩锻铸的火匠")],
- [_("天才"), _("天才"), _("量子"), _("繁星璀璨的天才")],
- [_("乐队"), _("雷电"), _("雷套"), _("激奏雷电的乐队")],
- [_("翔"), _("翔"), _("风套"), _("晨昏交界的翔鹰")],
- [_("怪盗"), _("怪盗"), _("怪盗"), _("流星追迹的怪盗")],
- [_("废"), _("废"), _("虚数"), _("盗匪荒漠的废土客")],
- [_("者"), _("长存"), _("莳者"), _("宝命长存的莳者")],
- [_("信使"), _("信使"), _("速度"), _("骇域漫游的信使")],
- [_("黑塔"), _("太空"), _("空间站"), _("太空封印站")],
- [_("仙"), _("仙"), _("仙舟"), _("不老者的仙舟")],
- [_("公司"), _("公司"), _("命中"), _("泛银河商业公司")],
- [_("贝洛"), _("贝洛"), _("防御"), _("筑城者的贝洛伯格")],
- [_("螺丝"), _("差分"), _("差分"), _("星体差分机")],
- [_("萨尔"), _("停转"), _("停转"), _("停转的萨尔索图")],
- [_("利亚"), _("盗贼"), _("击破"), _("盗贼公国塔利亚")],
- [_("瓦克"), _("瓦克"), _("翁瓦克"), _("生命的翁瓦克")],
- [_("泰科"), _("繁星"), _("繁星"), _("繁星竞技场")],
- [_("伊须"), _("龙骨"), _("龙骨"), _("折断的龙骨")]
- ], dtype=np.str_)
- """遗器套装名称:0-套装散件名的共有词(ocr-必须),1-套装名的特异词(ocr-可选,为了增强鲁棒性),2-玩家惯用简称(print),3-套装全称(json),已按[1.4游戏]遗器筛选界面排序"""
- stats_name = np.array([
- [_("命值"), _("生"), _("生命值")], [_("击力"), _("攻"), _("攻击力")], [_("防御"), _("防"), _("防御力")],
- [_("命值"), _("生"), _("生命值%")], [_("击力"), _("攻"), _("攻击力%")], [_("防御"), _("防"), _("防御力%")],
- [_("度"), _("速"), _("速度")], [_("击率"), _("暴击"), _("暴击率")], [_("击伤"), _("爆伤"), _("暴击伤害")], [_("命中"), _("命中"), _("效果命中")], [_("治疗"), _("治疗"), _("治疗量加成")],
- [_("理"), _("伤害"), _("物理属性伤害")], [_("火"), _("火伤"), _("火属性伤害")], [_("冰"), _("冰伤"), _("冰属性伤害")], [_("雷"), _("雷伤"), _("雷属性伤害")], [_("风"), _("风伤"), _("风属性伤害")],
- [_("量"), _("量子"), _("量子属性伤害")], [_("数"), _("虚数"), _("虚数属性伤害")],
- [_("抵抗"), _("效果抵抗"), _("效果抵抗")], [_("破"), _("击破"), _("击破特攻")], [_("恢复"), _("能"), _("能量恢复效率")]
- ], dtype=np.str_)
- """遗器属性名称:0-属性名的特异词(ocr-不区分大小词条),1-玩家惯用简称(print),2-属性全称(json-区分大小词条)"""
- not_pre_stats = [_("生命值"), _("攻击力"), _("防御力"), _("速度")]
- """遗器的整数属性名称"""
- base_stats_name = np.concatenate((stats_name[:2],stats_name[3:-3],stats_name[-2:]), axis=0)
- """遗器主属性名称"""
- base_stats_name4equip = [base_stats_name[0:1],
- base_stats_name[1:2],
- np.vstack((base_stats_name[2:5],base_stats_name[6:10])),
- base_stats_name[2:6],
- np.vstack((base_stats_name[2:5],base_stats_name[10:17])),
- np.vstack((base_stats_name[2:5],base_stats_name[-2:]))]
- """遗器各部位主属性名称"""
- subs_stats_name = np.vstack((stats_name[:10],stats_name[-3:-1]))
- """遗器副属性名称,已按副词条顺序排序"""
- subs_stats_tier = [
- [(27.096, 3.3870 ), (13.548 , 1.6935 ), (13.548 , 1.6935 ), (2.7648, 0.3456), (2.7648, 0.3456), (3.456, 0.4320), # 四星遗器数值
- (1.60, 0.20), (2.0736, 0.2592), (4.1472, 0.5184), (2.7648, 0.3456), (2.7648, 0.3456), (4.1472, 0.5184)],
- [(33.870, 4.233755), (16.935 , 2.116877), (16.935 , 2.116877), (3.4560, 0.4320), (3.4560, 0.4320), (4.320, 0.5400), # 五星遗器数值
- (2.00, 0.30), (2.5920, 0.3240), (5.1840, 0.6480), (3.4560, 0.4320), (3.4560, 0.4320), (5.1840, 0.6480)]]
- """副属性词条档位:t0-基础值,t1-每提升一档的数值;l1-四星遗器数值,l2-五星遗器数值 <<数据来源:米游社@666bj>>"""
-
- # json数据格式规范
- relics_schema = { # 遗器数据集
- "type": "object",
- "additionalProperties": { # [主键]遗器哈希值 (由其键值遗器数据自动生成)
- "type": "object",
- "properties": {
- "equip_set": { # 遗器部位
- "type": "string",
- "enum": equip_set_name },
- "relic_set": { # 遗器套装
- "type": "string",
- "enum": relic_set_name[:, -1].tolist() },
- "rarity": { # 遗器稀有度 (2-5星)
- "type": "integer",
- "minimum": 2,
- "maximum": 5 },
- "level": { # 遗器等级 (0-15级)
- "type": "integer",
- "minimum": 0,
- "maximum": 15 },
- "base_stats": { # 遗器主属性 (词条数为 1)
- "type": "object",
- "minProperties": 1,
- "maxProperties": 1,
- "properties": {
- key: {"type": "number"} for key in base_stats_name[:, -1]},
- "additionalProperties": False },
- "subs_stats": { # 遗器副属性 (词条数为 1-4)
- "type": "object",
- "minProperties": 1,
- "maxProperties": 4,
- "properties": {
- key: {"type": "number"} for key in subs_stats_name[:, -1]},
- "additionalProperties": False },
- "pre_ver_hash": {"type": "string"} # [外键]本次升级前的版本
- },
- "required": ["relic_set", "equip_set", "rarity", "level", "base_stats", "subs_stats"], # 需包含遗器的全部固有属性
- "additionalProperties": False
- }}
- loadout_schema = { # 人物遗器配装数据集
- "type": "object",
- "additionalProperties": { # [主键]人物名称 (以OCR结果为准)
- "type": "object",
- "additionalProperties": { # [次主键]配装名称 (自定义)
- "type": "array", # 配装组成 (6件遗器,按部位排序)
- "minItems": 6,
- "maxItems": 6,
- "items": {"type": "string"} # [外键]遗器哈希值
- }}}
- team_schema = { # 队伍遗器配装数据集
- "type": "object",
- "additionalProperties": { # [主键]队伍名称 (自定义)
- "type": "object",
- "additionalProperties": { # [外键]队伍成员名称 (以OCR结果为准)
- "type": "string" # [外键]各队伍成员的配装名称
- },
- "minProperties": 1,
- "maxProperties": 4
- }}
- relic_data_filter = ["pre_ver_hash"]
- """遗器数据过滤器"""
def __init__(self, title=_("崩坏:星穹铁道")):
"""
@@ -180,19 +87,27 @@ def __init__(self, title=_("崩坏:星穹铁道")):
"""是否在遗器OCR时开启对副词条的数据验证 (关闭后,会将is_detail强制关闭)"""
self.is_detail = sra_config_obj.detail_for_relic and self.is_check_stats
"""是否在打印遗器信息时显示详细信息 (如各副词条的强化次数、档位积分,以及提高原数据的小数精度)"""
+ self.ndigits: Literal[0, 1, 2, 3] = sra_config_obj.ndigits_for_relic
+ """在打印遗器信息时的小数精度"""
# 读取json文件,仅初始化时检查格式规范
- self.relics_data = read_json_file(RELIC_FILE_NAME, schema=self.relics_schema)
- self.loadout_data = read_json_file(LOADOUT_FILE_NAME, schema=self.loadout_schema)
- self.team_data = read_json_file(TEAM_FILE_NAME, schema=self.team_schema)
- log.info(_("遗器数据载入完成"))
+ self.relics_data: Dict[str, Dict[str, Any]] = read_json_file(RELIC_FILE_NAME, schema = RELIC_SCHEMA)
+ self.loadout_data: Dict[str, Dict[str, List[str]]] = read_json_file(LOADOUT_FILE_NAME, schema = LOADOUT_SCHEMA)
+ self.team_data: Dict[str, Dict[str, Any]] = read_json_file(TEAM_FILE_NAME, schema = TEAM_SCHEMA)
+
+ log.info(_("遗器模块初始化完成"))
log.info(_(f"共载入 {len(list(self.relics_data.keys()))} 件遗器数据"))
+ log.info(_(f"共载入 {sum(len(char_loadouts) for char_name, char_loadouts in self.loadout_data.items())} 套配装数据"))
+ log.info(_(f"共载入 {sum(len(group_data) for group_name, group_data in self.team_data.items())} 组队伍数据"))
# 校验遗器哈希值
if not self.check_relic_data_hash():
- option = questionary.select(_("是否依据当前遗器数据更新哈希值:"), [_("是"), _("否")]).ask()
+ option = select(_("是否依据当前遗器数据更新哈希值:"), [_("是"), _("否")]).ask()
if option == _("是"):
self.check_relic_data_hash(updata=True)
+ # 校验队伍配装规范
+ if not self.check_team_data():
+ log.error(_("怀疑为手动错误修改json文件导致"))
def relic_entrance(self):
"""
@@ -200,76 +115,118 @@ def relic_entrance(self):
遗器模块入口
"""
title = _("遗器模块:")
- options = [_("保存当前人物的配装"), _("读取当前人物的配装"), _("识别当前遗器的数据"), _("返回主菜单")]
+ tab = "\n" + " " * 5
+ options = [
+ Choice(_("保存当前角色的配装"), value = 0,
+ description = tab + _("请使游戏保持在[角色]界面")),
+ Choice(_("保存当前队伍的配装"), value = 1,
+ description = tab + _("请使游戏保持在[角色]界面") + tab + _("并确保[角色头像列表]移动至开头")),
+ Choice(_("读取当前角色的配装记录"), value = 2,
+ description = tab + _("请使游戏保持在[角色]界面")),
+ Choice(_("读取队伍的配装记录"), value = 3,
+ description = tab + _("请使游戏保持在[角色]界面") + tab + _("并确保[角色头像列表]移动至开头")),
+ Choice(_("识别当前遗器数据"), value = 4,
+ description = tab + _("请使游戏保持在[角色]-[遗器]-[遗器替换]界面") + tab + _("推荐手动点击[对比]提高识别度")),
+ _("<返回主菜单>")
+ ] # 注:[角色]界面的前继可为[队伍]-[角色选择]-[详情]界面
option = None # 保存上一次的选择
while True:
self.calculated.switch_cmd()
- option = questionary.select(title, options, default=option).ask()
- if option == _("保存当前人物的配装"):
+ option = select(title, options, default=option, show_description=True).ask()
+ if option == 0:
self.calculated.switch_window()
self.save_loadout_for_char()
- elif option == _("读取当前人物的配装"):
+ elif option == 1:
+ self.save_loadout_for_team()
+ elif option == 2:
self.equip_loadout_for_char()
- elif option == _("识别当前遗器的数据"):
+ elif option == 3:
+ self.equip_loadout_for_team()
+ elif option == 4:
self.calculated.switch_window()
data = self.try_ocr_relic()
self.print_relic(data)
- elif option == _("返回主菜单"):
+ elif option == _("<返回主菜单>"):
break
def equip_loadout_for_team(self):
"""
说明:
- 装备当前[人物]界面本队伍的遗器配装
+ 装备当前[角色]界面本队伍的遗器配装
"""
- ...
+ char_pos_list = [(26,6),(31,6),(37,6),(42,6),...,(75,6)] if IS_PC else [(5,16),(5,27),(5,38),(5,49),...,(5,81)]
+ # 选择队伍
+ option = select(
+ _("请选择对当前队伍进行遗器装备的编队:"),
+ choices = self.get_team_choice_options() + [(_("<返回上一级>"))],
+ show_description = True, # 需questionary具备对show_description的相关支持
+ ).ask()
+ if option == _("<返回上一级>"):
+ return
+ team_members = option # 得到 (char_name: loadout_name) 的键值对
+ # 检查人物列表是否移动至开头
+ self.calculated.switch_window()
+ self.calculated.relative_swipe(char_pos_list[0], char_pos_list[-1]) # 滑动人物列表
+ time.sleep(1)
+ self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击导航栏的遗器,进入[角色]-[遗器]界面
+ time.sleep(1)
+ # 依次点击人物,进行配装 (编队人物无序)
+ for char_index in range(len(team_members)):
+ char_pos = char_pos_list[char_index]
+ self.calculated.relative_click(char_pos) # 点击人物
+ time.sleep(2)
+ character_name = self.ocr_character_name() # 识别当前人物名称
+ if character_name not in team_members:
+ log.error(_(f"编队错误:角色'{character_name}'不应在当前队伍中"))
+ return
+ relic_hash = self.loadout_data[character_name][team_members[character_name]]
+ self.equip_loadout(relic_hash)
+ log.info(_("队伍配装完毕"))
- def equip_loadout_for_char(self):
+ def equip_loadout_for_char(self, character_name :Optional[str]=None):
"""
说明:
- 装备当前[人物]界面本人物的遗器配装
+ 装备当前[角色]界面本人物的遗器配装
"""
# 识别当前人物名称
- character_name = self.ocr_character_name()
+ character_name = self.ocr_character_name() if character_name is None else character_name
character_data = self.loadout_data[character_name]
# 选择配装
if not character_data: # 字典为空
log.info(_("当前人物配装记录为空"))
return
- title = _("请选择将要进行装备的配装:")
- options_map = {str_just(loadout_name, 12) + self.get_loadout_brief(hash_list): hash_list for loadout_name, hash_list in character_data.items()}
- options = list(options_map.keys())
- options.append(_("返回上一级"))
- option = questionary.select(title, options).ask()
- if option == _("返回上一级"):
+ option = select(
+ _("请选择将要进行装备的配装:"),
+ choices = self.get_loadout_choice_options(character_name) + [(_("<返回上一级>"))],
+ show_description = True, # 需questionary具备对show_description的相关支持
+ ).ask()
+ if option == _("<返回上一级>"):
return
- relic_hash = options_map[option]
+ loadout_name, relic_hash = option
self.calculated.switch_window()
# 进行配装
- self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击遗器,进入[人物]-[遗器]界面
+ self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击遗器,进入[角色]-[遗器]界面
time.sleep(0.5)
- self.calculated.relative_click((38,26) if IS_PC else (36,21)) # 点击头部遗器,进入[人物]-[遗器]-[遗器详情]界面
- time.sleep(2)
- self.calculated.relative_click((82,12) if IS_PC else (78,12)) # 点击遗器[对比],将遗器详情的背景由星空变为纯黑
- time.sleep(1)
self.equip_loadout(relic_hash)
- self.calculated.relative_click((97,6) if IS_PC else (96,5)) # 退出[遗器]界面,返回[人物]界面
- time.sleep(1)
- def equip_loadout(self, relics_hash:list[str]):
+ def equip_loadout(self, relics_hash:List[str]):
"""
说明:
- 装备当前[人物]-[遗器]-[遗器详情]页面内的指定遗器配装。
+ 装备当前[角色]-[遗器]页面内的指定遗器配装。
遗器将强制替换 (1-替换己方已装备的遗器,2-替换对方已装备的遗器)
参数:
:param relics_hash: 遗器配装哈希值列表
"""
equip_pos_list = [(4,13),(9,13),(13,13),(18,13),(23,13),(27,13)] if IS_PC else [(5,14),(11,14),(17,14),(23,14),(28,14),(34,14)]
relic_filter = self.Relic_filter(self.calculated) # 遗器筛选器初始化
- relic_set_name_dict = Array2dict(self.relic_set_name)
+ relic_set_name_dict = Array2dict(RELIC_SET_NAME)
+ self.calculated.relative_click((38,26) if IS_PC else (36,21)) # 点击头部遗器,进入[角色]-[遗器]-[遗器替换]界面
+ time.sleep(2)
+ self.calculated.relative_click((82,12) if IS_PC else (78,12)) # 点击遗器[对比],将遗器详情的背景由星空变为纯黑
+ time.sleep(1)
for equip_indx, equip_pos in enumerate(equip_pos_list): # 遗器部位循环
# 选择部位
- log.info(_(f"选择部位:{self.equip_set_name[equip_indx]}"))
+ log.info(_(f"选择部位:{EQUIP_SET_NAME[equip_indx]}"))
self.calculated.relative_click(equip_pos)
time.sleep(0.5)
# 获取遗器数据
@@ -299,46 +256,149 @@ def equip_loadout(self, relics_hash:list[str]):
time.sleep(0.5)
elif button == 2:
log.info(_("已装备"))
+ self.calculated.relative_click((97,6) if IS_PC else (96,5)) # 退出[遗器替换]界面,返回[角色]-[遗器]界面
+ time.sleep(0.5)
log.info(_("配装装备完毕"))
def save_loadout_for_team(self):
"""
说明:
- 保存当前[人物]界面本队伍的遗器配装
+ 保存当前[角色]界面本队伍的遗器配装
"""
- ...
+ char_pos_list = [(26,6),(31,6),(37,6),(42,6),...,(75,6)] if IS_PC else [(5,16),(5,27),(5,38),(5,49),...,(5,81)]
+ char_name_list = []
+ relics_hash_list = []
+ loadout_name_list = []
+ # [1]选择队伍的人数 (能否通过页面识别?)
+ char_num = int(select(_("请选择队伍人数:"), ['4','3','2','1']).ask())
+ # [2]选择是否为互斥队伍组别
+ group_name = "compatible" # 默认为非互斥队组别
+ ... # 互斥队组别【待扩展】
+ if group_name not in self.team_data:
+ self.team_data[group_name] = {}
+ group_data = self.team_data[group_name]
+ # [3]选择组建方式
+ options = {
+ _("全识别"): 0,
+ _("参考已有的配装数据"): 1
+ }
+ option_ = select(_("请选择组建方式:"), options).ask()
+ state = options[option_]
+ # [4]检查人物列表是否移动至开头
+ self.calculated.switch_window()
+ self.calculated.relative_swipe(char_pos_list[0], char_pos_list[-1]) # 滑动人物列表
+ time.sleep(1)
+ self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击导航栏的遗器,进入[角色]-[遗器]界面
+ time.sleep(1)
+ # [5]依次点击人物,识别配装
+ char_index = 0
+ is_retrying = False
+ character_name = None
+ loadout_dict = self.HashList2dict()
+ while char_index < char_num:
+ char_pos = char_pos_list[char_index]
+ # [5.1]识别人物名称
+ if not is_retrying: # 如果处于重试,则不再次识别人物名称
+ self.calculated.switch_window()
+ self.calculated.relative_click(char_pos) # 点击人物
+ time.sleep(2)
+ character_name = self.ocr_character_name() # 识别当前人物名称
+ # [5.2]选择识别当前,还是录入已有
+ option = None
+ if state == 1:
+ self.calculated.switch_cmd()
+ option = select(
+ _("请选择配装:"),
+ choices = self.get_loadout_choice_options(character_name) + [_("<识别当前配装>"), _("<退出>")],
+ show_description = True, # 需questionary具备对show_description的相关支持
+ ).ask()
+ if option == _("<退出>"): # 退出本次编队
+ return
+ elif option != _("<识别当前配装>"):
+ loadout_name, relics_hash = option # 获取已录入的配装数据
+ if state == 0 or option == _("<识别当前配装>"):
+ self.calculated.switch_window()
+ relics_hash = self.save_loadout()
+ print(_("配装信息:\n {}\n{}").format(self.get_loadout_brief(relics_hash), self.get_loadout_detail(relics_hash, 2)))
+ loadout_name = self.find_loadout_name(character_name, relics_hash)
+ if loadout_name:
+ log.info(_(f"配装记录已存在,配装名称:{loadout_name}"))
+ # [5.3]检查是否存在冲突遗器
+ loadout_check = loadout_dict.add(relics_hash, character_name).find_duplicate_hash()
+ if loadout_check: # 列表非空,表示存在遗器冲突
+ for equip_index, char_names, element in loadout_check:
+ self.calculated.switch_cmd()
+ log.error(_("队伍遗器冲突:{}间的'{}'遗器冲突,遗器哈希值:{}").format(char_names, EQUIP_SET_NAME[equip_index], element))
+ is_retrying = True # 将重复本次循环
+ if is_retrying:
+ log.error(_("请重新选择配装"))
+ continue
+ log.info(_("配装校验成功"))
+ char_name_list.append(character_name)
+ relics_hash_list.append(relics_hash)
+ loadout_name_list.append(loadout_name)
+ is_retrying = False
+ char_index += 1
+ print(_("队伍配装信息:{}").format("".join("\n " + str_just(char_name, 10) + " " + self.get_loadout_brief(relics_hash)
+ for char_name, relics_hash in zip(char_name_list, relics_hash_list))))
+ # [6]自定义名称
+ self.calculated.switch_cmd()
+ team_name = input(_(">>>>命名编队名称 (将同时作为各人物新建配装的名称): "))
+ while team_name in group_data or \
+ any([team_name in self.loadout_data[character_name] for character_name, loadout_name in zip(char_name_list, loadout_name_list) if loadout_name is None]):
+ team_name = input(_(">>>>命名冲突,请重命名: "))
+ # [7]录入数据
+ for i, (char_name, relics_hash, loadout_name) in enumerate(zip(char_name_list, relics_hash_list, loadout_name_list)):
+ if loadout_name is None:
+ loadout_name_list[i] = team_name
+ self.loadout_data[char_name][team_name] = relics_hash
+ rewrite_json_file(LOADOUT_FILE_NAME, self.loadout_data)
+ group_data[team_name] = {"team_members": {key: value for key, value in zip(char_name_list, loadout_name_list)}}
+ rewrite_json_file(TEAM_FILE_NAME, self.team_data)
+ log.info(_("编队录入成功"))
def save_loadout_for_char(self):
"""
说明:
- 保存当前[人物]界面本人物的遗器配装
+ 保存当前[角色]界面本人物的遗器配装
"""
character_name = self.ocr_character_name() # 识别当前人物名称
- self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击遗器
- time.sleep(1)
- self.calculated.relative_click((38,26) if IS_PC else (36,21)) # 点击头部遗器,进入[人物]-[遗器]界面
- time.sleep(2)
- self.calculated.relative_click((82,12) if IS_PC else (78,12)) # 点击遗器[对比],将遗器详情的背景由星空变为纯黑
+ character_data = self.loadout_data[character_name]
+ self.calculated.relative_click((12,40) if IS_PC else (16,48)) # 点击导航栏的遗器,进入[角色]-[遗器]界面
time.sleep(1)
- self.save_loadout(character_name)
- self.calculated.relative_click((97,6) if IS_PC else (96,5)) # 退出[遗器]界面,返回[人物]界面
- time.sleep(2)
+ relics_hash = self.save_loadout()
+ self.calculated.switch_cmd()
+ print(_("配装信息:\n {}\n{}").format(self.get_loadout_brief(relics_hash), self.get_loadout_detail(relics_hash, 2)))
+ loadout_name = self.find_loadout_name(character_name, relics_hash)
+ if loadout_name:
+ log.info(_(f"配装记录已存在,配装名称:{loadout_name}"))
+ return
+ loadout_name = input(_(">>>>命名配装名称: ")) # 需作为字典key值,确保唯一性 (但不同的人物可以有同一配装名称)
+ while loadout_name in character_data:
+ loadout_name = input(_(">>>>命名冲突,请重命名: "))
+ character_data[loadout_name] = relics_hash
+ rewrite_json_file(LOADOUT_FILE_NAME, self.loadout_data)
+ log.info(_("配装录入成功"))
- def save_loadout(self, character_name:str=None, max_retries=3):
+ def save_loadout(self, max_retries=3) -> list[str]:
"""
说明:
- 保存当前[人物]-[遗器]-[遗器详情]界面内的遗器配装
+ 保存当前[角色]-[遗器]界面内的遗器配装
+ 返回:
+ :return relics_hash: 遗器配装哈希值列表
"""
- character_name = character_name if character_name else self.ocr_character_name()
- character_data = self.loadout_data[character_name]
- equip_pos_list = [(4,13),(9,13),(13,13),(18,13),(23,13),(27,13)] if IS_PC else [(5,14), (11,14), (17,14), (23,14), (28,14), (34,14)]
+ equip_pos_list = [(4,13),(9,13),(13,13),(18,13),(23,13),(27,13)] if IS_PC else [(5,14),(11,14),(17,14),(23,14),(28,14),(34,14)]
relics_hash = []
+ self.calculated.relative_click((38,26) if IS_PC else (36,21)) # 点击头部遗器,进入[角色]-[遗器]-[遗器替换]界面
+ time.sleep(2)
+ self.calculated.relative_click((82,12) if IS_PC else (78,12)) # 点击遗器[对比],将遗器详情的背景由星空变为纯黑
+ time.sleep(1)
for equip_indx, equip_pos in enumerate(equip_pos_list): # 遗器部位循环
- log.info(_(f"选择部位:{self.equip_set_name[equip_indx]}"))
+ log.info(_(f"选择部位:{EQUIP_SET_NAME[equip_indx]}"))
self.calculated.relative_click(equip_pos)
time.sleep(1)
tmp_data = self.try_ocr_relic(equip_indx, max_retries)
- tmp_hash = get_data_hash(tmp_data, self.relic_data_filter)
+ tmp_hash = get_data_hash(tmp_data, RELIC_DATA_FILTER)
log.debug("\n"+pp.pformat(tmp_data))
self.print_relic(tmp_data)
if tmp_hash in self.relics_data:
@@ -347,27 +407,22 @@ def save_loadout(self, character_name:str=None, max_retries=3):
log.info(_("录入遗器数据"))
self.add_relic_data(tmp_data, tmp_hash)
relics_hash.append(tmp_hash)
+ self.calculated.relative_click((97,6) if IS_PC else (96,5)) # 退出[遗器替换]界面,返回[角色]-[遗器]界面
+ time.sleep(0.5)
log.info(_("配装识别完毕"))
- self.calculated.switch_cmd()
- loadout_name = input(_(">>>>命名配装名称: ")) # 需作为字典key值,确保唯一性 (但不同的人物可以有同一配装名称)
- while loadout_name in character_data:
- loadout_name = input(_(">>>>命名冲突,请重命名: "))
- character_data[loadout_name] = relics_hash
- self.loadout_data = modify_json_file(LOADOUT_FILE_NAME, character_name, character_data)
- log.info(_("配装录入成功"))
- self.calculated.switch_window()
+ return relics_hash
class Relic_filter:
"""
说明:
- 遗器筛选器。封装了在[人物]-[遗器]-[遗器详情]-[遗器筛选]界面内的遗器筛选方法,
+ 遗器筛选器。封装了在[角色]-[遗器]-[遗器替换]-[遗器筛选]界面内的遗器筛选方法,
目前可以对遗器套装与稀有度进行筛选,并记录先前的筛选状态
- (注意在未退出[遗器详情]界面时切换遗器,会保留上一次的筛选状态)
+ (注意在未退出[遗器替换]界面时切换遗器,会保留上一次的筛选状态)
"""
rarity_pos_list = [(77,38),(89,38),(77,42),(89,42)] if IS_PC else [(71,45),(86,45),(71,52),(86,52)]
"""稀有度筛选项的点击位点 (分别为2,3,4,5星稀有度)"""
- def __init__(self, calculated:calculated):
+ def __init__(self, calculated: calculated):
self.calculated = calculated
# 记录上一次的筛选状态
self.pre_relic_set_index = -1
@@ -375,10 +430,10 @@ def __init__(self, calculated:calculated):
self.pre_rarity = -1
"""过去稀有度"""
- def do(self, relic_set_index:int, rairty:int):
+ def do(self, relic_set_index: int, rairty: int):
"""
说明:
- 在当前[人物]-[遗器]-[遗器详情]内进行遗器筛选
+ 在当前[角色]-[遗器]-[遗器替换]内进行遗器筛选
参数:
:param relic_set_index: 遗器套装索引
:param rairty: 稀有度
@@ -386,7 +441,7 @@ def do(self, relic_set_index:int, rairty:int):
if self.pre_relic_set_index == relic_set_index and self.pre_rarity == rairty: # 筛选条件未改变
return
# 若筛选条件之一发生改变,未改变的不会进行重复动作
- log.info(_(f"进行遗器筛选,筛选条件: set={relic_set_index}, rairty={rairty}"))
+ log.debug(_(f"进行遗器筛选,筛选条件: set={relic_set_index}, rairty={rairty}"))
self.calculated.relative_click((3,92) if IS_PC else (4,92)) # 点击筛选图标进入[遗器筛选]界面
time.sleep(0.5)
# 筛选遗器套装
@@ -411,10 +466,10 @@ def do(self, relic_set_index:int, rairty:int):
... # 其他筛选条件
self.calculated.relative_click((3,92) if IS_PC else (4,92)) # 任意点击筛选框外退出[遗器筛选]界面
- def search_relic_set_for_filter(self, relic_set_index:int):
+ def search_relic_set_for_filter(self, relic_set_index: int):
"""
说明:
- 在当前滑动[人物]-[遗器]-[遗器详情]-[遗器筛选]-[遗器套装筛选]界面内,搜索遗器套装名,并点击。
+ 在当前滑动[角色]-[遗器]-[遗器替换]-[遗器筛选]-[遗器套装筛选]界面内,搜索遗器套装名,并点击。
综合OCR识别与方位计算
参数:
:param equip_set_index: 遗器套装索引
@@ -431,13 +486,14 @@ def search_relic_set_for_filter(self, relic_set_index:int):
time.sleep(0.5)
self.calculated.relative_click((35,35) if IS_PC else (35,32)) # 取消选中
points = ((28,33,42,63) if is_left else (53,33,67,63)) if IS_PC else ((22,29,41,65) if is_left else (53,29,72,65))
- self.calculated.ocr_click(Relic.relic_set_name[relic_set_index, 1], points=points)
+ self.calculated.ocr_click(RELIC_SET_NAME[relic_set_index, 1], points=points)
- def search_relic(self, equip_indx:int, key_hash:str=None, key_data:dict=None, overtime=180, max_retries=3) -> tuple[int, int]:
+ def search_relic(self, equip_indx: int, key_hash: Optional[str]=None, key_data: Optional[Dict[str, Any]]=None, overtime=180, max_retries=3
+ ) -> Optional[tuple[int, int]]:
"""
说明:
- 在当前滑动[人物]-[遗器]-[遗器详情]界面内,搜索匹配的遗器。
+ 在当前滑动[角色]-[遗器]-[遗器替换]界面内,搜索匹配的遗器。
key_hash非空: 激活精确匹配 (假设数据保存期间遗器未再次升级);
key_data非空: 激活模糊匹配 (假设数据保存期间遗器再次升级,匹配成功后自动更新遗器数据);
key_hash & key_data均空: 遍历当前页面内的遗器
@@ -496,7 +552,7 @@ def search_relic(self, equip_indx:int, key_hash:str=None, key_data:dict=None, ov
break
return None
- def compare_relics(self, old_data:dict, new_data:dict) -> bool:
+ def compare_relics(self, old_data: Dict[str, Any], new_data: Dict[str, Any]) -> bool:
"""
说明:
比对两者遗器数据,判断新遗器是否为旧遗器升级后
@@ -510,21 +566,90 @@ def compare_relics(self, old_data:dict, new_data:dict) -> bool:
for key in old_data["subs_stats"].keys():
if key not in new_data["subs_stats"]:
return False
- if old_data["subs_stats"][key] > new_data["subs_stats"][key]:
- return False
+ if key == _("速度") and int(old_data["subs_stats"][key]) > int(new_data["subs_stats"][key]) or \
+ key != _("速度") and old_data["subs_stats"][key] > new_data["subs_stats"][key]:
+ return False # 考虑手动提高速度数据精度的情况
return True
- def check_relic_data_hash(self, updata=False):
+ def find_loadout_name(self, char_name: str, relics_hash: List[str]) -> Optional[str]:
+ """
+ 说明:
+ 通过配装数据在记录中寻找配装名称
+ """
+ for loadout_name, hash_list in self.loadout_data[char_name].items():
+ if hash_list == relics_hash:
+ return loadout_name
+ return None
+
+ def check_team_data(self) -> bool:
+ """
+ 说明:
+ 检查队伍配装数据是否满足规范
+ """
+ ret = True
+ for group_name, team_group in self.team_data.items():
+ for team_name, team_data in team_group.items():
+ loadout_dict = self.HashList2dict()
+ [loadout_dict.add(self.loadout_data[char_name][loadout_name], char_name) for char_name, loadout_name in team_data["team_members"].items()]
+ for equip_index, char_names, element in loadout_dict.find_duplicate_hash():
+ log.error(_("队伍遗器冲突:'{}'队伍的{}间的'{}'遗器冲突,遗器哈希值:{}").format(team_name, char_names, EQUIP_SET_NAME[equip_index], element))
+ ret = False
+ if group_name != "compatible": ... # 互斥队伍组别【待扩展】
+ if ret:
+ log.info(_("队伍配装校验成功"))
+ return ret
+
+ class HashList2dict:
+ """
+ 说明:
+ 将队伍或队伍组别的配装按遗器部位进行字典统计,以查找可能存在的重复遗器
+ """
+
+ def __init__(self):
+ self.hash_dict_for_equip: List[Dict[str, List[str]]] = [{},{},{},{},{},{}]
+ """按遗器部位分别的配装统计,key-遗器哈希值,value-装备者的名称"""
+
+ def add(self, relics_hash: List[str], char_name: str) -> 'Relic.HashList2dict':
+ """
+ 说明:
+ 添加一组数据
+ 参数:
+ :param relics_hash: 配装遗器哈希值列表
+ :param char_name: 配装装备者的名称
+ """
+ for equip_index, element in enumerate(relics_hash):
+ if element in self.hash_dict_for_equip[equip_index]:
+ self.hash_dict_for_equip[equip_index][element].append(char_name)
+ else:
+ self.hash_dict_for_equip[equip_index][element] = [char_name]
+ return self
+
+ def find_duplicate_hash(self) -> List[Tuple[int, List[str], str]]:
+ """
+ 说明:
+ 按遗器部位遍历字典,查找可能存在的重复遗器
+ 返回:
+ :return ret[list(tuple)]:
+ 0-遗器部位索引,1-装备者的名称序列,2-遗器哈希值
+ """
+ ret = []
+ for equip_index in range(len(self.hash_dict_for_equip)):
+ for element, char_names in self.hash_dict_for_equip[equip_index].items():
+ if len(char_names) > 1:
+ ret.append((equip_index, char_names, element))
+ return ret
+
+ def check_relic_data_hash(self, updata=False) -> bool:
"""
说明:
检查遗器数据是否发生手动修改 (应对json数据格式变动或手动矫正仪器数值),
若发生修改,可选择更新仪器哈希值,并替换配装数据中相应的数值
"""
- equip_set_dict = {key: value for value, key in enumerate(self.equip_set_name)}
+ equip_set_dict = {key: value for value, key in enumerate(EQUIP_SET_NAME)}
relics_data_copy = self.relics_data.copy() # 字典迭代过程中不允许修改key
cnt = 0
for old_hash, data in relics_data_copy.items():
- new_hash = get_data_hash(data, self.relic_data_filter)
+ new_hash = get_data_hash(data, RELIC_DATA_FILTER, speed_modified=True)
if old_hash != new_hash:
equip_indx = equip_set_dict[data["equip_set"]]
log.debug(f"(old={old_hash}, new={new_hash})")
@@ -532,7 +657,7 @@ def check_relic_data_hash(self, updata=False):
self.updata_relic_data(old_hash, new_hash, equip_indx)
cnt += 1
if not cnt:
- log.info(_(f"遗器哈希值校验成功"))
+ log.info(_("遗器哈希值校验成功"))
return True
if updata:
log.info(_(f"已更新 {cnt} 件遗器的哈希值"))
@@ -541,7 +666,7 @@ def check_relic_data_hash(self, updata=False):
log.error(_(f"发现 {cnt} 件遗器的哈希值校验失败"))
return False
- def updata_relic_data(self, old_hash:str, new_hash:str, equip_indx:int, new_data:dict=None, delete_old_data=False):
+ def updata_relic_data(self, old_hash: str, new_hash: str, equip_indx: int, new_data: Optional[Dict[str, Any]]=None, delete_old_data=False):
"""
说明:
更改仪器数据,先后修改遗器与配装文件
@@ -568,13 +693,13 @@ def updata_relic_data(self, old_hash:str, new_hash:str, equip_indx:int, new_data
rewrite_json_file(LOADOUT_FILE_NAME, self.loadout_data)
# 队伍配装文件无需修改
- def add_relic_data(self, data:dict, data_hash:str=None) -> bool:
+ def add_relic_data(self, data: Dict[str, Any], data_hash: Optional[str]=None) -> bool:
"""
说明:
录入仪器数据
"""
if not data_hash:
- data_hash = get_data_hash(data, self.relic_data_filter)
+ data_hash = get_data_hash(data, RELIC_DATA_FILTER)
if data_hash not in self.relics_data:
self.relics_data = modify_json_file(RELIC_FILE_NAME, data_hash, data) # 返回更新后的字典
return True
@@ -590,14 +715,14 @@ def ocr_character_name(self) -> str:
:return character_name: 人物名称
"""
str = self.calculated.ocr_pos_for_single_line(points=(10.4,6,18,9) if IS_PC else (13,4,22,9)) # 识别人物名称 (主角名称为玩家自定义,无法适用预选列表)
- character_name = re.sub(r"[.’,,。、·'-_——\"/\\]", '', str) # 删除由于背景光点造成的误判
+ character_name = re.sub(r"[.’,,。、·'-_——「」/|\[\]\"\\]", '', str) # 删除由于背景光点造成的误判
log.info(_(f"识别人物: {character_name}"))
if character_name not in self.loadout_data:
self.loadout_data = modify_json_file(LOADOUT_FILE_NAME, character_name, {})
log.info(_("创建新人物"))
return character_name
- def try_ocr_relic(self, equip_set_index:int = None, max_retries = 3) -> dict:
+ def try_ocr_relic(self, equip_set_index:Optional[int]=None, max_retries=3) -> Dict[str, Any]:
"""
说明:
在规定次数内尝试OCR遗器数据
@@ -618,10 +743,10 @@ def try_ocr_relic(self, equip_set_index:int = None, max_retries = 3) -> dict:
retry += 1
log.info(_(f"第 {retry} 次尝试重新OCR"))
- def ocr_relic(self, equip_set_index:int = None) -> dict:
+ def ocr_relic(self, equip_set_index: Optional[int]=None) -> Dict[str, Any]:
"""
说明:
- OCR当前静态[人物]-[遗器]-[遗器详情]界面内的遗器数据,单次用时约0.5s。
+ OCR当前静态[角色]-[遗器]-[遗器替换]界面内的遗器数据,单次用时约0.5s。
更改为ocr_for_single_line()后,相较ocr()已缩短一半用时,且提高了部分识别的准确性,
若更改为ocr_for_single_lines()后的性能变化【待测】(代码重构较大)
参数:
@@ -633,17 +758,20 @@ def ocr_relic(self, equip_set_index:int = None) -> dict:
img_pc = self.calculated.take_screenshot() # 仅截取一次图片
# [1]部位识别
if equip_set_index is None:
- equip_set_index = self.calculated.ocr_pos_for_single_line(self.equip_set_name, points=(77,19,83,23) if IS_PC else (71,22,78,26), img_pk=img_pc)
+ equip_set_index = self.calculated.ocr_pos_for_single_line(EQUIP_SET_NAME, points=(77,19,83,23) if IS_PC else (71,22,78,26), img_pk=img_pc)
if equip_set_index < 0:
raise RelicOCRException(_("遗器套装OCR错误"))
- equip_set_name = self.equip_set_name[equip_set_index]
+ equip_set_name = EQUIP_SET_NAME[equip_set_index]
# [2]套装识别
- name_list = self.relic_set_name[:, 0].tolist()
+ name_list = RELIC_SET_NAME[:, 0].tolist()
+ name_list = name_list[:RELIC_INNER_SET_INDEX] if equip_set_index < 4 else name_list[RELIC_INNER_SET_INDEX:] # 取外圈/内圈的切片
relic_set_index = self.calculated.ocr_pos_for_single_line(name_list, points=(77,15,92,19) if IS_PC else (71,17,88,21), img_pk=img_pc)
if relic_set_index < 0:
raise RelicOCRException(_("遗器部位OCR错误"))
- relic_set_name = self.relic_set_name[relic_set_index, -1]
- # [3] 稀有度识别
+ if equip_set_index in [4, 5]:
+ relic_set_index += RELIC_INNER_SET_INDEX # 还原内圈遗器的真实索引
+ relic_set_name = RELIC_SET_NAME[relic_set_index, -1]
+ # [3]稀有度识别
hue, __, __ = self.calculated.get_relative_pix_hsv((43,55) if IS_PC else (41,55)) # 识别指定位点色相
log.debug(f"hue = {hue}")
if hue < 40: # [黄]模拟器测试结果的均值为 25
@@ -662,7 +790,7 @@ def ocr_relic(self, equip_set_index:int = None) -> dict:
if level > 15:
raise RelicOCRException(_("遗器等级OCR错误"))
# [5]主属性识别
- name_list = self.base_stats_name4equip[equip_set_index][:, 0].tolist()
+ name_list = BASE_STATS_NAME_FOR_EQUIP[equip_set_index][:, 0].tolist()
base_stats_index = self.calculated.ocr_pos_for_single_line(name_list, points=(79.5,25,92,29) if IS_PC else (74,29,89,34), img_pk=img_pc)
base_stats_value = self.calculated.ocr_pos_for_single_line(points=(93,25,98,29) if IS_PC else (91,29,98,34), number=True, img_pk=img_pc)
if base_stats_index < 0:
@@ -674,11 +802,11 @@ def ocr_relic(self, equip_set_index:int = None) -> dict:
s = base_stats_value.split('%')[0] # 修正'48%1'如此的错误识别
base_stats_value = s[:-1] + '.' + s[-1:] # 添加未识别的小数点
base_stats_value = float(base_stats_value)
- base_stats_name = str(self.base_stats_name4equip[equip_set_index][base_stats_index, -1])
+ base_stats_name = str(BASE_STATS_NAME_FOR_EQUIP[equip_set_index][base_stats_index, -1])
# [6]副属性识别 (词条数量 2-4)
subs_stats_name_points = [(79.5,29,85,33),(79.5,33,85,36.5),(79.5,36.5,85,40),(79.5,40,85,44)] if IS_PC else [(74,35,81,38),(74,39,81,43),(74,44,81,47),(74,48,81,52)]
subs_stats_value_points = [(93,29,98,33),(93,33,98,36.5),(93,36.5,98,40),(93,40,98,44)] if IS_PC else [(92,35,98,38),(92,39,98,43),(92,44,98,47),(92,48,98,52)]
- name_list = self.subs_stats_name[:, 0].tolist()
+ name_list = SUBS_STATS_NAME[:, 0].tolist()
subs_stats_dict = {}
total_level = 0
for name_point, value_point in zip(subs_stats_name_points, subs_stats_value_points):
@@ -696,7 +824,7 @@ def ocr_relic(self, equip_set_index:int = None) -> dict:
if tmp_index >= 0 and tmp_index < 3:
tmp_index += 3 # 小词条转为大词条
tmp_value = float(tmp_value)
- tmp_name = str(self.subs_stats_name[tmp_index, -1])
+ tmp_name = str(SUBS_STATS_NAME[tmp_index, -1])
check = self.get_subs_stats_detail((tmp_name, tmp_value), rarity, tmp_index)
if check is None:
raise RelicOCRException(_("遗器副词条数值OCR错误"))
@@ -721,26 +849,31 @@ def ocr_relic(self, equip_set_index:int = None) -> dict:
log.info(f"用时\033[1;92m『{seconds:.1f}秒』\033[0m")
return result_data
- def print_relic(self, data:dict):
+ def print_relic(self, data: Dict[str, Any]):
"""
说明:
打印遗器信息,
可通过is_detail设置打印普通信息与拓展信息
"""
- print(_("部位: {equip_set}").format(equip_set=data["equip_set"]))
- print(_("套装: {relic_set}").format(relic_set=data["relic_set"]))
- print(_("星级: {star}").format(star='★'*data["rarity"]))
- print(_("等级: +{level}").format(level=data["level"]))
- print(_("主词条:"))
+ token = []
+ token.append(_("部位: {equip_set}").format(equip_set=data["equip_set"]))
+ token.append(_("套装: {relic_set}").format(relic_set=data["relic_set"]))
+ token.append(_("星级: {star}").format(star='★'*data["rarity"]))
+ token.append(_("等级: +{level}").format(level=data["level"]))
+ token.append(_("主词条:"))
name, value = list(data["base_stats"].items())[0]
- pre = " " if name in self.not_pre_stats else "%"
- print(_(" {name:<4}\t{value:>5}{pre}").format(name=name, value=value, pre=pre))
- print(_("副词条:"))
- subs_stats_dict = Array2dict(self.subs_stats_name)
+ pre = " " if name in NOT_PRE_STATS else "%"
+ result = self.get_base_stats_detail((name, value), data["rarity"], data["level"])
+ if result:
+ token.append(_(" {name:<4}\t{value:>7.{ndigits}f}{pre}").format(name=name, value=result, pre=pre, ndigits=self.ndigits))
+ else:
+ token.append(_(" {name:<4}\t{value:>5}{pre} [ERROR]").format(name=name, value=value, pre=pre))
+ token.append(_("副词条:"))
+ subs_stats_dict = Array2dict(SUBS_STATS_NAME)
for name, value in data["subs_stats"].items():
- pre = " " if name in self.not_pre_stats else "%"
+ pre = " " if name in NOT_PRE_STATS else "%"
if not self.is_detail or data["rarity"] not in [4,5]: # 不满足校验条件
- print(_(" {name:<4}\t{value:>5}{pre}").format(name=name, value=value, pre=pre))
+ token.append(_(" {name:<4}\t{value:>5}{pre}").format(name=name, value=value, pre=pre))
continue
stats_index = subs_stats_dict[name]
# 增强信息并校验数据
@@ -748,18 +881,105 @@ def print_relic(self, data:dict):
if ret: # 数据校验成功
level, score, result = ret
tag = '>'*(level-1) # 强化次数的标识
- print(_(" {name:<4}\t{tag:<7}{value:>6.3f}{pre} [{score}]").format(name=name, tag=tag, value=result, score=score, pre=pre))
+ token.append(_(" {name:<4}\t{tag:<7}{value:>7.{ndigits}f}{pre} [{score}]").
+ format(name=name, tag=tag, value=result, score=score, pre=pre, ndigits=self.ndigits))
else: # 数据校验失败
- print(_(" {name:<4}\t{value:>5}{pre} [ERROR]").format(name=name, value=value, pre=pre))
- print('-'*50)
+ token.append(_(" {name:<4}\t{value:>5}{pre} [ERROR]").format(name=name, value=value, pre=pre))
+ token.append('-'*50)
+ print("\n".join(token))
+
+ def get_team_choice_options(self) -> List[Choice]:
+ """
+ 说明:
+ 获取所有队伍配装记录的选项表
+ 返回:
+ :return choice_options: 队伍配装记录的选项列表,Choice构造参数如下:
+ :return title: 队伍名称,
+ :return value: 队员信息字典(key-人物名称,value-配装名称),
+ :return description: 队员配装的简要信息
+ """
+ group_data = self.team_data["compatible"] # 获取非互斥队组别信息
+ ... # 获取互斥队伍组别信息【待扩展】
+ prefix = "\n" + " " * 5
+ choice_options = [Choice(
+ title = str_just(team_name, 12),
+ value = team_data["team_members"],
+ description = "".join(
+ prefix + str_just(char_name, 10) + " " + self.get_loadout_brief(self.loadout_data[char_name][loadout_name])
+ for char_name, loadout_name in team_data["team_members"].items())
+ ) for team_name, team_data in group_data.items()]
+ return choice_options
+
+ def get_loadout_choice_options(self, character_name:str) -> List[Choice]:
+ """
+ 说明:
+ 获取该人物配装记录的选项表
+ 参数:
+ :param character_name: 人物名称
+ 返回:
+ :return choice_options: 人物配装记录的选项表,Choice构造参数如下:
+ :return title: 配装名称+配装简要信息,
+ :return value: 元组(配装名称, 遗器哈希值列表),
+ :return description: 配装各属性数值统计
+ """
+ character_data = self.loadout_data[character_name]
+ choice_options = [Choice(
+ title = str_just(loadout_name, 14) + " " + self.get_loadout_brief(relics_hash),
+ value = (loadout_name, relics_hash),
+ description = '\n' + self.get_loadout_detail(relics_hash, 5, True)
+ ) for loadout_name, relics_hash in character_data.items()]
+ return choice_options
+
+ def get_loadout_detail(self, relics_hash: List[str], tab_num: int=0, flag=False) -> str:
+ """
+ 说明:
+ 获取配装的详细信息 (各属性数值统计)
+ """
+ stats_total_value = [0 for _ in range(len(STATS_NAME))]
+ stats_name_dict = Array2dict(STATS_NAME)
+ base_stats_dict = Array2dict(BASE_STATS_NAME)
+ subs_stats_dict = Array2dict(SUBS_STATS_NAME)
+ for equip_indx in range(len((relics_hash))):
+ tmp_data = self.relics_data[relics_hash[equip_indx]]
+ rarity = tmp_data["rarity"]
+ level = tmp_data["level"]
+ stats_list = [(key, self.get_base_stats_detail((key, value), rarity, level, base_stats_dict[key]))
+ for key, value in tmp_data["base_stats"].items()] # 获取数值精度提高后的主词条
+ stats_list.extend([(key, self.get_subs_stats_detail((key, value), rarity, subs_stats_dict[key])[-1])
+ for key, value in tmp_data["subs_stats"].items()]) # 获取数值精度提高后的副词条
+ for key, value in stats_list:
+ stats_total_value[stats_name_dict[key]] += value # 数值统计
+ token_list = []
+ has_ = False # 标记有无属性伤害
+ for index, value in enumerate(stats_total_value):
+ name = STATS_NAME[index, -1]
+ if index in range(11, 18):
+ if index == 17 and not has_ and value == 0 : # 无属性伤害的情形
+ name = _("属性伤害")
+ elif value == 0: continue
+ else: has_ = True
+ pre = " " if name in NOT_PRE_STATS else "%"
+ token_list.append(_("{name}{value:>7.{ndigits}f}{pre}").format(name=str_just(name, 15), value=value, pre=pre, ndigits=self.ndigits))
+ msg = ""
+ column = 2 # 栏数 (可调节)
+ tab = " " * tab_num
+ for index in range(len(token_list)): # 分栏输出 (纵向顺序,横向逆序,保证余数项在左栏)
+ i = index // column
+ j = index % column
+ n = (column-j-1) * len(token_list) // column + i
+ msg += token_list[n] if j != 0 else tab+token_list[n]
+ msg += "\n" if j == column-1 else " "
+ if msg[-1] != "\n": msg += "\n"
+ if flag: msg += "\n" + tab + _("(未计算遗器套装的属性加成)") # 【待扩展】
+ return msg
- def get_loadout_brief(self, relics_hash:list[str]) -> str:
+ def get_loadout_brief(self, relics_hash: List[str]) -> str:
"""
说明:
获取配装的简要信息 (包含内外圈套装信息与主词条信息)
"""
- set_abbr_dict = Array2dict(self.relic_set_name, -1, 2)
- stats_abbr_dict = Array2dict(self.base_stats_name, -1, 1)
+ set_abbr_dict = Array2dict(RELIC_SET_NAME, -1, 2)
+ stats_abbr_dict = Array2dict(BASE_STATS_NAME, -1, 1)
outer_set_list, inner_set_list, base_stats_list = [], [], []
# 获取遗器数据
for equip_indx in range(len((relics_hash))):
@@ -774,13 +994,45 @@ def get_loadout_brief(self, relics_hash:list[str]) -> str:
outer_set_cnt = Counter(outer_set_list)
inner_set_cnt = Counter(inner_set_list)
# 生成信息
- msg = "外:" + '+'.join([str(cnt) + name for name, cnt in outer_set_cnt.items()]) + " "
- msg += "内:" + '+'.join([str(cnt) + name for name, cnt in inner_set_cnt.items()]) + " "
- # msg += " ".join([self.equip_set_abbr[idx]+":"+name for idx, name in enumerate(base_stats_list) if idx > 1])
- msg += ".".join([name for idx, name in enumerate(base_stats_list) if idx > 1]) # 排除头部与手部
+ outer = _("外:") + '+'.join([str(cnt) + name for name, cnt in outer_set_cnt.items()]) + " "
+ inner = _("内:") + '+'.join([str(cnt) + name for name, cnt in inner_set_cnt.items()]) + " "
+ # stats = " ".join([EQUIP_SET_ADDR[idx]+":"+name for idx, name in enumerate(base_stats_list) if idx > 1])
+ stats = ".".join([name for idx, name in enumerate(base_stats_list) if idx > 1]) # 排除头部与手部
+ msg = str_just(stats, 17) + " " + str_just(inner, 10) + " " + outer # 将长度最不定的外圈信息放至最后
return msg
- def get_subs_stats_detail(self, data:tuple[str, float], rarity:int, stats_index:int = None) -> tuple[int, int, float]:
+ def get_base_stats_detail(self, data: Tuple[str, float], rarity: int, level: int, stats_index: Optional[int]=None) -> Optional[float]:
+ """
+ 说明:
+ 计算主词条的详细信息 (提高原数据的小数精度)
+ 可以作为主词条校验函数 (可以检测出大部分的OCR错误)
+ 支持五星遗器与四星遗器
+ 参数:
+ :param data: 遗器副词条键值对
+ :param stats_index: 遗器副词条索引
+ :param rarity: 遗器稀有度
+ :param level: 遗器等级
+ 返回:
+ :return result: 修正后数值 (提高了原数值精度)
+ """
+ name, value = data
+ if not self.is_check_stats or rarity not in [4,5]: # 仅支持五星遗器与四星遗器
+ return value
+ stats_index = np.where(BASE_STATS_NAME[:, -1] == name)[0][0] if stats_index is None else stats_index
+ rarity_index = rarity - 4 # 0-四星,1-五星
+ a, d = BASE_STATS_TIER[rarity_index][stats_index]
+ result = round(a + d*level, 4) # 四舍五入 (考虑浮点数运算的数值损失)
+ # 校验数据
+ check = result - value
+ if check < 0 or \
+ name in NOT_PRE_STATS and check >= 1 or \
+ name not in NOT_PRE_STATS and check >= 0.1:
+ log.error(_(f"校验失败,原数据或计算方法有误: {data}"))
+ log.debug(f"[{a}, {d}], l={level} r={result}")
+ return None
+ return result
+
+ def get_subs_stats_detail(self, data: Tuple[str, float], rarity: int, stats_index: Optional[int]=None) -> Optional[Tuple[int, int, float]]:
"""
说明:
计算副词条的详细信息 (如强化次数、档位积分,以及提高原数据的小数精度)
@@ -796,13 +1048,13 @@ def get_subs_stats_detail(self, data:tuple[str, float], rarity:int, stats_index:
:return score: 档位总积分: 1档记0分, 2档记1分, 3档记2分
:return result: 修正后数值 (提高了原数值精度)
"""
- if not self.is_check_stats or rarity not in [4,5]: # 仅支持五星遗器与四星遗器
- return (0,0,0)
name, value = data
- stats_index = np.where(self.subs_stats_name[:, -1] == name)[0][0] if stats_index is None else stats_index
+ if not self.is_check_stats or rarity not in [4,5]: # 仅支持五星遗器与四星遗器
+ return (-1, -1, value)
+ stats_index = np.where(SUBS_STATS_NAME[:, -1] == name)[0][0] if stats_index is None else stats_index
rarity_index = rarity - 4 # 0-四星,1-五星
- a, d = self.subs_stats_tier[rarity_index][stats_index]
- if name in self.not_pre_stats:
+ a, d = SUBS_STATS_TIER[rarity_index][stats_index]
+ if name in NOT_PRE_STATS:
a_ = int(a) # 从个分位截断小数
else:
a_ = int(a * 10)/10 # 从十分位截断小数
@@ -812,15 +1064,15 @@ def get_subs_stats_detail(self, data:tuple[str, float], rarity:int, stats_index:
if score < 0: # 总分小于零打补丁 (由于真实总分过大导致)
level -= 1
score = math.ceil((value - a_*level) / d - 1.e-6)
- result = round(a*level + d*score, 3) # 四舍五入 (考虑浮点数运算的数值损失)
+ result = round(a*level + d*score, 4) # 四舍五入 (考虑浮点数运算的数值损失)
# 校验数据
check = result - value
- log.debug(f"[{a}, {d}], l={level}, s={score}, r={result}")
if check < 0 or \
- name in self.not_pre_stats and check >= 1 or \
- name not in self.not_pre_stats and check >= 0.1 or \
+ name in NOT_PRE_STATS and check >= 1 or \
+ name not in NOT_PRE_STATS and check >= 0.1 or \
level > 6 or level < 1 or \
score > level*2 or score < 0:
log.error(_(f"校验失败,原数据或计算方法有误: {data}"))
+ log.debug(f"[{a}, {d}], l={level}, s={score}, r={result}")
return None
return (level, score, result)
\ No newline at end of file
diff --git a/utils/relic_constants.py b/utils/relic_constants.py
new file mode 100644
index 00000000..6dab2993
--- /dev/null
+++ b/utils/relic_constants.py
@@ -0,0 +1,199 @@
+"""
+遗器模块相关静态数据
+"""
+import numpy as np
+from .config import _
+
+
+EQUIP_SET_NAME = [_("头部"), _("手部"), _("躯干"), _("脚部"), _("位面球"), _("连结绳")]
+"""遗器部位名称,已经按游戏界面顺序排序"""
+
+EQUIP_SET_ADDR = [_("头"), _("手"), _("衣"), _("鞋"), _("球"), _("绳")]
+"""遗器部位简称"""
+
+# 注:因为数据有时要行取有时要列取,故采用数组存储
+RELIC_SET_NAME = np.array([
+# 外圈
+ [_("过客"), _("过客"), _("治疗"), _("云无留迹的过客")],
+ [_("枪手"), _("枪手"), _("快枪手"), _("野穗伴行的快枪手")],
+ [_("圣骑"), _("圣骑"), _("圣骑"), _("净庭教宗的圣骑士")],
+ [_("雪猎"), _("猎人"), _("冰套"), _("密林卧雪的猎人")],
+ [_("拳王"), _("拳王"), _("物理"), _("街头出身的拳王")],
+ [_("铁卫"), _("铁卫"), _("铁卫"), _("戍卫风雪的铁卫")],
+ [_("火匠"), _("火匠"), _("火套"), _("熔岩锻铸的火匠")],
+ [_("天才"), _("天才"), _("量子"), _("繁星璀璨的天才")],
+ [_("乐队"), _("雷电"), _("雷套"), _("激奏雷电的乐队")],
+ [_("翔"), _("翔"), _("风套"), _("晨昏交界的翔鹰")],
+ [_("怪盗"), _("怪盗"), _("怪盗"), _("流星追迹的怪盗")],
+ [_("废"), _("废"), _("虚数"), _("盗匪荒漠的废土客")],
+ [_("者"), _("长存"), _("莳者"), _("宝命长存的莳者")],
+ [_("信使"), _("信使"), _("信使"), _("骇域漫游的信使")],
+# 内圈
+ [_("黑塔"), _("太空"), _("空间站"), _("太空封印站")],
+ [_("仙"), _("仙"), _("仙舟"), _("不老者的仙舟")],
+ [_("公司"), _("公司"), _("命中"), _("泛银河商业公司")],
+ [_("贝洛"), _("贝洛"), _("防御"), _("筑城者的贝洛伯格")], # 注:有散件名为'贝洛伯格的铁卫防线'
+ [_("螺丝"), _("差分"), _("差分"), _("星体差分机")],
+ [_("萨尔"), _("停转"), _("停转"), _("停转的萨尔索图")],
+ [_("利亚"), _("盗贼"), _("击破"), _("盗贼公国塔利亚")],
+ [_("瓦克"), _("瓦克"), _("翁瓦克"), _("生命的翁瓦克")],
+ [_("泰科"), _("繁星"), _("繁星"), _("繁星竞技场")],
+ [_("伊须"), _("龙骨"), _("龙骨"), _("折断的龙骨")]
+], dtype=np.str_)
+"""遗器套装名称:0-套装散件名的共有词(ocr-必须),1-套装名的特异词(ocr-可选,为了增强鲁棒性),2-玩家惯用简称(print),3-套装全称(json),已按[1.4游戏]遗器筛选界面排序 (且前段为外圈,后段为内圈)"""
+
+RELIC_INNER_SET_INDEX = 14
+"""RELIC_SET_NAME参数的遗器内圈的起始点索引"""
+
+STATS_NAME = np.array([
+ [_("命值"), _("生"), _("生命值")],
+ [_("击力"), _("攻"), _("攻击力")],
+ [_("防御"), _("防"), _("防御力")],
+ [_("命值"), _("生"), _("生命值%")],
+ [_("击力"), _("攻"), _("攻击力%")],
+ [_("防御"), _("防"), _("防御力%")],
+ [_("度"), _("速"), _("速度")],
+ [_("击率"), _("暴击"), _("暴击率")],
+ [_("击伤"), _("爆伤"), _("暴击伤害")],
+ [_("命中"), _("命中"), _("效果命中")],
+ [_("治疗"), _("治疗"), _("治疗量加成")],
+ [_("理"), _("伤害"), _("物理属性伤害")],
+ [_("火"), _("火伤"), _("火属性伤害")],
+ [_("冰"), _("冰伤"), _("冰属性伤害")],
+ [_("雷"), _("雷伤"), _("雷属性伤害")],
+ [_("风"), _("风伤"), _("风属性伤害")],
+ [_("量"), _("量子"), _("量子属性伤害")],
+ [_("数"), _("虚数"), _("虚数属性伤害")],
+ [_("抵抗"), _("效果抵抗"), _("效果抵抗")],
+ [_("破"), _("击破"), _("击破特攻")],
+ [_("恢复"), _("能"), _("能量恢复效率")]
+], dtype=np.str_)
+"""遗器属性名称:0-属性名的特异词(ocr-不区分大小词条),1-玩家惯用简称(print),2-属性全称(json-区分大小词条)"""
+
+NOT_PRE_STATS = [_("生命值"), _("攻击力"), _("防御力"), _("速度")]
+"""遗器的整数属性名称"""
+
+BASE_STATS_NAME = np.concatenate((STATS_NAME[:2],STATS_NAME[3:-3],STATS_NAME[-2:]), axis=0)
+"""遗器主属性名称"""
+
+BASE_STATS_NAME_FOR_EQUIP = [
+ BASE_STATS_NAME[0:1],
+ BASE_STATS_NAME[1:2],
+ np.vstack((BASE_STATS_NAME[2:5],BASE_STATS_NAME[6:10])),
+ BASE_STATS_NAME[2:6],
+ np.vstack((BASE_STATS_NAME[2:5],BASE_STATS_NAME[10:17])),
+ np.vstack((BASE_STATS_NAME[2:5],BASE_STATS_NAME[-2:]))
+]
+"""遗器各部位主属性名称"""
+
+SUBS_STATS_NAME = np.vstack((STATS_NAME[:10],STATS_NAME[-3:-1]))
+"""遗器副属性名称,已按副词条顺序排序"""
+
+SUBS_STATS_TIER = [
+ [(27.096, 3.3870 ), (13.548 , 1.6935 ), (13.548 , 1.6935 ), (2.7648, 0.3456), (2.7648, 0.3456), (3.456, 0.4320), # 四星遗器数值
+ (1.60, 0.20), (2.0736, 0.2592), (4.1472, 0.5184), (2.7648, 0.3456), (2.7648, 0.3456), (4.1472, 0.5184)],
+ [(33.870, 4.233755), (16.935 , 2.116877), (16.935 , 2.116877), (3.4560, 0.4320), (3.4560, 0.4320), (4.320, 0.5400), # 五星遗器数值
+ (2.00, 0.30), (2.5920, 0.3240), (5.1840, 0.6480), (3.4560, 0.4320), (3.4560, 0.4320), (5.1840, 0.6480)]]
+"""副属性词条档位:t0-基础值,t1-每提升一档的数值;l1-四星遗器数值,l2-五星遗器数值 <<数据来源:米游社@666bj>>"""
+
+BASE_STATS_TIER = [
+ [( 90.3168, 31.61088), (45.1584, 15.80544), (5.5296, 1.9354), (5.5296, 1.9354), (6.9120, 2.4192), (3.2256, 1.1), # 四星遗器数值
+ (4.1472, 1.4515), ( 8.2944, 2.9030), (5.5296, 1.9354), (4.4237, 1.5483), (4.9766, 1.7418), ( 8.2944, 2.9030), (2.4883, 0.8709)],
+ [(112.896, 39.5136 ), (56.448, 19.7568 ), (6.9120, 2.4192), (6.9120, 2.4192), (8.6400, 3.0240), (4.032, 1.4), # 五星遗器数值
+ (5.1840, 1.8144), (10.3680, 3.6288), (6.9120, 2.4192), (5.5296, 1.9354), (6.2208, 2.1773), (10.3680, 3.6288), (3.1104, 1.0886)]]
+"""主属性词条级别:t0-基础值,t1-每提升一级的数值;l1-四星遗器数值,l2-五星遗器数值 <<数据来源:米游社@666bj>>"""
+
+for i in range(len(BASE_STATS_TIER)):
+ BASE_STATS_TIER[i][10:10] = [BASE_STATS_TIER[i][10]] * 6 # 复制属性伤害
+
+
+RELIC_SCHEMA = {
+ "type": "object",
+ "additionalProperties": { # [主键]遗器哈希值 (由其键值遗器数据自动生成)
+ "type": "object",
+ "properties": {
+ "equip_set": { # 遗器部位
+ "type": "string",
+ "enum": EQUIP_SET_NAME
+ },
+ "relic_set": { # 遗器套装
+ "type": "string",
+ "enum": RELIC_SET_NAME[:, -1].tolist()
+ },
+ "rarity": { # 遗器稀有度 (2-5星)
+ "type": "integer",
+ "minimum": 2,
+ "maximum": 5
+ },
+ "level": { # 遗器等级 (0-15级)
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 15
+ },
+ "base_stats": { # 遗器主属性 (词条数为 1)
+ "type": "object",
+ "minProperties": 1,
+ "maxProperties": 1,
+ "properties": {
+ key: {"type": "number"} for key in BASE_STATS_NAME[:, -1]
+ },
+ "additionalProperties": False
+ },
+ "subs_stats": { # 遗器副属性 (词条数为 1-4)
+ "type": "object",
+ "minProperties": 1,
+ "maxProperties": 4,
+ "properties": {
+ key: {"type": "number"} for key in SUBS_STATS_NAME[:, -1]
+ },
+ "additionalProperties": False
+ },
+ "pre_ver_hash": {"type": "string"} # [外键]本次升级前的版本
+ },
+ "required": ["relic_set", "equip_set", "rarity", "level", "base_stats", "subs_stats"], # 需包含遗器的全部固有属性
+ "additionalProperties": False
+}}
+"""遗器数据json格式规范"""
+
+LOADOUT_SCHEMA = {
+ "type": "object",
+ "additionalProperties": { # [主键]人物名称 (以OCR结果为准)
+ "type": "object",
+ "additionalProperties": { # [次主键]配装名称 (自定义)
+ "type": "array", # 配装组成 (6件遗器,按部位排序)
+ "minItems": 6,
+ "maxItems": 6,
+ "items": {"type": "string"} # [外键]遗器哈希值
+}}}
+"""人物配装数据json格式规范"""
+
+TEAM_SCHEMA_PART = {
+ "additionalProperties": { # [主键]队伍名称 (自定义)
+ "type": "object",
+ "properties": {
+ "team_members": { # 队伍成员 (无序,1-4人)
+ "type": "object",
+ "additionalProperties": { # [外键]队伍成员名称 (以OCR结果为准)
+ "type": "string" # [外键]各队伍成员的配装名称
+ },
+ "minProperties": 1,
+ "maxProperties": 4
+ },
+ # 【可扩展】如"visible","ordered"等其他队伍属性
+ },
+ "required": ["team_members"], # 需包含遗器的全部固有属性
+ "additionalProperties": False
+}}
+TEAM_SCHEMA = {
+ "type": "object",
+ "properties": {
+ "compatible": # [主键]非互斥队伍组别 (默认,不可更改)
+ TEAM_SCHEMA_PART,
+ },
+ "additionalProperties": # [主键]互斥队伍组别名称 (自定义,例如用于忘却之庭上下半配队)【待扩展】
+ TEAM_SCHEMA_PART,
+}
+"""队伍配装数据json格式规范"""
+
+RELIC_DATA_FILTER = ["pre_ver_hash"]
+"""遗器数据过滤器"""
\ No newline at end of file