recs 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

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.
recs/cfg/cli.py CHANGED
@@ -1,37 +1,17 @@
1
1
  import string
2
- import sys
3
2
  from pathlib import Path
4
3
 
5
- import click
6
4
  import dtyper
7
5
  from typer import Argument, rich_utils
8
6
 
9
- from recs.base import RecsError, pyproject
7
+ from recs.base import types
10
8
  from recs.base.cfg_raw import CfgRaw
11
9
 
12
- from .cfg import Cfg
10
+ from . import app, cfg
13
11
 
14
12
  rich_utils.STYLE_METAVAR = 'dim yellow'
15
- INTRO = f"""
16
- {pyproject.message()}
17
-
18
- ============================================="""
19
- LINES = (
20
- INTRO,
21
- 'Why should there be a record button at all?',
22
- 'I wanted to digitize a huge number of cassettes and LPs, so I wanted a '
23
- + 'program that ran in the background and recorded everything except quiet.',
24
- 'Nothing like that existed so I wrote it. Free, open-source, configurable.',
25
- 'Full documentation here: https://github.com/rec/recs',
26
- '',
27
- )
28
- HELP = '\n\n\n\n'.join(LINES)
29
13
  # Three blank lines seems to force Typer to format correctly
30
14
 
31
- app = dtyper.Typer(
32
- add_completion=False,
33
- context_settings={'help_option_names': ['--help', '-h']},
34
- )
35
15
  _SINGLES: set[str] = set()
36
16
 
37
17
  RECS = CfgRaw()
@@ -43,151 +23,232 @@ def Option(default, *a, **ka) -> dtyper.Option:
43
23
  return dtyper.Option(default, *a, **ka)
44
24
 
45
25
 
46
- @app.command(help=HELP)
26
+ GENERAL_PANEL = 'Options' # 'General Settings'
27
+ SUBCOMMANDS_PANEL = 'Subcommands'
28
+ NAMES_PANEL = 'Selecting and Naming Devices and Channels'
29
+ FILE_PANEL = 'Audio File Format Settings'
30
+ CONSOLE_PANEL = 'Console and UI Settings'
31
+ RECORD_PANEL = 'Record Settings'
32
+
33
+
34
+ @app.app.command(help=app.HELP)
47
35
  def recs(
48
36
  #
49
37
  # Directory settings
50
38
  #
51
- path: Path = Argument(
52
- RECS.path, help='Path to the parent directory to create audio files in'
53
- ),
54
- subdirectory: list[str] = Option(
55
- RECS.subdirectory,
56
- '-s',
57
- '--subdirectory',
58
- help='Organize files into subdirectories by channel, device or time.',
39
+ path: str = Argument(
40
+ RECS.path, help='Path or path pattern for recorded file locations'
59
41
  ),
60
42
  #
61
- # General purpose settings
43
+ # General
62
44
  #
45
+ calibrate: bool = Option(
46
+ RECS.calibrate,
47
+ '--calibrate',
48
+ help='Detect and print noise levels, do not record',
49
+ rich_help_panel=GENERAL_PANEL,
50
+ ),
63
51
  dry_run: bool = Option(
64
- RECS.dry_run, '-n', '--dry-run', help='Display levels only, do not record'
52
+ RECS.dry_run,
53
+ '-n',
54
+ '--dry-run',
55
+ help='Display levels only, do not record',
56
+ rich_help_panel=GENERAL_PANEL,
57
+ ),
58
+ verbose: bool = Option(
59
+ RECS.verbose,
60
+ '-v',
61
+ '--verbose',
62
+ help='Print more stuff - currently does nothing',
63
+ rich_help_panel=GENERAL_PANEL,
65
64
  ),
65
+ #
66
+ # Subcommands
67
+ #
66
68
  info: bool = Option(
67
- RECS.info, '--info', help='Do not run, display device info instead'
69
+ RECS.info,
70
+ '--info',
71
+ help='Display device info as JSON',
72
+ rich_help_panel=SUBCOMMANDS_PANEL,
68
73
  ),
69
74
  list_types: bool = Option(
70
- RECS.list_types, help='List all subtypes for each format'
71
- ),
72
- verbose: bool = Option(
73
- RECS.verbose, '-v', '--verbose', help='Print full stack traces'
75
+ RECS.list_types,
76
+ '--list-types',
77
+ help='List all subtypes for each format as JSON',
78
+ rich_help_panel=SUBCOMMANDS_PANEL,
74
79
  ),
75
80
  #
76
- # Aliases for input devices or channels
81
+ # Names
77
82
  #
78
83
  alias: list[str] = Option(
79
- RECS.alias, '-a', '--alias', help='Aliases for devices or channels'
84
+ RECS.alias,
85
+ '-a',
86
+ '--alias',
87
+ click_type=app.AliasParam(),
88
+ help='Set aliases for devices or channels',
89
+ rich_help_panel=NAMES_PANEL,
90
+ ),
91
+ devices: Path = Option(
92
+ RECS.devices,
93
+ help='A path to a JSON file with device definitions',
94
+ rich_help_panel=NAMES_PANEL,
80
95
  ),
81
- devices: Path = Option(RECS.devices, help='A JSON file with device definitions'),
82
- #
83
- # Exclude or include devices or channels
84
- #
85
96
  exclude: list[str] = Option(
86
- RECS.exclude, '-e', '--exclude', help='Exclude these devices or channels'
97
+ RECS.exclude,
98
+ '-e',
99
+ '--exclude',
100
+ help='Exclude devices or channels',
101
+ rich_help_panel=NAMES_PANEL,
87
102
  ),
88
103
  include: list[str] = Option(
89
- RECS.include, '-i', '--include', help='Only include these devices or channels'
104
+ RECS.include,
105
+ '-i',
106
+ '--include',
107
+ help='Only include these devices or channels',
108
+ rich_help_panel=NAMES_PANEL,
90
109
  ),
91
110
  #
92
- # Audio file data
111
+ # File
93
112
  #
94
- format: str = Option(RECS.format, '-f', '--format', help='Audio format'),
95
- metadata: list[str] = Option(
96
- RECS.metadata, '-m', '--metadata', help='Metadata fields to add to output files'
113
+ format: types.Format = Option(
114
+ RECS.format,
115
+ '-f',
116
+ '--format',
117
+ click_type=app.FormatParam(),
118
+ help='Audio file format',
119
+ rich_help_panel=FILE_PANEL,
97
120
  ),
98
- sdtype: str = Option(
99
- RECS.sdtype, '-d', '--sdtype', help='Type of sounddevice numbers'
121
+ metadata: list[str] = Option(
122
+ RECS.metadata,
123
+ '-m',
124
+ '--metadata',
125
+ click_type=app.MetadataParam(),
126
+ help='Metadata fields to add to output files',
127
+ rich_help_panel=FILE_PANEL,
128
+ ),
129
+ sdtype: types.SdType
130
+ | None = Option(
131
+ RECS.sdtype,
132
+ '-d',
133
+ '--sdtype',
134
+ click_type=app.SdTypeParam(),
135
+ help='Integer or float number type for recording',
136
+ rich_help_panel=FILE_PANEL,
137
+ ),
138
+ subtype: types.Subtype
139
+ | None = Option(
140
+ RECS.subtype,
141
+ '-u',
142
+ '--subtype',
143
+ click_type=app.SubtypeParam(),
144
+ help='Audio file subtype',
145
+ rich_help_panel=FILE_PANEL,
100
146
  ),
101
- subtype: str = Option(RECS.subtype, '-u', '--subtype', help='File subtype'),
102
147
  #
103
- # Console and UI settings
148
+ # Console
104
149
  #
150
+ clear: bool = Option(
151
+ RECS.clear,
152
+ '-r',
153
+ '--clear',
154
+ help='Clear display on shutdown',
155
+ rich_help_panel=CONSOLE_PANEL,
156
+ ),
105
157
  silent: bool = Option(
106
- RECS.silent, '-q', '--silent', help='If true, do not display live updates'
158
+ RECS.silent,
159
+ '-s',
160
+ '--silent',
161
+ help='Do not display live updates',
162
+ rich_help_panel=CONSOLE_PANEL,
107
163
  ),
108
- retain: bool = Option(
109
- RECS.retain, '-r', '--retain', help='Retain rich display on shutdown'
164
+ sleep_time_device: str = Option(
165
+ RECS.sleep_time_device,
166
+ '--sleep-time-device',
167
+ click_type=app.TimeParam(),
168
+ help='How long to sleep between checking device',
169
+ rich_help_panel=CONSOLE_PANEL,
110
170
  ),
111
171
  ui_refresh_rate: float = Option(
112
172
  RECS.ui_refresh_rate,
113
173
  '--ui-refresh-rate',
114
174
  help='How many UI refreshes per second',
115
- ),
116
- sleep_time: float = Option(
117
- RECS.sleep_time, '--sleep-time', help='How long to sleep between data refreshes'
175
+ rich_help_panel=CONSOLE_PANEL,
118
176
  ),
119
177
  #
120
- # Settings relating to times
178
+ # Record
121
179
  #
122
180
  infinite_length: bool = Option(
123
181
  RECS.infinite_length,
124
- help='If true, ignore file size limits (4G on .wav, 2G on .aiff)',
182
+ '--infinite-length',
183
+ help='Ignore file size limits (4G on .wav, 2G on .aiff)',
184
+ rich_help_panel=RECORD_PANEL,
125
185
  ),
126
186
  longest_file_time: str = Option(
127
- RECS.longest_file_time, help='Longest amount of time per file: 0 means infinite'
187
+ RECS.longest_file_time,
188
+ click_type=app.TimeParam(),
189
+ help='Longest amount of time per file: 0 means infinite',
190
+ rich_help_panel=RECORD_PANEL,
128
191
  ),
129
- moving_average_time: float = Option(
130
- RECS.moving_average_time, help='How long to average the volume display over'
192
+ moving_average_time: str = Option(
193
+ RECS.moving_average_time,
194
+ click_type=app.TimeParam(),
195
+ help='How long to average the volume display over',
196
+ rich_help_panel=RECORD_PANEL,
131
197
  ),
132
198
  noise_floor: float = Option(
133
- RECS.noise_floor, '-o', '--noise-floor', help='The noise floor in decibels'
199
+ RECS.noise_floor,
200
+ '-o',
201
+ '--noise-floor',
202
+ help='The noise floor in decibels',
203
+ rich_help_panel=RECORD_PANEL,
134
204
  ),
135
205
  shortest_file_time: str = Option(
136
- RECS.shortest_file_time, help='Shortest amount of time per file'
206
+ RECS.shortest_file_time,
207
+ click_type=app.TimeParam(),
208
+ help='Shortest amount of time per file',
209
+ rich_help_panel=RECORD_PANEL,
137
210
  ),
138
- quiet_after_end: float = Option(
211
+ quiet_after_end: str = Option(
139
212
  RECS.quiet_after_end,
140
213
  '-c',
141
214
  '--quiet-after-end',
142
- help='Quiet after the end, in seconds',
215
+ click_type=app.TimeParam(),
216
+ help='How much quiet after the end',
217
+ rich_help_panel=RECORD_PANEL,
143
218
  ),
144
- quiet_before_start: float = Option(
219
+ quiet_before_start: str = Option(
145
220
  RECS.quiet_before_start,
146
221
  '-b',
147
222
  '--quiet-before-start',
148
- help='Quiet before the start, in seconds',
223
+ click_type=app.TimeParam(),
224
+ help='How much quiet before a recording',
225
+ rich_help_panel=RECORD_PANEL,
149
226
  ),
150
- stop_after_quiet: float = Option(
227
+ stop_after_quiet: str = Option(
151
228
  RECS.stop_after_quiet,
152
229
  '--stop-after-quiet',
153
- help='Stop recs after quiet',
230
+ click_type=app.TimeParam(),
231
+ help='How much quiet before stopping a recording',
232
+ rich_help_panel=RECORD_PANEL,
154
233
  ),
155
- total_run_time: float = Option(
234
+ total_run_time: str = Option(
156
235
  RECS.total_run_time,
157
236
  '-t',
158
237
  '--total-run-time',
238
+ click_type=app.TimeParam(),
159
239
  help='How many seconds to record? 0 means forever',
240
+ rich_help_panel=RECORD_PANEL,
160
241
  ),
161
- ) -> None: # pragma: no cover: This is tested in a subprocess.
162
- cfg = Cfg(**locals())
242
+ ) -> None:
243
+ c = cfg.Cfg(**locals())
163
244
 
164
- from . import run
245
+ from . import run_cli
165
246
 
166
- run.run(cfg)
247
+ run_cli.run_cli(c)
167
248
 
168
249
 
169
250
  _USED_SINGLES = ''.join(sorted(_SINGLES))
170
251
  _UNUSED_SINGLES = ''.join(sorted(set(string.ascii_lowercase) - set(_SINGLES)))
171
252
 
172
- assert _USED_SINGLES == 'abcdefimnoqrstuv', _USED_SINGLES
173
- assert _UNUSED_SINGLES == 'ghjklpwxyz', _UNUSED_SINGLES
174
-
175
-
176
- def run() -> int:
177
- try:
178
- app(standalone_mode=False)
179
- return 0
180
-
181
- except RecsError as e:
182
- print('ERROR:', *e.args, file=sys.stderr)
183
-
184
- except click.ClickException as e:
185
- print(f'{e.__class__.__name__}: {e.message}', file=sys.stderr)
186
-
187
- except click.Abort:
188
- print('Aborted', file=sys.stderr)
189
-
190
- except KeyboardInterrupt:
191
- print('Interrupted', file=sys.stderr)
192
-
193
- return -1
253
+ assert _USED_SINGLES == 'abcdefimnorstuv', _USED_SINGLES
254
+ assert _UNUSED_SINGLES == 'ghjklpqwxyz', _UNUSED_SINGLES
recs/cfg/device.py CHANGED
@@ -8,10 +8,27 @@ import numpy as np
8
8
 
9
9
  from recs.base import times
10
10
  from recs.base.prefix_dict import PrefixDict
11
- from recs.base.types import DeviceDict, SdType
12
- from recs.misc import hash_cmp
11
+ from recs.base.types import DeviceDict, SdType, Stop
12
+ from recs.cfg import hash_cmp
13
13
 
14
- Callback = t.Callable[[np.ndarray, float], None]
14
+
15
+ class Update(t.NamedTuple):
16
+ array: np.ndarray
17
+ timestamp: float
18
+
19
+
20
+ DeviceCallback = t.Callable[[Update], None]
21
+
22
+
23
+ class InputStream(t.Protocol):
24
+ def close(self, ignore_errors=True) -> None:
25
+ pass
26
+
27
+ def start(self) -> None:
28
+ pass
29
+
30
+ def stop(self, ignore_errors=True) -> None:
31
+ pass
15
32
 
16
33
 
17
34
  class InputDevice(hash_cmp.HashCmp):
@@ -25,64 +42,34 @@ class InputDevice(hash_cmp.HashCmp):
25
42
  def __str__(self) -> str:
26
43
  return self.name
27
44
 
28
- @property
29
- def is_online(self) -> bool:
30
- # TODO: this is wrong!
31
- # https://github.com/spatialaudio/python-sounddevice/issues/382
32
- # return any(self.name == i['name'] for i in sd.query_devices())
33
- return True
34
-
35
45
  def input_stream(
36
- self,
37
- callback: Callback,
38
- dtype: SdType,
39
- stop: t.Callable[[], None],
40
- ) -> t.Iterator[None] | None:
46
+ self, device_callback: DeviceCallback, sdtype: SdType, on_error: Stop
47
+ ) -> InputStream:
41
48
  import sounddevice as sd
42
49
 
43
- if not self.is_online:
44
- return None
45
-
46
50
  stream: sd.InputStream
47
51
 
48
- def cb(indata: np.ndarray, frames: int, _time: float, status: int) -> None:
49
- # TODO: time is a _cffi_backend._CDataBase, not a float!
50
-
51
- stream._recs_timestamp = times.time()
52
-
52
+ def callback(indata: np.ndarray, frames: int, time: t.Any, status: int) -> None:
53
53
  if status: # pragma: no cover
54
- # This has not yet happened, probably because we never get behind
55
- # the device callback cycle.
56
54
  print('Status', self, status, file=sys.stderr)
57
55
 
58
- if not indata.size: # pragma: no cover
59
- print('Empty block', self, file=sys.stderr)
60
- return
61
-
62
56
  try:
63
- # `indata` is always the same variable!
64
- callback(indata.copy(), stream._recs_timestamp)
57
+ device_callback(Update(indata.copy(), times.timestamp()))
65
58
 
66
59
  except Exception: # pragma: no cover
67
60
  traceback.print_exc()
68
61
  try:
69
- stream.stop()
70
- except Exception:
71
- traceback.print_exc()
72
- try:
73
- stop()
62
+ on_error()
74
63
  except Exception:
75
64
  traceback.print_exc()
76
65
 
77
- assert dtype is not None # If sdtype is None, then the whole system blocks
78
66
  stream = sd.InputStream(
79
- callback=cb,
67
+ callback=callback,
80
68
  channels=self.channels,
81
69
  device=self.name,
82
- dtype=dtype,
70
+ dtype=sdtype,
83
71
  samplerate=self.samplerate,
84
72
  )
85
- stream._recs_timestamp = times.time()
86
73
  return stream
87
74
 
88
75
 
@@ -90,16 +77,23 @@ InputDevices = PrefixDict[InputDevice]
90
77
 
91
78
 
92
79
  def get_input_devices(devices: t.Sequence[DeviceDict]) -> InputDevices:
93
- return PrefixDict({d.name: d for i in devices if (d := InputDevice(i))})
80
+ return PrefixDict({d.name: d for i in devices if (d := InputDevice(i)).channels})
94
81
 
95
82
 
96
- P = 'import json, sounddevice; print(json.dumps(sounddevice.query_devices(), indent=4))'
83
+ CMD = sys.executable, '-m', 'recs.base._query_device'
97
84
 
98
85
 
99
86
  def query_devices() -> t.Sequence[DeviceDict]:
100
- r = sp.run(('python', '-c', P), text=True, check=True, stdout=sp.PIPE)
87
+ try:
88
+ r = sp.run(CMD, text=True, check=True, stdout=sp.PIPE)
89
+ except sp.CalledProcessError:
90
+ return []
101
91
  return json.loads(r.stdout)
102
92
 
103
93
 
94
+ def input_names() -> t.Sequence[str]:
95
+ return sorted(str(i['name']) for i in query_devices())
96
+
97
+
104
98
  def input_devices() -> InputDevices:
105
99
  return get_input_devices(query_devices())
@@ -2,13 +2,16 @@ import typing as t
2
2
 
3
3
  import soundfile as sf
4
4
 
5
- from . import RecsError
5
+ from recs.base import RecsError, prefix_dict
6
+ from recs.base.types import Format
6
7
 
7
8
  RECS_USES = {'date', 'software', 'tracknumber'}
8
9
  USABLE = {'album', 'artist', 'comment', 'copyright', 'genre', 'title'}
9
10
  UNUSABLE = {'license'} # Can't be set for some reason
10
11
  ALL = RECS_USES | USABLE | UNUSABLE
11
12
 
13
+ PREFIX_DICT = prefix_dict.PrefixDict({i: i for i in sorted(ALL)})
14
+
12
15
 
13
16
  def to_dict(metadata: t.Sequence[str]) -> dict[str, str]:
14
17
  result: dict[str, str] = {}
@@ -16,6 +19,12 @@ def to_dict(metadata: t.Sequence[str]) -> dict[str, str]:
16
19
 
17
20
  for m in metadata:
18
21
  name, eq, value = (i.strip() for i in m.partition('='))
22
+
23
+ try:
24
+ name = PREFIX_DICT[name]
25
+ except KeyError:
26
+ pass
27
+
19
28
  if not (name and eq and value):
20
29
  errors.setdefault('malformed', []).append(m)
21
30
  elif name in result:
@@ -29,10 +38,22 @@ def to_dict(metadata: t.Sequence[str]) -> dict[str, str]:
29
38
 
30
39
  if errors:
31
40
  msg = ', '.join(f'{k}: {v}' for k, v in errors.items())
32
- raise RecsError('Metadata: ' + msg)
41
+ raise RecsError(msg)
33
42
 
34
43
  return result
35
44
 
36
45
 
37
46
  def get_metadata(fp: sf.SoundFile) -> dict[str, str]:
38
47
  return {k: v for k in ALL if (v := getattr(fp, k))}
48
+
49
+
50
+ ALLOWS_METADATA = {
51
+ Format.aiff,
52
+ Format.caf,
53
+ Format.flac,
54
+ Format.mp3,
55
+ Format.ogg,
56
+ Format.rf64,
57
+ Format.wav,
58
+ Format.wavex,
59
+ }
@@ -0,0 +1,156 @@
1
+ import re
2
+ import string
3
+ from datetime import datetime
4
+ from enum import IntEnum, auto
5
+
6
+ from recs.base import RecsError
7
+ from recs.cfg import aliases, track
8
+
9
+ findall_strftime = re.compile('%.').findall
10
+
11
+
12
+ def parse_fields(s: str) -> list[str]:
13
+ return sorted(n for _, n, _, _ in string.Formatter().parse(s) if n)
14
+
15
+
16
+ class Req(IntEnum):
17
+ device = auto()
18
+ channel = auto()
19
+ year = auto()
20
+ month = auto()
21
+ day = auto()
22
+ hour = auto()
23
+ minute = auto()
24
+ second = auto()
25
+
26
+
27
+ class PathPattern:
28
+ def __init__(self, path: str) -> None:
29
+ str_parts = parse_fields(path)
30
+ time_parts = findall_strftime(path)
31
+ parts = set(time_parts + str_parts)
32
+
33
+ if bad := parts - FIELDS:
34
+ raise RecsError(f'Unknown: {", ".join(sorted(bad))}')
35
+
36
+ self.name_parts = tuple(i for i in str_parts if i not in FIELD_TO_PSTRING)
37
+ self.pstring_parts = tuple(i for i in str_parts if i in FIELD_TO_PSTRING)
38
+
39
+ used = set().union(*(FIELD_TO_REQUIRED[p] for p in parts))
40
+ unused = set(Req) - used
41
+
42
+ def rep(r: Req) -> str:
43
+ return (r in unused) * f'{{{r.name}}}'
44
+
45
+ y, m, d = rep(Req.year), rep(Req.month), rep(Req.day)
46
+ if (m and not (y or d)) or (not m and (y and d)):
47
+ raise RecsError(f'Must specify year or day with month: {path}')
48
+ date = y + m + d
49
+
50
+ h, m, s = rep(Req.hour), rep(Req.minute), rep(Req.second)
51
+ if (m and not (h or s)) or (not m and (h and s)):
52
+ raise RecsError(f'Must specify hour or second with minute: {path}')
53
+ time = h + m + s
54
+
55
+ d, c = rep(Req.device), rep(Req.channel)
56
+ dc = '{track}' if all((d, c)) else d + c
57
+
58
+ dt = f'{date}-{time}' if all((date, time)) else date + time
59
+ p = f'{dc} + {dt}' if all((dc, dt)) else dc + dt
60
+
61
+ self.path = f'{path}/{p}' if all((path, p)) else path + p
62
+
63
+ str_parts = parse_fields(self.path)
64
+ self.strf_parts = tuple(i for i in str_parts if i in FIELD_TO_PSTRING)
65
+
66
+ def times(self, ts: datetime) -> dict[str, str]:
67
+ return {k: ts.strftime(FIELD_TO_PSTRING[k]) for k in self.strf_parts}
68
+
69
+ def evaluate(
70
+ self,
71
+ track: track.Track,
72
+ aliases: aliases.Aliases,
73
+ timestamp: float,
74
+ index: int,
75
+ ) -> str:
76
+ ts = datetime.fromtimestamp(timestamp)
77
+ s = ts.strftime(self.path)
78
+
79
+ return s.format(
80
+ channel=track.name,
81
+ device=aliases.display_name(track.device),
82
+ index=str(index),
83
+ track=aliases.display_name(track, short=False),
84
+ **self.times(ts),
85
+ )
86
+
87
+
88
+ DATE = {Req.year, Req.month, Req.day}
89
+ TIME = {Req.hour, Req.minute, Req.second}
90
+
91
+ FIELD_TO_REQUIRED: dict[str, set[Req]] = {
92
+ 'index': DATE | TIME,
93
+ 'device': {Req.device},
94
+ 'channel': {Req.channel},
95
+ 'track': {Req.channel, Req.device},
96
+ #
97
+ 'date': DATE,
98
+ 'time': TIME,
99
+ 'ddate': DATE,
100
+ 'dtime': TIME,
101
+ 'sdate': DATE,
102
+ 'stime': TIME,
103
+ 'timestamp': DATE | TIME,
104
+ #
105
+ 'year': {Req.year},
106
+ 'month': {Req.month},
107
+ 'day': {Req.day},
108
+ 'hour': {Req.hour},
109
+ 'minute': {Req.minute},
110
+ 'second': {Req.second},
111
+ #
112
+ '%A': set(),
113
+ '%B': {Req.month},
114
+ '%G': {Req.year},
115
+ '%H': {Req.hour},
116
+ '%I': set(),
117
+ '%M': {Req.minute},
118
+ '%S': {Req.second},
119
+ '%U': set(),
120
+ '%V': set(),
121
+ '%W': set(),
122
+ '%X': TIME,
123
+ '%Y': {Req.year},
124
+ '%Z': set(),
125
+ '%a': set(),
126
+ '%b': {Req.month},
127
+ '%c': DATE | TIME,
128
+ '%d': {Req.day},
129
+ '%f': set(),
130
+ '%j': {Req.month, Req.day},
131
+ '%m': {Req.month},
132
+ '%p': set(),
133
+ '%u': set(),
134
+ '%w': set(),
135
+ '%x': DATE,
136
+ '%y': {Req.year},
137
+ '%%': set(),
138
+ }
139
+ FIELDS = set(FIELD_TO_REQUIRED)
140
+
141
+ FIELD_TO_PSTRING: dict[str, str] = {
142
+ 'date': '%Y%m%d',
143
+ 'time': '%H%M%S',
144
+ 'ddate': '%Y-%m-%d',
145
+ 'dtime': '%H-%M-%S',
146
+ 'sdate': '%Y/%m/%d',
147
+ 'stime': '%H/%M/%S',
148
+ 'timestamp': '%H%M%S_Y%m%d',
149
+ #
150
+ 'year': '%Y',
151
+ 'month': '%m',
152
+ 'day': '%d',
153
+ 'hour': '%H',
154
+ 'minute': '%M',
155
+ 'second': '%S',
156
+ }