From 2bcc2f2a17d651d11fdc65a42ddda70deaa8b001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20F=C3=A4hrmann?= Date: Sun, 22 Jun 2025 15:09:28 +0200 Subject: [PATCH] [formatter] implement 'M' format specifier Map a list of objects to a list of values [ {"name": "John Doe" , "age": 42}, {"name": "Jane Smith" , "age": 24}, {"name": "Max Mustermann", "age": null} ] == :Mname => ["John Doe", "Jane Smith", "Max Mustermann"] --- docs/formatting.md | 6 ++++++ gallery_dl/formatter.py | 26 ++++++++++++++++++++++++-- test/test_formatter.py | 39 ++++++++++++++++++++++++++++++--------- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/docs/formatting.md b/docs/formatting.md index 44a103d4..57779f4c 100644 --- a/docs/formatting.md +++ b/docs/formatting.md @@ -266,6 +266,12 @@ Format specifiers can be used for advanced formatting by using the options provi {tags:J - /} sun - tree - water + + M<key>/ + Maps a list of objects to a list of corresponding values by looking up <key> in each object + {users:Mname/} + ["John", "David", "Max"] + R<old>/<new>/ Replaces all occurrences of <old> with <new> using str.replace() diff --git a/gallery_dl/formatter.py b/gallery_dl/formatter.py index d638fae9..8e0dc3f1 100644 --- a/gallery_dl/formatter.py +++ b/gallery_dl/formatter.py @@ -221,7 +221,7 @@ class FStringFormatter(): """Generate text by evaluating an f-string literal""" def __init__(self, fstring, default=NONE, fmt=None): - self.format_map = util.compile_expression('f"""' + fstring + '"""') + self.format_map = util.compile_expression(f'f"""{fstring}"""') class TemplateFormatter(StringFormatter): @@ -302,7 +302,7 @@ def _parse_optional(format_spec, default): fmt = _build_format_func(format_spec, default) def optional(obj): - return before + fmt(obj) + after if obj else "" + return f"{before}{fmt(obj)}{after}" if obj else "" return optional @@ -385,6 +385,27 @@ def _parse_join(format_spec, default): return apply_join +def _parse_map(format_spec, default): + key, _, format_spec = format_spec.partition(_SEPARATOR) + key = key[1:] + fmt = _build_format_func(format_spec, default) + + def map_(obj): + if not obj or isinstance(obj, str): + return fmt(obj) + + results = [] + for item in obj: + if isinstance(item, dict): + value = item.get(key, ...) + results.append(default if value is ... else value) + else: + results.append(item) + return fmt(results) + + return map_ + + def _parse_replace(format_spec, default): old, new, format_spec = format_spec.split(_SEPARATOR, 2) old = old[1:] @@ -507,6 +528,7 @@ _FORMAT_SPECIFIERS = { "D": _parse_datetime, "J": _parse_join, "L": _parse_maxlen, + "M": _parse_map, "O": _parse_offset, "R": _parse_replace, "S": _parse_sort, diff --git a/test/test_formatter.py b/test/test_formatter.py index df9bb879..9d59fda4 100644 --- a/test/test_formatter.py +++ b/test/test_formatter.py @@ -27,6 +27,11 @@ class TestFormatter(unittest.TestCase): "d": {"a": "foo", "b": 0, "c": None}, "i": 2, "l": ["a", "b", "c"], + "L": [ + {"name": "John Doe" , "age": 42, "email": "jd@example.org"}, + {"name": "Jane Smith" , "age": 24, "email": None}, + {"name": "Max Mustermann", "age": False}, + ], "n": None, "s": " \n\r\tSPACE ", "h": "

foo

& bar

", @@ -209,7 +214,7 @@ class TestFormatter(unittest.TestCase): self._run_test("{j:[b:]}" , v) self._run_test("{j:[b::]}" , v) - def test_maxlen(self): + def test_specifier_maxlen(self): v = self.kwdict["a"] self._run_test("{a:L5/foo/}" , "foo") self._run_test("{a:L50/foo/}", v) @@ -217,7 +222,7 @@ class TestFormatter(unittest.TestCase): self._run_test("{a:L50/foo/>51}", "foo") self._run_test("{a:Lab/foo/}", "foo") - def test_join(self): + def test_specifier_join(self): self._run_test("{l:J}" , "abc") self._run_test("{l:J,}" , "a,b,c") self._run_test("{l:J,/}" , "a,b,c") @@ -229,7 +234,7 @@ class TestFormatter(unittest.TestCase): self._run_test("{a:J/}" , self.kwdict["a"]) self._run_test("{a:J, /}" , self.kwdict["a"]) - def test_replace(self): + def test_specifier_replace(self): self._run_test("{a:Rh/C/}" , "CElLo wOrLd") self._run_test("{a!l:Rh/C/}", "Cello world") self._run_test("{a!u:Rh/C/}", "HELLO WORLD") @@ -238,12 +243,12 @@ class TestFormatter(unittest.TestCase): self._run_test("{a!l:Rl//}" , "heo word") self._run_test("{name:Rame/othing/}", "Nothing") - def test_datetime(self): + def test_specifier_datetime(self): self._run_test("{ds:D%Y-%m-%dT%H:%M:%S%z}", "2010-01-01 00:00:00") self._run_test("{ds:D%Y}", "2010-01-01T01:00:00+01:00") self._run_test("{l:D%Y}", "None") - def test_offset(self): + def test_specifier_offset(self): self._run_test("{dt:O 01:00}", "2010-01-01 01:00:00") self._run_test("{dt:O+02:00}", "2010-01-01 02:00:00") self._run_test("{dt:O-03:45}", "2009-12-31 20:15:00") @@ -254,7 +259,7 @@ class TestFormatter(unittest.TestCase): self._run_test("{ds:D%Y-%m-%dT%H:%M:%S%z/O1}", "2010-01-01 01:00:00") self._run_test("{t!d:O2}", "2010-01-01 02:00:00") - def test_offset_local(self): + def test_specifier_offset_local(self): ts = self.kwdict["dt"].replace( tzinfo=datetime.timezone.utc).timestamp() offset = time.localtime(ts).tm_gmtoff @@ -269,7 +274,7 @@ class TestFormatter(unittest.TestCase): self._run_test("{dt_dst:O}", str(dt)) self._run_test("{dt_dst:Olocal}", str(dt)) - def test_sort(self): + def test_specifier_sort(self): self._run_test("{l:S}" , "['a', 'b', 'c']") self._run_test("{l:Sa}", "['a', 'b', 'c']") self._run_test("{l:Sd}", "['c', 'b', 'a']") @@ -301,6 +306,19 @@ class TestFormatter(unittest.TestCase): with self.assertRaises(ValueError): self._run_test("{a:Xfoo/ */}", "hello wo *") + def test_specifier_map(self): + self._run_test("{L:Mname/}" , + "['John Doe', 'Jane Smith', 'Max Mustermann']") + self._run_test("{L:Mage/}" , + "[42, 24, False]") + + self._run_test("{a:Mname}", self.kwdict["a"]) + self._run_test("{n:Mname}", "None") + self._run_test("{title4:Mname}", "0") + + with self.assertRaises(ValueError): + self._run_test("{t:Mname", "") + def test_chain_special(self): # multiple replacements self._run_test("{a:Rh/C/RE/e/RL/l/}", "Cello wOrld") @@ -322,6 +340,9 @@ class TestFormatter(unittest.TestCase): # sort and join self._run_test("{a:S/J}", " ELLOdhlorw") + # map and join + self._run_test("{L:Mname/J-}", "John Doe-Jane Smith-Max Mustermann") + def test_separator(self): orig_separator = formatter._SEPARATOR try: @@ -494,10 +515,10 @@ def noarg(): fmt4 = formatter.parse("\fM " + path + ":lengths") self.assertEqual(fmt1.format_map(self.kwdict), "'Title' by Name") - self.assertEqual(fmt2.format_map(self.kwdict), "139") + self.assertEqual(fmt2.format_map(self.kwdict), "142") self.assertEqual(fmt3.format_map(self.kwdict), "'Title' by Name") - self.assertEqual(fmt4.format_map(self.kwdict), "139") + self.assertEqual(fmt4.format_map(self.kwdict), "142") with self.assertRaises(TypeError): self.assertEqual(fmt0.format_map(self.kwdict), "")