libs.domain.command

libs/domain/command.py

  1"""
  2libs/domain/command.py
  3"""
  4
  5import re
  6from collections.abc import Mapping
  7from dataclasses import dataclass
  8from typing import Any, Callable, Literal, TypedDict, Union
  9
 10import pandas as pd
 11
 12from libs.utils import formatter, textutil
 13from libs.utils.timekit import ExtendedDatetime as ExtDt
 14
 15CommandResult = Mapping[str, Union[str, int, bool, tuple[str, ...]]]
 16"""コマンド処理結果の型(パラメータ名とその値のマッピング)"""
 17CommandAction = Callable[[Union[str, tuple[str, ...]]], CommandResult]
 18"""コマンド処理関数の型(入力文字列を受け取り、結果辞書を返す)"""
 19
 20
 21class CommandSpec(TypedDict, total=False):
 22    """コマンドマッピングテーブル"""
 23
 24    match: list[str]
 25    action: CommandAction
 26    type: Literal["int", "str", "sql", "filename"]
 27
 28
 29CommandsDict = dict[str, CommandSpec]
 30COMMANDS: CommandsDict = {
 31    # --- ゲスト処理
 32    "guest_off": {
 33        "match": [r"^ゲストナシ$"],
 34        "action": lambda _: {"guest_skip": False, "guest_skip2": False, "unregistered_replace": True},
 35    },
 36    "guest_on": {
 37        "match": [r"^ゲストアリ$"],
 38        "action": lambda _: {"guest_skip": True, "guest_skip2": True, "unregistered_replace": True},
 39    },
 40    # --- 個人戦/チーム戦
 41    "individual": {
 42        "match": [r"^個人$", "^個人成績$"],
 43        "action": lambda _: {"individual": True},
 44    },
 45    "team": {
 46        "match": [r"^チーム$", "^チーム成績$", "^team$"],
 47        "action": lambda _: {"individual": False},
 48    },
 49    "all_player": {
 50        "match": [r"^全員$", r"^all$"],
 51        "action": lambda _: {"all_player": True},
 52    },
 53    "a": {
 54        "match": [r"^(チーム同卓アリ|コンビアリ|同士討チ)$"],
 55        "action": lambda _: {"friendly_fire": True},
 56    },
 57    "b": {
 58        "match": [r"^(チーム同卓ナシ|コンビナシ)$"],
 59        "action": lambda _: {"friendly_fire": False},
 60    },
 61    # --- プレイヤー名変換処理
 62    "guest_disable": {
 63        "match": [r"^ゲスト無効$"],
 64        "action": lambda _: {"unregistered_replace": False},
 65    },
 66    "anonymous": {
 67        "match": [r"^匿名$", r"^anonymous$"],
 68        "action": lambda _: {"anonymous": True},
 69    },
 70    # --- 動作変更フラグ
 71    "score_comparisons": {  # 比較
 72        "match": [r"^比較$", r"^点差$", r"^差分$"],
 73        "action": lambda _: {"score_comparisons": True},
 74    },
 75    "order": {  # 順位出力
 76        "match": [r"^順位$"],
 77        "action": lambda _: {"order": True},
 78    },
 79    "results": {  # 戦績
 80        "match": [r"^戦績$"],
 81        "action": lambda _: {"game_results": True},
 82    },
 83    "versus": {  # 対戦結果
 84        "match": [r"^対戦結果$", r"^対戦$"],
 85        "action": lambda _: {"versus_matrix": True},
 86    },
 87    "statistics": {  # 統計
 88        "match": [r"^統計$"],
 89        "action": lambda _: {"statistics": True},
 90    },
 91    "rating": {  # レーティング
 92        "match": [r"^レート$", r"^レーティング$", r"^rate$", r"^ratings?$"],
 93        "action": lambda _: {"rating": True},
 94    },
 95    "verbose": {  # 詳細
 96        "match": [r"^詳細$", r"^verbose$"],
 97        "action": lambda _: {"verbose": True},
 98    },
 99    # --- 集計条件
100    "ranked": {
101        "match": [r"^(トップ|上位|top)(\d*)$"],
102        "action": lambda w: {"ranked": w},
103    },
104    "stipulated": {
105        "match": [r"^(規定数|規定打数)(\d*)$"],
106        "action": lambda w: {"stipulated": w},
107    },
108    "interval": {
109        "match": [r"^(期間|区間|区切リ?|interval)(\d*)$"],
110        "action": lambda w: {"interval": w},
111    },
112    # --- 集約 / 検索条件
113    "daily": {
114        "match": [r"^daily$", r"^日次$", r"^デイリー$"],
115        "action": lambda _: {"collection": "daily"},
116    },
117    "weekly": {
118        "match": [r"^weekly$", r"^週次$", r"^ウイークリー$"],
119        "action": lambda _: {"collection": "weekly"},
120    },
121    "monthly": {
122        "match": [r"^monthly$", r"^月次$", r"^マンスリー$"],
123        "action": lambda _: {"collection": "monthly"},
124    },
125    "yearly": {
126        "match": [r"^yearly$", r"^年次$", r"^イヤーリー$"],
127        "action": lambda _: {"collection": "yearly"},
128    },
129    "collection": {
130        "match": [r"^全体$"],
131        "action": lambda _: {"collection": "all"},
132    },
133    "comment": {
134        "match": [r"^(コメント|comment)(.*)$"],
135        "action": lambda w: {"search_word": w},
136        "type": "sql",
137    },
138    "grouping": {
139        "match": [r"^(集約)(\d*)$"],
140        "action": lambda w: {"group_length": w},
141    },
142    "mode3": {
143        "match": [r"^三人打ち$", r"^三人打$", r"^三麻$", r"^サンマ$"],
144        "action": lambda _: {"target_mode": 3},
145    },
146    "mode4": {
147        "match": [r"^四人打ち$", r"^四人打$", r"^四麻$", r"^ヨンマ$"],
148        "action": lambda _: {"target_mode": 4},
149    },
150    "most_recent": {
151        "match": [r"^(直近)(\d*)$"],
152        "action": lambda w: {"target_count": w},
153    },
154    "mixed": {
155        "match": [r"^横断$", r"^mix$", r"^mixed$"],
156        "action": lambda _: {"mixed": True},
157    },
158    # --- 出力オプション
159    "format": {
160        "match": [r"^(csv|text|txt)$"],
161        "action": lambda w: {"format": w if w != "text" else "txt"},
162        "type": "str",
163    },
164    "filename": {
165        "match": [r"^(filename:|ファイル名)(.*)$"],
166        "action": lambda w: {"filename": w},
167        "type": "filename",
168    },
169}
170
171
172@dataclass
173class ParsedCommand:
174    """コマンド解析結果"""
175
176    flags: dict[str, Any]
177    """真偽値、引数を持つオプションを格納"""
178    arguments: list[str]
179    """単独オプションを格納"""
180    unknown: list[str]
181    """オプションと認識されない文字列を格納(プレイヤー名候補)"""
182    search_range: list[ExtDt]
183    """検索範囲の日時を格納"""
184
185
186class CommandParser:
187    """引数解析クラス"""
188
189    def __init__(self) -> None:
190        self.day_format = re.compile(r"^([0-9]{8}|[0-9/.-]{8,10})$")
191        """日付文字列判定用正規表現
192        - *yyyymmdd*
193        - *yyyy/mm/dd*, *yyyy/m/d*
194        - *yyyy-mm-dd*, *yyyy-m-d*
195        - *yyyy.mm.dd*, *yyyy.m.d*
196        """
197
198    @classmethod
199    def is_valid_command(cls, word: str) -> bool:
200        """
201        引数がコマンド名と一致するか判定する
202
203        Args:
204            word (str): チェック文字列
205
206        Returns:
207            bool: 真偽
208
209        """
210        for cmd in COMMANDS.values():
211            for pattern in cmd["match"]:
212                m = re.match(pattern, word)
213                if m:
214                    return True
215                m = re.match(pattern, textutil.str_conv(word.lower(), textutil.ConversionType.HtoK))
216                if m:
217                    return True
218
219        return False
220
221    def analysis_argument(self, argument: list[str]) -> ParsedCommand:
222        """
223        コマンドライン引数を解析する
224
225        Args:
226            argument (list[str]): 引数
227
228        Returns:
229            ParsedCommand: 結果
230
231        """
232        ret: dict[str, Any] = {}
233        unknown: list[str] = []
234        args: list[str] = []
235        search_range: list[Any] = []
236
237        for keyword in argument:
238            check_word = textutil.str_conv(keyword.lower(), textutil.ConversionType.HtoK)
239            check_word = check_word.replace("無シ", "ナシ").replace("有リ", "アリ")
240
241            if re.match(r"^([0-9]{8}|[0-9/.-]{8,10})$", check_word):
242                try_day = pd.to_datetime(check_word, errors="coerce").to_pydatetime()
243                if not pd.isna(try_day):
244                    search_range.append(ExtDt(try_day))
245                    search_range.append(ExtDt(try_day) + {"hour": 23, "minute": 59, "second": 59, "microsecond": 999999})
246                continue
247
248            if check_word in ExtDt.valid_keywords():
249                search_range.append(check_word)
250                continue
251
252            for cmd in COMMANDS.values():
253                for pattern in cmd["match"]:
254                    m = re.match(pattern, keyword)
255                    if m:
256                        ret.update(self._parse_match(cmd, m))
257                        break
258                    m = re.match(pattern, check_word)
259                    if m:
260                        ret.update(self._parse_match(cmd, m))
261                        break
262                else:
263                    continue
264                break
265            else:
266                unknown.append(formatter.name_replace(keyword, add_mark=False, not_replace=True))
267
268        return ParsedCommand(flags=ret, arguments=args, unknown=unknown, search_range=search_range)
269
270    def _parse_match(self, cmd: CommandSpec, obj: re.Match[str]) -> dict[str, Any]:
271        """
272        コマンド名に一致したときの処理
273
274        Args:
275            cmd (CommandSpec): コマンドマップ
276            obj (re.Match[str]): Matchオブジェクト
277
278        Returns:
279            dict[str, Any]: 更新用辞書
280
281        """
282        ret: dict[str, Any] = {}
283
284        def with_arguments(tmp: dict[str, Any]) -> None:
285            key = str(next(iter(tmp.keys())))
286            val = str(tmp[key][1])
287            if "" != val:
288                match cmd.get("type"):
289                    case "str":
290                        ret.update({key: val})
291                    case "sql":
292                        ret.update({key: f"%{val}%"})
293                    case "filename":
294                        if re.search(r"^[\w()\-\.]+$", val):
295                            ret.update({key: val})
296                    case "int":
297                        ret.update({key: int(val)})
298                    case _:
299                        ret.update({key: int(val) if val.isdigit() else val})
300
301        match len(obj.groups()):
302            case 0:  # 完全一致: ^command$
303                ret.update(cmd["action"](obj.group()))
304            case 1:  # 選択: ^(command1|command2|...)$
305                ret.update(cmd["action"](obj.groups()[0]))
306            case 2:  # 引数あり: ^(command)(\d*)$
307                tmp = cmd["action"](obj.groups())
308                if isinstance(tmp, dict):
309                    for k, v in tmp.items():
310                        if isinstance(v, tuple):  # 引数取り出し&セット
311                            with_arguments(tmp)
312                        if isinstance(v, bool):  # フラグ上書き
313                            ret.update({k: v})
314
315        return ret
CommandResult = collections.abc.Mapping[str, str | int | bool | tuple[str, ...]]

コマンド処理結果の型(パラメータ名とその値のマッピング)

CommandAction = typing.Callable[[str | tuple[str, ...]], collections.abc.Mapping[str, str | int | bool | tuple[str, ...]]]

コマンド処理関数の型(入力文字列を受け取り、結果辞書を返す)

class CommandSpec(typing.TypedDict):
22class CommandSpec(TypedDict, total=False):
23    """コマンドマッピングテーブル"""
24
25    match: list[str]
26    action: CommandAction
27    type: Literal["int", "str", "sql", "filename"]

コマンドマッピングテーブル

match: list[str]
action: Callable[[str | tuple[str, ...]], Mapping[str, str | int | bool | tuple[str, ...]]]
type: Literal['int', 'str', 'sql', 'filename']
CommandsDict = dict[str, CommandSpec]
COMMANDS: dict[str, CommandSpec] = {'guest_off': {'match': ['^ゲストナシ$'], 'action': <function <lambda>>}, 'guest_on': {'match': ['^ゲストアリ$'], 'action': <function <lambda>>}, 'individual': {'match': ['^個人$', '^個人成績$'], 'action': <function <lambda>>}, 'team': {'match': ['^チーム$', '^チーム成績$', '^team$'], 'action': <function <lambda>>}, 'all_player': {'match': ['^全員$', '^all$'], 'action': <function <lambda>>}, 'a': {'match': ['^(チーム同卓アリ|コンビアリ|同士討チ)$'], 'action': <function <lambda>>}, 'b': {'match': ['^(チーム同卓ナシ|コンビナシ)$'], 'action': <function <lambda>>}, 'guest_disable': {'match': ['^ゲスト無効$'], 'action': <function <lambda>>}, 'anonymous': {'match': ['^匿名$', '^anonymous$'], 'action': <function <lambda>>}, 'score_comparisons': {'match': ['^比較$', '^点差$', '^差分$'], 'action': <function <lambda>>}, 'order': {'match': ['^順位$'], 'action': <function <lambda>>}, 'results': {'match': ['^戦績$'], 'action': <function <lambda>>}, 'versus': {'match': ['^対戦結果$', '^対戦$'], 'action': <function <lambda>>}, 'statistics': {'match': ['^統計$'], 'action': <function <lambda>>}, 'rating': {'match': ['^レート$', '^レーティング$', '^rate$', '^ratings?$'], 'action': <function <lambda>>}, 'verbose': {'match': ['^詳細$', '^verbose$'], 'action': <function <lambda>>}, 'ranked': {'match': ['^(トップ|上位|top)(\\d*)$'], 'action': <function <lambda>>}, 'stipulated': {'match': ['^(規定数|規定打数)(\\d*)$'], 'action': <function <lambda>>}, 'interval': {'match': ['^(期間|区間|区切リ?|interval)(\\d*)$'], 'action': <function <lambda>>}, 'daily': {'match': ['^daily$', '^日次$', '^デイリー$'], 'action': <function <lambda>>}, 'weekly': {'match': ['^weekly$', '^週次$', '^ウイークリー$'], 'action': <function <lambda>>}, 'monthly': {'match': ['^monthly$', '^月次$', '^マンスリー$'], 'action': <function <lambda>>}, 'yearly': {'match': ['^yearly$', '^年次$', '^イヤーリー$'], 'action': <function <lambda>>}, 'collection': {'match': ['^全体$'], 'action': <function <lambda>>}, 'comment': {'match': ['^(コメント|comment)(.*)$'], 'action': <function <lambda>>, 'type': 'sql'}, 'grouping': {'match': ['^(集約)(\\d*)$'], 'action': <function <lambda>>}, 'mode3': {'match': ['^三人打ち$', '^三人打$', '^三麻$', '^サンマ$'], 'action': <function <lambda>>}, 'mode4': {'match': ['^四人打ち$', '^四人打$', '^四麻$', '^ヨンマ$'], 'action': <function <lambda>>}, 'most_recent': {'match': ['^(直近)(\\d*)$'], 'action': <function <lambda>>}, 'mixed': {'match': ['^横断$', '^mix$', '^mixed$'], 'action': <function <lambda>>}, 'format': {'match': ['^(csv|text|txt)$'], 'action': <function <lambda>>, 'type': 'str'}, 'filename': {'match': ['^(filename:|ファイル名)(.*)$'], 'action': <function <lambda>>, 'type': 'filename'}}
@dataclass
class ParsedCommand:
173@dataclass
174class ParsedCommand:
175    """コマンド解析結果"""
176
177    flags: dict[str, Any]
178    """真偽値、引数を持つオプションを格納"""
179    arguments: list[str]
180    """単独オプションを格納"""
181    unknown: list[str]
182    """オプションと認識されない文字列を格納(プレイヤー名候補)"""
183    search_range: list[ExtDt]
184    """検索範囲の日時を格納"""

コマンド解析結果

ParsedCommand( flags: dict[str, typing.Any], arguments: list[str], unknown: list[str], search_range: list[libs.utils.timekit.ExtendedDatetime])
flags: dict[str, typing.Any]

真偽値、引数を持つオプションを格納

arguments: list[str]

単独オプションを格納

unknown: list[str]

オプションと認識されない文字列を格納(プレイヤー名候補)

検索範囲の日時を格納

class CommandParser:
187class CommandParser:
188    """引数解析クラス"""
189
190    def __init__(self) -> None:
191        self.day_format = re.compile(r"^([0-9]{8}|[0-9/.-]{8,10})$")
192        """日付文字列判定用正規表現
193        - *yyyymmdd*
194        - *yyyy/mm/dd*, *yyyy/m/d*
195        - *yyyy-mm-dd*, *yyyy-m-d*
196        - *yyyy.mm.dd*, *yyyy.m.d*
197        """
198
199    @classmethod
200    def is_valid_command(cls, word: str) -> bool:
201        """
202        引数がコマンド名と一致するか判定する
203
204        Args:
205            word (str): チェック文字列
206
207        Returns:
208            bool: 真偽
209
210        """
211        for cmd in COMMANDS.values():
212            for pattern in cmd["match"]:
213                m = re.match(pattern, word)
214                if m:
215                    return True
216                m = re.match(pattern, textutil.str_conv(word.lower(), textutil.ConversionType.HtoK))
217                if m:
218                    return True
219
220        return False
221
222    def analysis_argument(self, argument: list[str]) -> ParsedCommand:
223        """
224        コマンドライン引数を解析する
225
226        Args:
227            argument (list[str]): 引数
228
229        Returns:
230            ParsedCommand: 結果
231
232        """
233        ret: dict[str, Any] = {}
234        unknown: list[str] = []
235        args: list[str] = []
236        search_range: list[Any] = []
237
238        for keyword in argument:
239            check_word = textutil.str_conv(keyword.lower(), textutil.ConversionType.HtoK)
240            check_word = check_word.replace("無シ", "ナシ").replace("有リ", "アリ")
241
242            if re.match(r"^([0-9]{8}|[0-9/.-]{8,10})$", check_word):
243                try_day = pd.to_datetime(check_word, errors="coerce").to_pydatetime()
244                if not pd.isna(try_day):
245                    search_range.append(ExtDt(try_day))
246                    search_range.append(ExtDt(try_day) + {"hour": 23, "minute": 59, "second": 59, "microsecond": 999999})
247                continue
248
249            if check_word in ExtDt.valid_keywords():
250                search_range.append(check_word)
251                continue
252
253            for cmd in COMMANDS.values():
254                for pattern in cmd["match"]:
255                    m = re.match(pattern, keyword)
256                    if m:
257                        ret.update(self._parse_match(cmd, m))
258                        break
259                    m = re.match(pattern, check_word)
260                    if m:
261                        ret.update(self._parse_match(cmd, m))
262                        break
263                else:
264                    continue
265                break
266            else:
267                unknown.append(formatter.name_replace(keyword, add_mark=False, not_replace=True))
268
269        return ParsedCommand(flags=ret, arguments=args, unknown=unknown, search_range=search_range)
270
271    def _parse_match(self, cmd: CommandSpec, obj: re.Match[str]) -> dict[str, Any]:
272        """
273        コマンド名に一致したときの処理
274
275        Args:
276            cmd (CommandSpec): コマンドマップ
277            obj (re.Match[str]): Matchオブジェクト
278
279        Returns:
280            dict[str, Any]: 更新用辞書
281
282        """
283        ret: dict[str, Any] = {}
284
285        def with_arguments(tmp: dict[str, Any]) -> None:
286            key = str(next(iter(tmp.keys())))
287            val = str(tmp[key][1])
288            if "" != val:
289                match cmd.get("type"):
290                    case "str":
291                        ret.update({key: val})
292                    case "sql":
293                        ret.update({key: f"%{val}%"})
294                    case "filename":
295                        if re.search(r"^[\w()\-\.]+$", val):
296                            ret.update({key: val})
297                    case "int":
298                        ret.update({key: int(val)})
299                    case _:
300                        ret.update({key: int(val) if val.isdigit() else val})
301
302        match len(obj.groups()):
303            case 0:  # 完全一致: ^command$
304                ret.update(cmd["action"](obj.group()))
305            case 1:  # 選択: ^(command1|command2|...)$
306                ret.update(cmd["action"](obj.groups()[0]))
307            case 2:  # 引数あり: ^(command)(\d*)$
308                tmp = cmd["action"](obj.groups())
309                if isinstance(tmp, dict):
310                    for k, v in tmp.items():
311                        if isinstance(v, tuple):  # 引数取り出し&セット
312                            with_arguments(tmp)
313                        if isinstance(v, bool):  # フラグ上書き
314                            ret.update({k: v})
315
316        return ret

引数解析クラス

day_format

日付文字列判定用正規表現

  • yyyymmdd
  • yyyy/mm/dd, yyyy/m/d
  • yyyy-mm-dd, yyyy-m-d
  • yyyy.mm.dd, yyyy.m.d
@classmethod
def is_valid_command(cls, word: str) -> bool:
199    @classmethod
200    def is_valid_command(cls, word: str) -> bool:
201        """
202        引数がコマンド名と一致するか判定する
203
204        Args:
205            word (str): チェック文字列
206
207        Returns:
208            bool: 真偽
209
210        """
211        for cmd in COMMANDS.values():
212            for pattern in cmd["match"]:
213                m = re.match(pattern, word)
214                if m:
215                    return True
216                m = re.match(pattern, textutil.str_conv(word.lower(), textutil.ConversionType.HtoK))
217                if m:
218                    return True
219
220        return False

引数がコマンド名と一致するか判定する

Arguments:
  • word (str): チェック文字列
Returns:

bool: 真偽

def analysis_argument(self, argument: list[str]) -> ParsedCommand:
222    def analysis_argument(self, argument: list[str]) -> ParsedCommand:
223        """
224        コマンドライン引数を解析する
225
226        Args:
227            argument (list[str]): 引数
228
229        Returns:
230            ParsedCommand: 結果
231
232        """
233        ret: dict[str, Any] = {}
234        unknown: list[str] = []
235        args: list[str] = []
236        search_range: list[Any] = []
237
238        for keyword in argument:
239            check_word = textutil.str_conv(keyword.lower(), textutil.ConversionType.HtoK)
240            check_word = check_word.replace("無シ", "ナシ").replace("有リ", "アリ")
241
242            if re.match(r"^([0-9]{8}|[0-9/.-]{8,10})$", check_word):
243                try_day = pd.to_datetime(check_word, errors="coerce").to_pydatetime()
244                if not pd.isna(try_day):
245                    search_range.append(ExtDt(try_day))
246                    search_range.append(ExtDt(try_day) + {"hour": 23, "minute": 59, "second": 59, "microsecond": 999999})
247                continue
248
249            if check_word in ExtDt.valid_keywords():
250                search_range.append(check_word)
251                continue
252
253            for cmd in COMMANDS.values():
254                for pattern in cmd["match"]:
255                    m = re.match(pattern, keyword)
256                    if m:
257                        ret.update(self._parse_match(cmd, m))
258                        break
259                    m = re.match(pattern, check_word)
260                    if m:
261                        ret.update(self._parse_match(cmd, m))
262                        break
263                else:
264                    continue
265                break
266            else:
267                unknown.append(formatter.name_replace(keyword, add_mark=False, not_replace=True))
268
269        return ParsedCommand(flags=ret, arguments=args, unknown=unknown, search_range=search_range)

コマンドライン引数を解析する

Arguments:
  • argument (list[str]): 引数
Returns:

ParsedCommand: 結果