trackparse 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ # Node
2
+ node_modules/
3
+ dist/
4
+ coverage/
5
+ *.tsbuildinfo
6
+ .pnpm-store/
7
+ *.tgz
8
+
9
+ # Python
10
+ __pycache__/
11
+ *.py[cod]
12
+ .venv/
13
+ .pytest_cache/
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+ python/dist/
17
+ *.egg-info/
18
+
19
+ # Rust
20
+ target/
21
+ rust/Cargo.lock
22
+
23
+ # Editors / OS
24
+ .DS_Store
25
+ .idea/
26
+ .vscode/
27
+ *.swp
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maksim Maksimov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,389 @@
1
+ Metadata-Version: 2.5
2
+ Name: trackparse
3
+ Version: 0.2.0
4
+ Summary: Spec-driven parser for music track titles: artists, feat, remixes, versions, junk, positions.
5
+ Project-URL: Homepage, https://github.com/4matic/trackparse
6
+ Project-URL: Repository, https://github.com/4matic/trackparse
7
+ Project-URL: Issues, https://github.com/4matic/trackparse/issues
8
+ Project-URL: Changelog, https://github.com/4matic/trackparse/blob/main/python/CHANGELOG.md
9
+ Project-URL: Specification, https://github.com/4matic/trackparse/blob/main/spec/SPEC.md
10
+ Author: Maksim Maksimov
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: artist,featuring,id3,metadata,music,parser,remix,title,track,youtube
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Topic :: Multimedia :: Sound/Audio
27
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
28
+ Classifier: Topic :: Text Processing
29
+ Classifier: Typing :: Typed
30
+ Requires-Python: >=3.9
31
+ Description-Content-Type: text/markdown
32
+
33
+ # trackparse
34
+
35
+ [![PyPI](https://img.shields.io/pypi/v/trackparse?color=3775a9&logo=pypi&logoColor=white)](https://pypi.org/project/trackparse/)
36
+ [![Python versions](https://img.shields.io/pypi/pyversions/trackparse?logo=python&logoColor=white)](https://pypi.org/project/trackparse/)
37
+ [![python](https://img.shields.io/github/actions/workflow/status/4matic/trackparse/python.yml?branch=main&label=tests&logo=github)](https://github.com/4matic/trackparse/actions/workflows/python.yml)
38
+ [![license](https://img.shields.io/badge/license-MIT-blue)](https://github.com/4matic/trackparse/blob/main/LICENSE)
39
+
40
+ **Turn messy track names into structured data: who made it, who's featured, who remixed it, and
41
+ what's just noise.**
42
+
43
+ ```text
44
+ 01. Wilkinson ft. Becky Hill & Tom Cane - Afterglow (Sub Focus VIP Remix) [Official Video]
45
+ ```
46
+
47
+ Most title parsers split that into `"Wilkinson ft. Becky Hill & Tom Cane"` and
48
+ `"Afterglow (Sub Focus VIP Remix)"` and stop. Others "clean" it and throw the feat and the remix
49
+ away. trackparse keeps all of it. Everything it recognises goes into a field, and anything it
50
+ doesn't recognise stays in the title, word for word.
51
+
52
+ This is the Python port of [trackparse](https://github.com/4matic/trackparse). Its behaviour is
53
+ defined by a [shared spec](https://github.com/4matic/trackparse/blob/main/spec/SPEC.md) and pinned
54
+ by 11,891 fixtures. It passes all of them, and for every fixture input its output is byte-identical
55
+ to the JavaScript package.
56
+
57
+ - Python 3.9 to 3.14
58
+ - No dependencies
59
+ - Typed (`py.typed`), immutable results
60
+ - Never raises on string input
61
+
62
+ ## Install
63
+
64
+ ```sh
65
+ pip install trackparse
66
+ # or
67
+ uv add trackparse
68
+ ```
69
+
70
+ ## Quick start
71
+
72
+ ```python
73
+ from trackparse import format_track, parse
74
+
75
+ track = parse(
76
+ "01. Wilkinson ft. Becky Hill & Tom Cane - Afterglow (Sub Focus VIP Remix) [Official Video]"
77
+ )
78
+
79
+ [f"{a.name} ({a.role})" for a in track.artists]
80
+ # ['Wilkinson (primary)', 'Becky Hill (featured)', 'Tom Cane (featured)']
81
+ track.title # 'Afterglow'
82
+ track.full_title # 'Afterglow (Sub Focus VIP Remix)'
83
+ track.versions[0].type # 'remix'
84
+ track.versions[0].artists[0].name # 'Sub Focus'
85
+ track.versions[0].modifiers # ('vip',)
86
+ track.junk # (Junk(raw='Official Video', kind='video'),)
87
+ track.position # Position(raw='01', number=1)
88
+ track.mode # 'youtube'
89
+
90
+ format_track(track, joiners="canonical")
91
+ # 'Wilkinson feat. Becky Hill & Tom Cane - Afterglow (Sub Focus VIP Remix)'
92
+ ```
93
+
94
+ `to_dict()` returns the spec's JSON form, with the same camelCase keys the JS package uses:
95
+
96
+ ```python
97
+ import json
98
+
99
+ print(json.dumps(track.to_dict(), indent=2))
100
+ ```
101
+
102
+ <details>
103
+ <summary>Output</summary>
104
+
105
+ ```json
106
+ {
107
+ "input": "01. Wilkinson ft. Becky Hill & Tom Cane - Afterglow (Sub Focus VIP Remix) [Official Video]",
108
+ "mode": "youtube",
109
+ "position": { "raw": "01", "number": 1 },
110
+ "timestamp": null,
111
+ "artists": [
112
+ { "name": "Wilkinson", "role": "primary", "joiner": null, "source": "artist" },
113
+ { "name": "Becky Hill", "role": "featured", "joiner": "ft.", "source": "artist" },
114
+ { "name": "Tom Cane", "role": "featured", "joiner": "&", "source": "artist" }
115
+ ],
116
+ "title": "Afterglow",
117
+ "fullTitle": "Afterglow (Sub Focus VIP Remix)",
118
+ "versions": [
119
+ {
120
+ "type": "remix",
121
+ "raw": "Sub Focus VIP Remix",
122
+ "artists": [{ "name": "Sub Focus", "role": "remixer", "joiner": null, "source": "version" }],
123
+ "modifiers": ["vip"],
124
+ "descriptor": null,
125
+ "year": null,
126
+ "unknownArtist": false,
127
+ "delimiter": "("
128
+ }
129
+ ],
130
+ "year": null,
131
+ "flags": { "explicit": false, "clean": false, "unknownArtist": false, "unknownTitle": false },
132
+ "junk": [{ "raw": "Official Video", "kind": "video" }],
133
+ "warnings": []
134
+ }
135
+ ```
136
+
137
+ (Reformatted for width; the keys and values are exactly what `to_dict()` returns.)
138
+
139
+ </details>
140
+
141
+ ## API
142
+
143
+ ```python
144
+ parse(input, *, mode="auto", uploader=None, known_artists=(), split_and="auto", keywords=None) -> ParsedTrack
145
+ parse_artists(input, *, <same options>) -> list[Artist]
146
+ format_track(track, *, feat="source", feat_marker=None, joiners="original",
147
+ versions="all", producers=True, position=False) -> str
148
+ normalize(s) -> str
149
+ all_artists(track) -> list[Artist]
150
+ create_parser(**options) -> Parser # Parser.parse(...), Parser.parse_artists(...)
151
+ SPEC_VERSION, __version__
152
+ ```
153
+
154
+ ### `parse(input, **options)`
155
+
156
+ | Option | Default | |
157
+ |---|---|---|
158
+ | `mode` | `"auto"` | `"clean"`, `"youtube"`, `"filename"`, or `"auto"` to detect from the string |
159
+ | `uploader` | `None` | YouTube channel name, used as the artist when there's no separator (`- Topic` and `VEVO` are dropped) |
160
+ | `known_artists` | `()` | Names that are never split. There is no built-in list |
161
+ | `split_and` | `"auto"` | `"auto"` splits on `and` only after a comma (`A, B and C`), so `Simon and Garfunkel` stays whole. Also `"always"`, `"never"` |
162
+ | `keywords` | `None` | Extra vocabulary, added to the built-in lists (see below) |
163
+
164
+ ```python
165
+ [a.name for a in parse("Chase & Status - Baddadan").artists]
166
+ # ['Chase', 'Status']
167
+ [a.name for a in parse("Chase & Status - Baddadan", known_artists=["Chase & Status"]).artists]
168
+ # ['Chase & Status']
169
+
170
+ [a.name for a in parse("Simon and Garfunkel - Mrs. Robinson", split_and="always").artists]
171
+ # ['Simon', 'Garfunkel']
172
+ ```
173
+
174
+ `keywords` is a dict with any of these keys:
175
+
176
+ | Key | Type | |
177
+ |---|---|---|
178
+ | `version_heads` | `dict[str, VersionType]` | Word that sets a version type (`{"rerub": "rework"}`) |
179
+ | `descriptors` | `list[str]` | Words kept as a version's `descriptor` |
180
+ | `genres` | `list[str]` | Genre words |
181
+ | `junk` | `dict[str, JunkKind]` | Phrase to junk kind (`{"some label rip": "label"}`) |
182
+ | `feat_markers` | `list[str]` | Extra feat markers |
183
+
184
+ ```python
185
+ parse("Artist - Song [Some Label Rip]").title
186
+ # 'Song [Some Label Rip]'
187
+ parse("Artist - Song [Some Label Rip]", keywords={"junk": {"some label rip": "label"}}).junk
188
+ # (Junk(raw='Some Label Rip', kind='label'),)
189
+ ```
190
+
191
+ Parsing is total: any `str` gives a `ParsedTrack`, including `""`. Bad option values fall back to
192
+ the defaults. Passing something that isn't a `str` raises `TypeError`.
193
+
194
+ ### `ParsedTrack`
195
+
196
+ | Attribute | Type | |
197
+ |---|---|---|
198
+ | `input` | `str` | The original string, untouched |
199
+ | `mode` | `"clean" \| "youtube" \| "filename"` | Resolved mode |
200
+ | `position` | `Position \| None` | Track number: `Position(raw='01', number=1)` |
201
+ | `timestamp` | `Timestamp \| None` | Leading cue time: `[12:34]` gives `Timestamp(raw='12:34', seconds=754)` |
202
+ | `artists` | `tuple[Artist, ...]` | Primary, featured and producer credits. Remixers live in `versions` |
203
+ | `title` | `str` | Clean title. Brackets it doesn't recognise stay in |
204
+ | `full_title` | `str` | `title` with the versions re-attached |
205
+ | `versions` | `tuple[Version, ...]` | Remixes and other versions, left to right |
206
+ | `year` | `int \| None` | From `(2015)` or `- 2015` |
207
+ | `flags` | `Flags` | `explicit`, `clean`, `unknown_artist`, `unknown_title` |
208
+ | `junk` | `tuple[Junk, ...]` | What was taken out, each with a `kind` |
209
+ | `warnings` | `tuple[Warning, ...]` | Heuristics that fired |
210
+
211
+ `Artist(name, role, joiner, source)`:
212
+
213
+ - `role` is `"primary"`, `"featured"`, `"remixer"` or `"producer"`.
214
+ - `joiner` is the text before the name as written (`"&"`, `","`, `"ft."`, `"prod. by"`), or `None`
215
+ for the first name in a list.
216
+ - `source` is where the credit came from: `"artist"` (left of the dash), `"title"` (right of it)
217
+ or `"version"`.
218
+
219
+ `Version(type, raw, artists, modifiers, descriptor, year, unknown_artist, delimiter)`:
220
+
221
+ ```python
222
+ parse("Pendulum - Watercolour (Extended VIP Mix)").versions[0]
223
+ # Version(type='vip', raw='Extended VIP Mix', artists=(), modifiers=('extended',),
224
+ # descriptor=None, year=None, unknown_artist=False, delimiter='(')
225
+
226
+ parse("Queen - Bohemian Rhapsody (Live at Wembley 1986)").versions[0]
227
+ # Version(type='live', raw='Live at Wembley 1986', artists=(), modifiers=(),
228
+ # descriptor='at Wembley', year=1986, unknown_artist=False, delimiter='(')
229
+ ```
230
+
231
+ Version types, junk kinds and warnings are listed in the
232
+ [main README](https://github.com/4matic/trackparse#api) and typed as `Literal` aliases
233
+ (`VersionType`, `JunkKind`, `Warning`, `ArtistRole`, `ArtistSource`, `Mode`, `ModeOption`,
234
+ `VersionDelimiter`).
235
+
236
+ ### `to_dict()` and `from_dict()`
237
+
238
+ Every result class has `to_dict()`, which returns plain dicts and lists matching
239
+ [`parsed-track.schema.json`](https://github.com/4matic/trackparse/blob/main/spec/schema/parsed-track.schema.json)
240
+ (`fullTitle`, `unknownArtist`, ...). With `json.dumps(track.to_dict(), separators=(",", ":"),
241
+ ensure_ascii=False)` the bytes are the same as `JSON.stringify(parse(s))` in JS. The `from_dict()`
242
+ classmethod goes the other way:
243
+
244
+ ```python
245
+ ParsedTrack.from_dict(track.to_dict()) == track # True
246
+ ```
247
+
248
+ ### `parse_artists(input, **options)`
249
+
250
+ Splits a bare artist string. Takes the same options as `parse`.
251
+
252
+ ```python
253
+ from trackparse import parse_artists
254
+
255
+ [f"{a.name}:{a.role}" for a in parse_artists("Sub Focus, Wilkinson & Dimension feat. Kojo")]
256
+ # ['Sub Focus:primary', 'Wilkinson:primary', 'Dimension:primary', 'Kojo:featured']
257
+ ```
258
+
259
+ ### `format_track(track, **options)`
260
+
261
+ Renders a track back to a string.
262
+
263
+ | Option | Default | |
264
+ |---|---|---|
265
+ | `feat` | `"source"` | Where featured artists go: `"source"` (where they were), `"artist"`, `"title"` or `"omit"` |
266
+ | `feat_marker` | `None` | Marker for featured artists, such as `"feat."` |
267
+ | `joiners` | `"original"` | `"original"` keeps the joiners as written, `"canonical"` uses the standard ones |
268
+ | `versions` | `"all"` | `"all"` or `"none"` |
269
+ | `producers` | `True` | Include `(prod. ...)` credits |
270
+ | `position` | `False` | Include the track number |
271
+
272
+ ```python
273
+ from trackparse import format_track
274
+
275
+ format_track(
276
+ parse("Wilkinson ft. Becky Hill - Afterglow (Sub Focus Remix)"),
277
+ feat="title",
278
+ joiners="canonical",
279
+ )
280
+ # 'Wilkinson - Afterglow (feat. Becky Hill) (Sub Focus Remix)'
281
+
282
+ format_track(track, feat="omit", versions="none")
283
+ # 'Wilkinson - Afterglow'
284
+ format_track(track, position=True)
285
+ # '01. Wilkinson ft. Becky Hill & Tom Cane - Afterglow (Sub Focus VIP Remix)'
286
+ ```
287
+
288
+ ### `normalize(s)`
289
+
290
+ The Unicode cleanup every function runs first: NFC, invisible characters, and dash, bracket and
291
+ quote variants.
292
+
293
+ ```python
294
+ from trackparse import normalize
295
+
296
+ normalize("Noisia​ – Stigma (VIP)")
297
+ # 'Noisia - Stigma (VIP)'
298
+ ```
299
+
300
+ ### `all_artists(track)`
301
+
302
+ Credits plus every remixer, deduplicated.
303
+
304
+ ```python
305
+ from trackparse import all_artists
306
+
307
+ [(a.name, a.role) for a in all_artists(track)]
308
+ # [('Wilkinson', 'primary'), ('Becky Hill', 'featured'), ('Tom Cane', 'featured'), ('Sub Focus', 'remixer')]
309
+ ```
310
+
311
+ ### `create_parser(**options)`
312
+
313
+ Compiles the options once, for many calls. Per-call options are merged over the parser's: a value
314
+ passed per call replaces the base one (`known_artists` included), `None` leaves it alone, and
315
+ `keywords` dicts are merged.
316
+
317
+ ```python
318
+ from trackparse import create_parser
319
+
320
+ parser = create_parser(
321
+ known_artists=["Chase & Status", "Camo & Krooked"],
322
+ keywords={"version_heads": {"rerub": "rework"}},
323
+ )
324
+ parser.parse("Chase & Status x Camo & Krooked - Track (Alix Perez Rerub)").versions[0].type
325
+ # 'rework'
326
+ ```
327
+
328
+ ### `SPEC_VERSION`
329
+
330
+ The spec version this build implements. The PyPI package, the npm package and the spec are
331
+ released together and always share one version number.
332
+
333
+ ## Modes
334
+
335
+ | Mode | For | Adds |
336
+ |---|---|---|
337
+ | `clean` | Store metadata, tags | Only a spaced dash separates artist and title |
338
+ | `youtube` | Video titles, scrobbles | Pipe segments, `"Title" by Artist`, `Artist: "Title"`, unspaced dashes, trailing junk, uploader fallback |
339
+ | `filename` | Files | Strips the extension, `_` becomes a space, `01-Track` numbering |
340
+ | `auto` | Anything | `filename` for known extensions, `youtube` when it sees junk, pipes or platform suffixes, `clean` otherwise |
341
+
342
+ ```python
343
+ t = parse('"Numb" by Linkin Park', mode="youtube")
344
+ [a.name for a in t.artists], t.title, t.warnings
345
+ # (['Linkin Park'], 'Numb', ('bySplit',))
346
+
347
+ t = parse("07_Burial_-_Archangel.mp3")
348
+ t.mode, [a.name for a in t.artists], t.title, t.position
349
+ # ('filename', ['Burial'], 'Archangel', Position(raw='07', number=7))
350
+
351
+ parse("Shelter", mode="youtube", uploader="Porter Robinson").artists
352
+ # (Artist(name='Porter Robinson', role='primary', joiner=None, source='artist'),)
353
+ ```
354
+
355
+ ## Typing
356
+
357
+ The package ships `py.typed` and passes `mypy --strict`. `ParsedTrack`, `Artist`, `Version`,
358
+ `Junk`, `Flags`, `Position` and `Timestamp` are frozen dataclasses with snake_case attributes.
359
+ Sequences are tuples, so results are immutable and hashable and can go in sets or be used as dict
360
+ keys. Option dicts have `TypedDict` types (`ParseOptions`, `KeywordOptions`, `FormatOptions`).
361
+
362
+ ## Development
363
+
364
+ From `python/` in the [repository](https://github.com/4matic/trackparse), with
365
+ [uv](https://docs.astral.sh/uv/):
366
+
367
+ ```sh
368
+ uv sync
369
+ uv run python scripts/gen_data.py --check # spec/data codegen is up to date
370
+ uv run ruff check . && uv run ruff format --check .
371
+ uv run mypy --strict src
372
+ uv run pytest # every shared fixture, properties, API
373
+ uv run python scripts/conformance.py # pass rate per fixture file and per rule
374
+ ```
375
+
376
+ Modules mirror the JS reference one to one (`_scanner.py` is `scanner.ts`, and so on) and cite the
377
+ same spec rule IDs. Behaviour changes start in the spec, not here: see
378
+ [AGENTS.md](https://github.com/4matic/trackparse/blob/main/AGENTS.md).
379
+
380
+ ## Links
381
+
382
+ - [Spec](https://github.com/4matic/trackparse/blob/main/spec/SPEC.md)
383
+ - [Project README](https://github.com/4matic/trackparse#readme), with a table of real outputs
384
+ - [Changelog](https://github.com/4matic/trackparse/blob/main/python/CHANGELOG.md)
385
+ - [npm package](https://www.npmjs.com/package/trackparse)
386
+
387
+ ## License
388
+
389
+ [MIT](https://github.com/4matic/trackparse/blob/main/LICENSE)