Skip to content

Commit 77f6f0b

Browse files
committed
feat: Add datetime support
1 parent 4a895af commit 77f6f0b

4 files changed

Lines changed: 114 additions & 62 deletions

File tree

src/sphinxnotes/data/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,6 @@
7070
logger = logging.getLogger(__name__)
7171

7272

73-
74-
7573
def setup(app: Sphinx):
7674
meta.pre_setup(app)
7775

src/sphinxnotes/data/config.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,9 @@
66
:license: BSD, see LICENSE for details.
77
"""
88

9-
from typing import TYPE_CHECKING
9+
from sphinx.application import Sphinx
10+
from sphinx.config import Config as SphinxConfig
1011

11-
if TYPE_CHECKING:
12-
from sphinx.application import Sphinx
13-
from sphinx.config import Config as SphinxConfig
1412

1513
class Config:
1614
"""Global config of extesion."""
@@ -22,7 +20,7 @@ class Config:
2220
datetime_fmt: str
2321

2422

25-
def _config_inited(_: Sphinx, config: SphinxConfig) -> None:
23+
def _config_inited(app: Sphinx, config: SphinxConfig) -> None:
2624
Config.template_debug = config.data_template_debug
2725

2826
Config.date_fmt = config.data_date_fmt

src/sphinxnotes/data/data.py

Lines changed: 73 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,18 @@
33
import re
44
from dataclasses import dataclass, asdict, field as dataclass_field
55
from ast import literal_eval
6+
from datetime import date, time, datetime
7+
8+
from .config import Config
69

710
if TYPE_CHECKING:
811
from typing import Any, Callable, Generator, Self, Literal
912

13+
# =====================================
14+
# Basic classes: Value, Form, Flag, ...
15+
# =====================================
1016

11-
#########################################
12-
# Basic classes: Value, Form, Flag, ... #
13-
#########################################
14-
15-
type PlainValue = bool | int | float | str
17+
type PlainValue = bool | int | float | str | date | time | datetime
1618
type Value = None | PlainValue | list[PlainValue]
1719

1820

@@ -24,14 +26,9 @@ def as_plain(self) -> PlainValue | None:
2426
if self.v is None:
2527
return None
2628
if isinstance(self.v, list):
27-
if len(self.v) == 0:
28-
return None
29-
return self.v[0]
29+
return self.v[0] if len(self.v) else None
3030
return self.v
3131

32-
def as_str(self) -> str | None:
33-
return str(self.as_plain())
34-
3532
def as_list(self) -> list[PlainValue]:
3633
if self.v is None:
3734
return []
@@ -40,13 +37,22 @@ def as_list(self) -> list[PlainValue]:
4037
else:
4138
return [self.v]
4239

40+
def as_str(self) -> str | None:
41+
v = self.as_plain()
42+
return self._strify(v) if v is not None else None
43+
4344
def as_str_list(self) -> list[str]:
44-
if self.v is None:
45-
return []
46-
elif isinstance(self.v, list):
47-
return [str(x) for x in self.v]
48-
else:
49-
return [str(self.v)]
45+
return [self._strify(x) for x in self.as_list()]
46+
47+
@staticmethod
48+
def _strify(v: PlainValue) -> str:
49+
if isinstance(v, datetime):
50+
return v.strftime(Config.datetime_fmt)
51+
elif isinstance(v, date):
52+
return v.strftime(Config.date_fmt)
53+
elif isinstance(v, time):
54+
return v.strftime(Config.time_fmt)
55+
return str(v)
5056

5157

5258
@dataclass(frozen=True)
@@ -77,9 +83,44 @@ class OperFlag(Flag):
7783
store: FlagStore = 'assign'
7884

7985

80-
############
81-
# Registry #
82-
############
86+
# ========
87+
# Registry
88+
# ========
89+
90+
91+
def _bool_conv(v: str | None) -> bool:
92+
v = v.lower().strip() if v is not None else None
93+
if v in ('true', 'yes', '1', 'on', 'y', ''):
94+
return True
95+
if v in ('false', 'no', '0', 'off', 'n', None):
96+
return False
97+
# Same to :meth:`directives.flag`.
98+
raise ValueError(f'no argument is allowed; "{v}" supplied')
99+
100+
101+
def _str_conv(v: str) -> str:
102+
try:
103+
vv = literal_eval(v)
104+
except (ValueError, SyntaxError):
105+
return v
106+
return vv if isinstance(vv, str) else v
107+
108+
109+
def _date_conv(v: str) -> date:
110+
return datetime.strptime(v, Config.date_fmt).date()
111+
112+
113+
def _time_conv(v: str) -> time:
114+
return datetime.strptime(v, Config.time_fmt).time()
115+
116+
117+
def _datetime_conv(v: str) -> datetime:
118+
return datetime.strptime(v, Config.datetime_fmt)
119+
120+
121+
_required_flag = BoolFlag('required')
122+
123+
_sep_flag = OperFlag('sep', etype=str)
83124

84125

85126
class Registry:
@@ -95,33 +136,19 @@ class Registry:
95136
'num': float,
96137
'str': str,
97138
'string': str,
139+
'date': date,
140+
'time': time,
141+
'datetime': datetime,
98142
}
99143

100-
"""Internal type converters."""
101-
102-
@staticmethod
103-
def _bool_conv(v: str | None) -> bool:
104-
v = v.lower().strip() if v is not None else None
105-
if v in ('true', 'yes', '1', 'on', 'y', ''):
106-
return True
107-
if v in ('false', 'no', '0', 'off', 'n', None):
108-
return False
109-
# Same to :meth:`directives.flag`.
110-
raise ValueError(f'no argument is allowed; "{v}" supplied')
111-
112-
@staticmethod
113-
def _str_conv(v: str) -> str:
114-
try:
115-
vv = literal_eval(v)
116-
except (ValueError, SyntaxError):
117-
return v
118-
return vv if isinstance(vv, str) else v
119-
120144
convs: dict[type, Callable[[str], Any]] = {
121145
bool: _bool_conv,
122146
int: int,
123147
float: float,
124148
str: _str_conv,
149+
date: _date_conv,
150+
time: _time_conv,
151+
datetime: _datetime_conv,
125152
}
126153

127154
forms: dict[str, Form] = {
@@ -132,9 +159,6 @@ def _str_conv(v: str) -> str:
132159

133160
"""Builtin flags."""
134161

135-
_required_flag = BoolFlag('required')
136-
_sep_flag = OperFlag('sep', etype=str)
137-
138162
flags: dict[str, BoolFlag] = {
139163
'required': _required_flag,
140164
'require': _required_flag,
@@ -147,9 +171,9 @@ def _str_conv(v: str) -> str:
147171
}
148172

149173

150-
##########################
151-
# Data, Field and Schema #
152-
##########################
174+
# ======================
175+
# Data, Field and Schema
176+
# ======================
153177

154178

155179
@dataclass
@@ -216,7 +240,7 @@ def parse(self, rawval: str | None) -> Value:
216240
if self.etype is bool:
217241
# Special case: A single bool field is valid even when
218242
# value is not supplied.
219-
return Registry._bool_conv(rawval)
243+
return _bool_conv(rawval)
220244
return None
221245

222246
# Strip whitespace. TODO: supported unchanged?
@@ -305,7 +329,7 @@ def _apply_modifier(self, mod: str):
305329

306330
self.field.etype = Registry.etypes[etype]
307331
self.field.ctype = Registry.forms[form].ctype
308-
self.field.flags[Registry._sep_flag.name] = Registry.forms[form].sep
332+
self.field.flags[_sep_flag.name] = Registry.forms[form].sep
309333
return
310334

311335
# Match: Type only (e.g., "int")
@@ -341,7 +365,7 @@ def _apply_modifier(self, mod: str):
341365
)
342366

343367
# Deal with builtin flags.
344-
if flag == Registry._sep_flag:
368+
if flag == _sep_flag:
345369
# ctype default to list if 'sep by' is used without a 'xxx of xxx'.
346370
if self.field.ctype is None:
347371
self.field.ctype = list

tests/test_data.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
11
import os
22
import sys
33
import unittest
4-
from textwrap import dedent
4+
from datetime import date, time, datetime
55

66
sys.path.insert(0, os.path.abspath('./src/sphinxnotes'))
77

88
from data.data import Field, Registry, BoolFlag, OperFlag
9+
from data.config import Config
910

1011
class TestFieldParser(unittest.TestCase):
1112

1213
# ==========================
1314
# Basic Types
1415
# ==========================
15-
16+
1617
def test_basic_scalar(self):
1718
f = Field.from_dsl('int')
1819
self.assertEqual(f.parse('123'), 123)
19-
20+
2021
f = Field.from_dsl('bool')
2122
self.assertTrue(f.parse('true'))
2223
self.assertTrue(f.parse('y'))
@@ -89,7 +90,7 @@ def test_custom_bool_flags(self):
8990
self.assertTrue(f.uniq)
9091
f = Field.from_dsl(r'int')
9192
self.assertFalse(f.uniq)
92-
93+
9394
# Test default value.
9495
Registry.flags['ref'] = BoolFlag('ref', default=True)
9596
f = Field.from_dsl(r'int, ref')
@@ -103,14 +104,31 @@ def test_custom_oper_flags(self):
103104
self.assertEqual(f.group, 'foo')
104105
f = Field.from_dsl(r'int')
105106
self.assertEqual(f.group, None)
106-
107+
107108
# Test append
108109
Registry.byflags['index'] = OperFlag('index', etype=str, store='append')
109110
f = Field.from_dsl(r'int, index by year')
110111
self.assertEqual(f.index, ['year'])
111112
f = Field.from_dsl(r'int, index by year, index by month')
112113
self.assertEqual(f.index, ['year', 'month'])
113114

115+
def test_datetime(self):
116+
# FIXME: side effects
117+
Config.date_fmt = '%Y-%m-%d'
118+
Config.time_fmt = '%H:%M:%S'
119+
Config.datetime_fmt = '%Y-%m-%d %H:%M:%S'
120+
121+
val = Field.from_dsl('date').parse('2023-10-01')
122+
self.assertIsInstance(val, date)
123+
self.assertEqual(val, date(2023, 10, 1))
124+
125+
val = Field.from_dsl('time').parse('14:30:00')
126+
self.assertIsInstance(val, time)
127+
self.assertEqual(val, time(14, 30, 0))
128+
129+
val = Field.from_dsl('datetime').parse('2023-10-01 14:30:00')
130+
self.assertIsInstance(val, datetime)
131+
self.assertEqual(val, datetime(2023, 10, 1, 14, 30, 0))
114132

115133
# ==========================
116134
# Errors
@@ -119,9 +137,23 @@ def test_custom_oper_flags(self):
119137
def test_unsupported_modifier(self):
120138
with self.assertRaisesRegex(ValueError, 'unsupported type'):
121139
Field.from_dsl('list of unknown')
122-
140+
123141
with self.assertRaisesRegex(ValueError, 'unknown modifier'):
124142
Field.from_dsl('int, random_mod')
125143

144+
145+
def test_invalid_formats(self):
146+
# FIXME: side effects
147+
Config.date_fmt = '%Y-%m-%d'
148+
Config.time_fmt = '%H:%M:%S'
149+
Config.datetime_fmt = '%Y-%m-%d %H:%M:%S'
150+
151+
with self.assertRaisesRegex(ValueError, 'failed to parse'):
152+
Field.from_dsl('date').parse('not-a-date')
153+
154+
with self.assertRaisesRegex(ValueError, 'failed to parse'):
155+
Field.from_dsl('datetime').parse('2023/13/45')
156+
157+
126158
if __name__ == '__main__':
127159
unittest.main()

0 commit comments

Comments
 (0)