├── .gitignore ├── README.md ├── beetsplug └── originquery.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | beets-originquery 2 | ================= 3 | 4 | Plugin for beets that uses supplemental files in imported directories to improve MusicBrainz matches for untagged data. 5 | 6 | Motivation 7 | ---------- 8 | 9 | Whenever beets tries to identify your music, it queries MusicBrainz using tags from your music files. The query returns 10 | only the few best matches of the many possible results, however, so the better your tags, the more likely you are to get 11 | a good match. 12 | 13 | But one of the reasons we're using beets to begin with is to _get_ those tags; music is often tagged with only the most 14 | essential data (i.e., album and artist), lacking tags that could actually identify the specific edition of an album. 15 | That puts us in a chicken-and-egg situation: beets can't accurately identify the release until it has relevant tags, but 16 | it can't assign relevant tags without knowing the release! 17 | 18 | In other words, while beets is an excellent tool, it's only as useful as the data it has available. It's common to store 19 | extra data in separate text or JSON files, but that data isn't read by beets as it's not included in music files 20 | themselves. If only there were a way to feed this origin data to beets to supplement our tags to accurately identify 21 | editions… 22 | 23 | Enter `originquery`. 24 | 25 | Installation 26 | ------------ 27 | 28 | This plugin relies on cutting-edge beets features to work. In particular, your beets installation must support the 29 | [`extra_tags`](https://github.com/beetbox/beets/blob/master/docs/reference/config.rst#id70) setting, which is not yet in 30 | an official beets release. Until beets v1.5.0 is released with [the commit adding this 31 | feature](https://github.com/beetbox/beets/commit/8ed76f1198c23b9205c6f566860a35569945d4bb), you must install the latest 32 | beets manually: 33 | 34 | $> pip install git+https://github.com/beetbox/beets 35 | $> beet --version 36 | beets version 1.5.0 37 | 38 | Once you have the latest and greatest beets, you can install this plugin: 39 | 40 | $> pip install git+https://github.com/x1ppy/beets-originquery 41 | 42 | Next, add the following section to your beets config file to enable improved MediaBrainz queries from tags: 43 | 44 | musicbrainz: 45 | extra_tags: [year, catalognum, country, media, label] 46 | 47 | Finally, add `originquery` to the `plugins` section of your beets config file, creating it if it doesn't exist: 48 | 49 | plugins: 50 | - originquery 51 | 52 | Configuration 53 | ------------- 54 | 55 | `originquery` reads an _origin file_ at the root of each album directory when music is imported. The origin file can be 56 | either a text, JSON, or YAML file. Beyond that, the format of the file is entirely user-defined and is specified in the 57 | `originquery` configuration. 58 | 59 | Your beets configuration must contain a section with the following fields: 60 | 61 | originquery: 62 | origin_file: 63 | tag_patterns: 64 | : 65 | : 66 | 67 | The `origin_file` supports glob wildcard characters. So, for instance, if you use a date scheme for your origin file 68 | naming (e.g., `origin-2020025.txt`), you could specify `origin_file: 'origin-*.txt'` here. If the pattern matches 69 | multiple files, the first file in the alphanumerically sorted list of results will be used. 70 | 71 | The tags under `tag_patterns` can be any combination of the following tags: 72 | * `media` (CD, vinyl, …) 73 | * `year` (edition year, _not_ original release year) 74 | * `country` (US, Japan, …) 75 | * `label` (Epic, Atlantic, …) 76 | * `catalognum` (ABC-XYZ, 102030, …) 77 | * `albumdisambig` (Remastered, Deluxe Edition, …) 78 | 79 | By default, the origin file parser will be determined by its file extension. `.yaml` will be parsed as YAML, `.json` 80 | will be parsed as JSON, and generic text parsing will be used otherwise. If your file format doesn't match its 81 | extension, you can override the file type with the `origin_type` config option, setting the type to either `yaml`, 82 | `json`, or `text`: 83 | 84 | originquery: 85 | ... 86 | origin_type: 87 | 88 | The patterns used will depend on your origin file type as outlined below. 89 | 90 | ### Text files 91 | 92 | When using text origin files, the `tag_patterns` pattern must be a regular expression containing a single match group 93 | corresponding to the value for the given tag. 94 | 95 | As an arbitrary example, you might have origin files that look like the following: 96 | 97 | media=CD 98 | year=1988 99 | label=Mobile Fidelity Sound Lab 100 | catalognum=UDCD 517 101 | 102 | In this case, your beets config would look like this: 103 | 104 | originquery: 105 | origin_file: origin.txt 106 | tag_patterns: 107 | media: 'media=(.+)' 108 | year: 'year=(\d{4})' 109 | label: 'label=(.+)' 110 | catalognum: 'catalognum=(.+)' 111 | 112 | This means that you have a file named `origin.txt` at the root of each album directory, and the `media`, `year`, `label`, 113 | and `catalognum` tags will be parsed from this file and used by beets to query MusicBrainz. In this case, each tag and 114 | value would be listed in the origin file separated by an `=` (i.e., `=`) as shown in the example. Of course, 115 | you're free to use a completely different formatting scheme if you update the patterns accordingly. 116 | 117 | ### JSON files 118 | 119 | With JSON origin files, the `tag_patterns` pattern must be a [JSONPath](https://goessner.net/articles/JsonPath/) 120 | expression that points to the value for the given tag. 121 | 122 | As an arbitrary example, you might have origin files that look like the following: 123 | 124 | { 125 | "mydata": { 126 | "media": "CD", 127 | "year": 1988, 128 | "label": "Mobile Fidelity Sound Lab", 129 | "catalognum": "UDCD 517" 130 | } 131 | } 132 | 133 | In this case, your beets config would look like this: 134 | 135 | originquery: 136 | origin_file: origin.json 137 | tag_patterns: 138 | media: '$.mydata.media' 139 | year: '$.mydata.year' 140 | label: '$.mydata.label' 141 | catalognum: '$.mydata.catalognum' 142 | 143 | This means that you have a file named `origin.json` at the root of each album directory, and the `media`, `year`, `label`, 144 | and `catalognum` tags will be parsed from this file and used by beets to query MusicBrainz. In this case, the tag and 145 | value mappings would be defined in an object literal under the `mydata` key at the root of the object as shown in the 146 | example. Of course, you're free to use a completely different schema if you update the patterns accordingly. 147 | 148 | ### YAML files 149 | 150 | Like JSON origin files, YAML files use [JSONPath](https://goessner.net/articles/JsonPath/) for `tag_patterns`. 151 | 152 | For example, with origin files that look like the following: 153 | 154 | mydata: 155 | media: CD 156 | year: 1988 157 | label: Mobile Fidelity Sound Lab 158 | catalognum: UDCD 517 159 | 160 | you would use the same JSONPath-based `tag_patterns` config as JSON files (see above). `origin_file` would of course 161 | point to `origin.yaml` instead of `origin.json`. 162 | 163 | Examples 164 | -------- 165 | 166 | ### Before `originquery` 167 | 168 | Just as a baseline, let's first try a beets import without `originquery`. We'll import [this 169 | edition](https://musicbrainz.org/release/51a4e8b4-1af1-4daf-a746-ac1c7206dd02) of Led Zeppelin's Houses of the Holy: 170 | 171 | $> beet import ~/music 172 | 173 | /home/x1ppy/music/(1973) Houses Of The Holy [2014 Remaster] (8 items) 174 | Correcting tags from: 175 | Led Zeppelin - Houses Of The Holy 176 | To: 177 | Led Zeppelin - Houses of the Holy 178 | URL: 179 | https://musicbrainz.org/release/3ccb4cb2-940a-4e2e-b1fd-4c0b7483280f 180 | (Similarity: 100.0%) (12" Vinyl, 1973, US, Atlantic, SD 7255) 181 | * The Song Remains The Same -> The Song Remains the Same 182 | * Over The Hills And Far Away -> Over the Hills and Far Away 183 | * D'yer Mak'er -> D’yer Mak’er 184 | [A]pply, More candidates, Skip, Use as-is, as Tracks, Group albums, Enter search, enter Id, aBort? 185 | 186 | Nice, 100%! A perfect match…or is it? 187 | 188 | On closer inspection, you'll notice that this is actually a very different edition than the one we're importing. beets 189 | is reporting the media as 12" Vinyl (instead of CD), the edition year is 1973 (instead of 2014), and the catalog number 190 | is different. No bueno. 191 | 192 | ### With `originquery` 193 | 194 | Now, let's compare that to a query with `originquery` enabled: 195 | 196 | $> beet import ~/music 197 | 198 | /home/x1ppy/music/(1973) Houses Of The Holy [2014 Remaster] (8 items) 199 | originquery: Using origin file /home/x1ppy/music/(1973) Houses Of The Holy [2014 Remaster]/origin.txt 200 | originquery: ╔════════════════╤═════════════╤═════════════╗ 201 | originquery: ║ Field │ Tagged Data │ Origin Data ║ 202 | originquery: ╟────────────────┼─────────────┼─────────────╢ 203 | originquery: ║ Media │ │ CD ║ 204 | originquery: ║ Edition year │ 1973 │ 2014 ║ 205 | originquery: ║ Record label │ │ Atlantic ║ 206 | originquery: ║ Catalog number │ │ 8122795828 ║ 207 | originquery: ║ Edition │ │ Remastered ║ 208 | originquery: ╚════════════════╧═════════════╧═════════════╝ 209 | Correcting tags from: 210 | Led Zeppelin - Houses Of The Holy 211 | To: 212 | Led Zeppelin - Houses of the Holy 213 | URL: 214 | https://musicbrainz.org/release/51a4e8b4-1af1-4daf-a746-ac1c7206dd02 215 | (Similarity: 100.0%) (CD, 2014, XE, Atlantic, 8122795828) 216 | * The Song Remains The Same -> The Song Remains the Same 217 | * Over The Hills And Far Away -> Over the Hills and Far Away 218 | * D'yer Mak'er -> D’yer Mak’er 219 | [A]pply, More candidates, Skip, Use as-is, as Tracks, Group albums, Enter search, enter Id, aBort? 220 | 221 | Another 100% match! This time, though, all of the fields reported by beets exactly match the ones we were looking for. 222 | Success! 223 | 224 | You'll also notice the shiny new table shown with the beets result. This gives us a handy reference for tags: the Tagged 225 | Data column lists the data beets found in the music files, and the Origin Data column lists the data `originquery` 226 | pulled from the origin file. With this information on hand, it's now clear why beets wasn't able to match the proper 227 | edition: the music tags don't contain _any_ tags that could actually identify the specific release! 228 | 229 | ### Conflicts 230 | 231 | Occasionally, you might see `originquery` complain about conflicts between tagged data and origin data: 232 | 233 | /home/x1ppy/music/Billy Joel - 1978 - 52nd Street (9 items) 234 | originquery: Using origin file /home/x1ppy/music/Billy Joel - 1978 - 52nd Street/origin.txt 235 | originquery: ╔════════════════╤═════════════╤════════════════╗ 236 | originquery: ║ Field │ Tagged Data │ Origin Data ║ 237 | originquery: ╟────────────────┼─────────────┼────────────────╢ 238 | originquery: ║ Media │ │ CD ║ 239 | originquery: ║ Edition year │ 1978 │ 2010 ║ 240 | originquery: ║ Record label │ Columbia │ Audio Fidelity ║ 241 | originquery: ║ Catalog number │ IDK 35609 │ AFZ 095 ║ 242 | originquery: ╚════════════════╧═════════════╧════════════════╝ 243 | originquery: Origin data conflicts with tagged data. 244 | Tagging: 245 | Billy Joel - 52nd Street 246 | URL: 247 | https://musicbrainz.org/release/6942718c-2fd2-4227-a882-130c500806f5 248 | (Similarity: 100.0%) (CD, 1978, CA, Columbia, IDK 35609) 249 | [A]pply, More candidates, Skip, Use as-is, as Tracks, Group albums, Enter search, enter Id, aBort? 250 | 251 | This happens if either the music is mistagged or the origin file contains the wrong origin data. Here, we see the tagged 252 | catalog number is `IDK 35609`, but the origin data is `AFZ 095`. These are clearly different editions, and it wouldn't 253 | make sense to try to merge them to search MusicBrainz, so `originquery` chooses just one set of values to query 254 | MusicBrainz and ignores the other. 255 | 256 | By default, `originquery` uses the _tagged data_ in the case of a conflict. This behavior can be changed by setting 257 | `use_origin_on_conflict` to `yes` in the beets config: 258 | 259 | originquery: 260 | ... 261 | use_origin_on_conflict: yes 262 | 263 | Changelog 264 | --------- 265 | ### [1.0.2] - 2020-04-06 266 | * Added support for YAML origin files 267 | ### [1.0.1] - 2020-03-25 268 | * Added support for glob patterns in `origin_file` 269 | ### [1.0.0] - 2020-03-23 270 | * Initial release 271 | 272 | [1.0.2]: https://github.com/x1ppy/beets-originquery/compare/1.0.1...1.0.2 273 | [1.0.1]: https://github.com/x1ppy/beets-originquery/compare/1.0.0...1.0.1 274 | [1.0.0]: https://github.com/x1ppy/beets-originquery/releases/tag/1.0.0 275 | -------------------------------------------------------------------------------- /beetsplug/originquery.py: -------------------------------------------------------------------------------- 1 | import confuse 2 | import glob 3 | import json 4 | import jsonpath_rw 5 | import os 6 | import re 7 | import sys 8 | import yaml 9 | from collections import OrderedDict 10 | from beets import config, ui 11 | from beets.autotag.match import current_metadata 12 | from beets.plugins import BeetsPlugin 13 | from pathlib import Path 14 | 15 | BEETS_TO_LABEL = OrderedDict([ 16 | ('media', 'Media'), 17 | ('year', 'Edition year'), 18 | ('country', 'Country'), 19 | ('label', 'Record label'), 20 | ('catalognum', 'Catalog number'), 21 | ('albumdisambig', 'Edition'), 22 | ]) 23 | 24 | # Conflicts will be reported if any of these fields don't match. 25 | CONFLICT_FIELDS = ['catalognum', 'media'] 26 | 27 | 28 | def escape_braces(string): 29 | return string.replace('{', '{{').replace('}', '}}') 30 | 31 | 32 | def normalize_catno(catno): 33 | return catno.upper().replace(' ', '').replace('-', '') 34 | 35 | 36 | def sanitize_value(key, value): 37 | if key == 'media' and value == 'WEB': 38 | return 'Digital Media' 39 | if key == 'catalognum' or key == 'label': 40 | return re.split('[,/]', value)[0].strip() 41 | if key == 'year' and value == '0': 42 | return '' 43 | return value 44 | 45 | 46 | def highlight(text, active=True): 47 | if active: 48 | return ui.colorize('text_highlight_minor', text) 49 | return text 50 | 51 | 52 | class OriginQuery(BeetsPlugin): 53 | def __init__(self): 54 | super(OriginQuery, self).__init__() 55 | 56 | def fail(msg): 57 | self.error(msg) 58 | self.error('Plugin disabled.') 59 | 60 | try: 61 | self.extra_tags = config['musicbrainz']['extra_tags'].get() 62 | except confuse.NotFoundError: 63 | return fail('This version of beets does not support extra query tags.') 64 | 65 | if not len(self.extra_tags): 66 | return fail('Config error: musicbrainz.extra_tags not set.') 67 | 68 | config_patterns = None 69 | try: 70 | config_patterns = self.config['tag_patterns'].get() 71 | if not isinstance(config_patterns, dict): 72 | raise confuse.ConfigError() 73 | except confuse.ConfigError: 74 | return fail('Config error: originquery.tag_patterns must be set to a dictionary of key -> pattern mappings.') 75 | 76 | try: 77 | self.origin_file = Path(self.config['origin_file'].get()) 78 | except confuse.NotFoundError: 79 | return fail('Config error: originquery.origin_file not set.') 80 | self.tag_patterns = {} 81 | 82 | try: 83 | origin_type = self.config['origin_type'].as_choice(['yaml', 'json', 'text']).lower() 84 | except confuse.NotFoundError: 85 | origin_type = self.origin_file.suffix.lower()[1:] 86 | 87 | if origin_type == 'json': 88 | self.match_fn = self.match_json 89 | elif origin_type == 'yaml': 90 | self.match_fn = self.match_yaml 91 | else: 92 | self.match_fn = self.match_text 93 | 94 | for key, pattern in config_patterns.items(): 95 | if key not in BEETS_TO_LABEL: 96 | return fail('Config error: unknown key "{0}"'.format(key)) 97 | self.error('Plugin disabled.') 98 | 99 | if origin_type == 'json' or origin_type == 'yaml': 100 | try: 101 | self.tag_patterns[key] = jsonpath_rw.parse(pattern) 102 | except Exception as e: 103 | return fail('Config error: invalid tag pattern for "{0}". "{1}" is not a valid JSON path ({2}).' 104 | .format(key, pattern, format(str(e)))) 105 | continue 106 | 107 | try: 108 | regex = re.compile(pattern) 109 | self.tag_patterns[key] = regex 110 | except re.error as e: 111 | return fail('Config error: invalid tag pattern for "{0}". "{1}" is not a valid regex ({2}).' 112 | .format(key, pattern, format(str(e)))) 113 | if regex.groups != 1: 114 | return fail('Config error: invalid tag pattern for "{0}". "{1}" must have exactly one capture group.' 115 | .format(key, pattern)) 116 | 117 | self.register_listener('import_task_start', self.import_task_start) 118 | self.register_listener('before_choose_candidate', self.before_choose_candidate) 119 | self.tasks = {} 120 | 121 | try: 122 | self.use_origin_on_conflict = self.config['use_origin_on_conflict'].get(bool) 123 | except confuse.NotFoundError: 124 | self.use_origin_on_conflict = False 125 | 126 | 127 | def error(self, msg): 128 | self._log.error(escape_braces(ui.colorize('text_error', msg))) 129 | 130 | 131 | def warn(self, msg): 132 | self._log.warning(escape_braces(ui.colorize('text_warning', msg))) 133 | 134 | 135 | def info(self, msg): 136 | # beets defaults to log level warning for event handlers. 137 | self._log.warning(escape_braces(msg)) 138 | 139 | 140 | def print_tags(self, items, use_tagged): 141 | headers = ['Field', 'Tagged Data', 'Origin Data'] 142 | 143 | w_key = max(len(headers[0]), *(len(BEETS_TO_LABEL[k]) for k, v in items)) 144 | w_tagged = max(len(headers[1]), *(len(v['tagged']) for k, v in items)) 145 | w_origin = max(len(headers[2]), *(len(v['origin']) for k, v in items)) 146 | 147 | self.info('╔{0}╤{1}╤{2}╗'.format('═' * (w_key + 2), '═' * (w_tagged + 2), '═' * (w_origin + 2))) 148 | self.info('║ {0} │ {1} │ {2} ║'.format(headers[0].ljust(w_key), 149 | highlight(headers[1].ljust(w_tagged), use_tagged), 150 | highlight(headers[2].ljust(w_origin), not use_tagged))) 151 | self.info('╟{0}┼{1}┼{2}╢'.format('─' * (w_key + 2), '─' * (w_tagged + 2), '─' * (w_origin + 2))) 152 | for k, v in items: 153 | if not v['tagged'] and not v['origin']: 154 | continue 155 | tagged_active = use_tagged and v['active'] 156 | origin_active = not use_tagged and v['active'] 157 | self.info('║ {0} │ {1} │ {2} ║'.format(BEETS_TO_LABEL[k].ljust(w_key), 158 | highlight(v['tagged'].ljust(w_tagged), tagged_active), 159 | highlight(v['origin'].ljust(w_origin), origin_active))) 160 | self.info('╚{0}╧{1}╧{2}╝'.format('═' * (w_key + 2), '═' * (w_tagged + 2), '═' * (w_origin + 2))) 161 | 162 | 163 | def before_choose_candidate(self, task, session): 164 | task_info = self.tasks[task] 165 | origin_path = task_info['origin_path'] 166 | 167 | if task_info.get('missing_origin', False): 168 | self.warn('No origin file found at {0}'.format(origin_path)) 169 | return 170 | else: 171 | self.info('Using origin file {0}'.format(origin_path)) 172 | 173 | conflict = task_info.get('conflict', False) 174 | use_tagged = conflict and not self.use_origin_on_conflict 175 | self.print_tags(task_info.get('tag_compare').items(), use_tagged) 176 | 177 | if conflict: 178 | self.warn("Origin data conflicts with tagged data.") 179 | 180 | 181 | def match_text(self, origin_path): 182 | with open(origin_path, encoding="utf-8") as f: 183 | lines = f.readlines() 184 | 185 | for key, pattern in self.tag_patterns.items(): 186 | for line in lines: 187 | line = line.strip() 188 | match = re.match(pattern, line) 189 | if not match: 190 | continue 191 | yield key, match[1] 192 | 193 | 194 | def match_json(self, origin_path): 195 | with open(origin_path, encoding="utf-8") as f: 196 | data = json.load(f) 197 | 198 | for key, pattern in self.tag_patterns.items(): 199 | match = pattern.find(data) 200 | if not len(match): 201 | continue 202 | 203 | yield key, str(match[0].value) 204 | 205 | 206 | def match_yaml(self, origin_path): 207 | with open(origin_path, encoding="utf-8") as f: 208 | data = yaml.load(f, Loader=yaml.SafeLoader) 209 | 210 | for key, pattern in self.tag_patterns.items(): 211 | match = pattern.find(data) 212 | if not len(match) or not match[0].value: 213 | continue 214 | yield key, str(match[0].value) 215 | 216 | 217 | def import_task_start(self, task, session): 218 | task_info = self.tasks[task] = {} 219 | 220 | # In case this is a multi-disc import, find the common parent directory. 221 | base = os.path.commonpath(task.paths).decode('utf8') 222 | 223 | glob_pattern = os.path.join(glob.escape(base), self.origin_file) 224 | origin_glob = sorted(glob.glob(glob_pattern)) 225 | if len(origin_glob) < 1: 226 | task_info['origin_path'] = Path(base) / self.origin_file 227 | task_info['missing_origin'] = True 228 | return 229 | task_info['origin_path'] = origin_path = Path(origin_glob[0]) 230 | 231 | conflict = False 232 | likelies, consensus = current_metadata(task.items) 233 | task_info['tag_compare'] = tag_compare = OrderedDict() 234 | for tag in BEETS_TO_LABEL: 235 | tag_compare.update({tag: { 236 | 'tagged': str(likelies[tag]), 237 | 'active': tag in self.extra_tags, 238 | 'origin': '', 239 | }}) 240 | 241 | for key, value in self.match_fn(origin_path): 242 | if tag_compare[key]['origin']: 243 | continue 244 | 245 | tagged_value = tag_compare[key]['tagged'] 246 | origin_value = sanitize_value(key, value) 247 | tag_compare[key]['origin'] = origin_value 248 | if key not in CONFLICT_FIELDS or not tagged_value or not origin_value: 249 | continue 250 | 251 | if key == 'catalognum': 252 | tagged_value = normalize_catno(tagged_value) 253 | origin_value = normalize_catno(origin_value) 254 | 255 | if tagged_value != origin_value: 256 | conflict = task_info['conflict'] = True 257 | 258 | if not conflict or self.use_origin_on_conflict: 259 | # Update all item with origin metadata. 260 | for item in task.items: 261 | for tag, entry in tag_compare.items(): 262 | origin_value = entry['origin'] 263 | if tag not in self.extra_tags: 264 | continue 265 | if tag == 'year' and origin_value: 266 | origin_value = int(origin_value) if origin_value.isdigit() else '' 267 | item[tag] = origin_value 268 | 269 | # beets weighs media heavily, and will even prioritize a media match over an exact catalognum match. 270 | # At the same time, media for uploaded music is often mislabeled (e.g., Enhanced CD and SACD are just 271 | # grouped as CD). This does not make a good combination. As a workaround, remove the media from the 272 | # item if we also have a catalognum. 273 | if item['media'] and item['catalognum']: 274 | del item['media'] 275 | tag_compare['media']['active'] = False 276 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r", encoding="utf-8") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="beets-originquery", 8 | version="1.0.2", 9 | author="x1ppy", 10 | packages=['beetsplug'], 11 | author_email="", 12 | description="Integrates origin.txt metadata into beets MusicBrainz queries", 13 | long_description=long_description, 14 | long_description_content_type="text/markdown", 15 | url="https://github.com/x1ppy/beets-originquery", 16 | python_requires='>=3.6', 17 | install_requires=[ 18 | "beets>=1.5.0", 19 | "confuse", 20 | "jsonpath-rw", 21 | "pyyaml", 22 | ], 23 | ) 24 | --------------------------------------------------------------------------------