todoosy 0.1.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.
todoosy-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: todoosy
3
+ Version: 0.1.0
4
+ Summary: Todoosy - Markdown-based todo system parser, formatter, linter, and query engine
5
+ License: MIT
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
17
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
18
+
19
+ # Todoosy Python Library
20
+
21
+ Python implementation of the Todoosy format parser, formatter, linter, and query engine.
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install -e .
27
+ ```
28
+
29
+ For development:
30
+ ```bash
31
+ pip install -e ".[dev]"
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ```python
37
+ from todoosy import parse, format, lint, query_upcoming, query_misc, parse_scheme
38
+
39
+ # Parse a document
40
+ result = parse('''
41
+ # Work
42
+
43
+ - Task (due 2026-01-15 p1 2h)
44
+
45
+ # Misc
46
+ ''')
47
+
48
+ # Access the AST
49
+ for item in result.ast.items:
50
+ print(f"{item.title_text}: due={item.metadata.due}")
51
+
52
+ # Format a document
53
+ formatted = format(input_text)
54
+
55
+ # Lint a document
56
+ warnings = lint(input_text)
57
+ for w in warnings.warnings:
58
+ print(f"{w.code}: {w.message}")
59
+
60
+ # Query upcoming items
61
+ scheme = parse_scheme(scheme_text)
62
+ upcoming = query_upcoming(input_text, scheme)
63
+ for item in upcoming.items:
64
+ print(f"{item.path}: {item.due}")
65
+
66
+ # Query misc items
67
+ misc = query_misc(input_text)
68
+ for item in misc.items:
69
+ print(item.title_text)
70
+ ```
71
+
72
+ ## Running Tests
73
+
74
+ ```bash
75
+ pytest
76
+ ```
@@ -0,0 +1,58 @@
1
+ # Todoosy Python Library
2
+
3
+ Python implementation of the Todoosy format parser, formatter, linter, and query engine.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install -e .
9
+ ```
10
+
11
+ For development:
12
+ ```bash
13
+ pip install -e ".[dev]"
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```python
19
+ from todoosy import parse, format, lint, query_upcoming, query_misc, parse_scheme
20
+
21
+ # Parse a document
22
+ result = parse('''
23
+ # Work
24
+
25
+ - Task (due 2026-01-15 p1 2h)
26
+
27
+ # Misc
28
+ ''')
29
+
30
+ # Access the AST
31
+ for item in result.ast.items:
32
+ print(f"{item.title_text}: due={item.metadata.due}")
33
+
34
+ # Format a document
35
+ formatted = format(input_text)
36
+
37
+ # Lint a document
38
+ warnings = lint(input_text)
39
+ for w in warnings.warnings:
40
+ print(f"{w.code}: {w.message}")
41
+
42
+ # Query upcoming items
43
+ scheme = parse_scheme(scheme_text)
44
+ upcoming = query_upcoming(input_text, scheme)
45
+ for item in upcoming.items:
46
+ print(f"{item.path}: {item.due}")
47
+
48
+ # Query misc items
49
+ misc = query_misc(input_text)
50
+ for item in misc.items:
51
+ print(item.title_text)
52
+ ```
53
+
54
+ ## Running Tests
55
+
56
+ ```bash
57
+ pytest
58
+ ```
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "todoosy"
7
+ version = "0.1.0"
8
+ description = "Todoosy - Markdown-based todo system parser, formatter, linter, and query engine"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.10"
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ dev = [
24
+ "pytest>=7.0.0",
25
+ "pytest-cov>=4.0.0",
26
+ ]
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["."]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
33
+ python_files = ["test_*.py"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ # Tests package
@@ -0,0 +1,226 @@
1
+ """
2
+ Todoosy Golden Tests - Using shared test files
3
+ """
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from todoosy import (
12
+ parse,
13
+ format,
14
+ lint,
15
+ query_upcoming,
16
+ query_misc,
17
+ parse_scheme,
18
+ )
19
+
20
+ # Get test data directory
21
+ TEST_DIR = Path(__file__).parent.parent.parent / 'testdata'
22
+
23
+
24
+ def get_test_cases():
25
+ """Get all test case directories."""
26
+ if not TEST_DIR.exists():
27
+ return []
28
+ return sorted([d.name for d in TEST_DIR.iterdir() if d.is_dir()])
29
+
30
+
31
+ def load_file(test_case: str, filename: str) -> str | None:
32
+ """Load a file from a test case directory."""
33
+ file_path = TEST_DIR / test_case / filename
34
+ if file_path.exists():
35
+ return file_path.read_text()
36
+ return None
37
+
38
+
39
+ def load_json(test_case: str, filename: str):
40
+ """Load a JSON file from a test case directory."""
41
+ content = load_file(test_case, filename)
42
+ if content:
43
+ return json.loads(content)
44
+ return None
45
+
46
+
47
+ class TestParser:
48
+ @pytest.mark.parametrize('test_case', get_test_cases())
49
+ def test_parse(self, test_case):
50
+ input_md = load_file(test_case, 'input.md')
51
+ expected_ast = load_json(test_case, 'expected_ast.json')
52
+
53
+ if not input_md or not expected_ast:
54
+ pytest.skip(f'Missing input or expected_ast for {test_case}')
55
+
56
+ result = parse(input_md)
57
+ ast = result.ast
58
+
59
+ # Compare items count
60
+ assert len(ast.items) == len(expected_ast['items'])
61
+
62
+ # Compare each item's essential properties
63
+ for i, (actual, expected) in enumerate(zip(ast.items, expected_ast['items'])):
64
+ assert actual.type == expected['type'], f"Item {i} type mismatch"
65
+ assert actual.title_text == expected['title_text'], f"Item {i} title_text mismatch"
66
+ assert actual.metadata.due == expected['metadata']['due'], f"Item {i} due mismatch"
67
+ assert actual.metadata.priority == expected['metadata']['priority'], f"Item {i} priority mismatch"
68
+ assert actual.metadata.estimate_minutes == expected['metadata']['estimate_minutes'], f"Item {i} estimate mismatch"
69
+ assert actual.comments == expected['comments'], f"Item {i} comments mismatch"
70
+ assert len(actual.children) == len(expected['children']), f"Item {i} children count mismatch"
71
+
72
+ # Compare root_ids count
73
+ assert len(ast.root_ids) == len(expected_ast['root_ids'])
74
+
75
+
76
+ class TestFormatter:
77
+ @pytest.mark.parametrize('test_case', get_test_cases())
78
+ def test_format(self, test_case):
79
+ input_md = load_file(test_case, 'input.md')
80
+ expected_formatted = load_file(test_case, 'expected_formatted.md')
81
+
82
+ if not input_md or not expected_formatted:
83
+ pytest.skip(f'Missing input or expected_formatted for {test_case}')
84
+
85
+ formatted = format(input_md)
86
+ assert formatted == expected_formatted
87
+
88
+
89
+ class TestLinter:
90
+ @pytest.mark.parametrize('test_case', get_test_cases())
91
+ def test_lint(self, test_case):
92
+ input_md = load_file(test_case, 'input.md')
93
+ expected_warnings = load_json(test_case, 'expected_warnings.json')
94
+ scheme_text = load_file(test_case, 'scheme.md')
95
+ scheme = parse_scheme(scheme_text) if scheme_text else None
96
+
97
+ if not input_md or not expected_warnings:
98
+ pytest.skip(f'Missing input or expected_warnings for {test_case}')
99
+
100
+ result = lint(input_md, scheme)
101
+
102
+ actual_codes = sorted([w.code for w in result.warnings])
103
+ expected_codes = sorted([w['code'] for w in expected_warnings.get('warnings', [])])
104
+ assert actual_codes == expected_codes
105
+
106
+
107
+ class TestQueryUpcoming:
108
+ @pytest.mark.parametrize('test_case', get_test_cases())
109
+ def test_query_upcoming(self, test_case):
110
+ input_md = load_file(test_case, 'input.md')
111
+ expected_upcoming = load_json(test_case, 'expected_upcoming.json')
112
+ scheme_text = load_file(test_case, 'scheme.md')
113
+ scheme = parse_scheme(scheme_text) if scheme_text else None
114
+
115
+ if not input_md or not expected_upcoming:
116
+ pytest.skip(f'Missing input or expected_upcoming for {test_case}')
117
+
118
+ result = query_upcoming(input_md, scheme)
119
+
120
+ assert len(result.items) == len(expected_upcoming['items'])
121
+
122
+ for actual, expected in zip(result.items, expected_upcoming['items']):
123
+ assert actual.due == expected['due']
124
+ assert actual.priority == expected['priority']
125
+ if 'priority_label' in expected:
126
+ assert actual.priority_label == expected['priority_label']
127
+
128
+
129
+ class TestQueryMisc:
130
+ @pytest.mark.parametrize('test_case', get_test_cases())
131
+ def test_query_misc(self, test_case):
132
+ input_md = load_file(test_case, 'input.md')
133
+ expected_misc = load_json(test_case, 'expected_misc.json')
134
+
135
+ if not input_md or not expected_misc:
136
+ pytest.skip(f'Missing input or expected_misc for {test_case}')
137
+
138
+ result = query_misc(input_md)
139
+
140
+ assert len(result.items) == len(expected_misc['items'])
141
+
142
+ for actual, expected in zip(result.items, expected_misc['items']):
143
+ assert actual.title_text == expected['title_text']
144
+
145
+
146
+ class TestSchemeParser:
147
+ @pytest.mark.parametrize('test_case', get_test_cases())
148
+ def test_parse_scheme(self, test_case):
149
+ scheme_text = load_file(test_case, 'scheme.md')
150
+ expected_scheme = load_json(test_case, 'expected_scheme.json')
151
+
152
+ if not scheme_text or not expected_scheme:
153
+ return # Scheme is optional
154
+
155
+ scheme = parse_scheme(scheme_text)
156
+
157
+ assert scheme.timezone == expected_scheme['timezone']
158
+ assert scheme.priorities == expected_scheme['priorities']
159
+
160
+
161
+ class TestEdgeCases:
162
+ def test_empty_document(self):
163
+ result = parse('')
164
+ assert len(result.ast.items) == 0
165
+ assert len(result.ast.root_ids) == 0
166
+
167
+ def test_whitespace_only(self):
168
+ result = parse(' \n\n ')
169
+ assert len(result.ast.items) == 0
170
+
171
+ def test_numbered_lists(self):
172
+ result = parse('# Tasks\n\n1. First task\n2. Second task')
173
+ assert len(result.ast.items) == 3
174
+ assert result.ast.items[1].title_text == 'First task'
175
+ assert result.ast.items[2].title_text == 'Second task'
176
+
177
+ def test_asterisk_lists(self):
178
+ result = parse('# Tasks\n\n* Task one\n* Task two')
179
+ assert len(result.ast.items) == 3
180
+ assert result.ast.items[1].title_text == 'Task one'
181
+
182
+ def test_2digit_year_dates(self):
183
+ result = parse('- Task (due 01/15/26)')
184
+ assert result.ast.items[0].metadata.due == '2026-01-15'
185
+
186
+ def test_estimate_days(self):
187
+ result = parse('- Task (2d)')
188
+ assert result.ast.items[0].metadata.estimate_minutes == 960
189
+
190
+ def test_formatter_adds_misc(self):
191
+ formatted = format('# Work\n\n- Task')
192
+ assert '# Misc' in formatted
193
+
194
+ def test_formatter_preserves_non_metadata_parens(self):
195
+ formatted = format('# Work\n\n- Call John (CEO)\n\n# Misc\n')
196
+ assert '(CEO)' in formatted
197
+
198
+ def test_linter_warns_missing_misc(self):
199
+ result = lint('# Work\n\n- Task')
200
+ assert any(w.code == 'MISC_MISSING' for w in result.warnings)
201
+
202
+ def test_linter_no_warnings_valid_doc(self):
203
+ result = lint('# Work\n\n- Task (due 2026-01-15 p1 2h)\n\n# Misc\n')
204
+ assert len(result.warnings) == 0
205
+
206
+ def test_scheme_empty(self):
207
+ scheme = parse_scheme('')
208
+ assert scheme.timezone is None
209
+ assert scheme.priorities == {}
210
+
211
+ def test_scheme_timezone_only(self):
212
+ scheme = parse_scheme('# Timezone\n\nEurope/London')
213
+ assert scheme.timezone == 'Europe/London'
214
+ assert scheme.priorities == {}
215
+
216
+ def test_scheme_various_bullets(self):
217
+ scheme = parse_scheme('''
218
+ # Priorities
219
+
220
+ - P0 - Critical
221
+ * P1 - High
222
+ P2 - Medium
223
+ ''')
224
+ assert scheme.priorities['0'] == 'Critical'
225
+ assert scheme.priorities['1'] == 'High'
226
+ assert scheme.priorities['2'] == 'Medium'
@@ -0,0 +1,40 @@
1
+ """
2
+ Todoosy - Markdown-based todo system
3
+ """
4
+
5
+ from .parser import parse, ParseResult
6
+ from .formatter import format
7
+ from .linter import lint, LintResult
8
+ from .query import query_upcoming, query_misc, UpcomingResult, MiscResult
9
+ from .scheme import parse_scheme
10
+ from .types import (
11
+ AST,
12
+ ItemNode,
13
+ ItemMetadata,
14
+ Warning,
15
+ UpcomingItem,
16
+ MiscItem,
17
+ Scheme,
18
+ )
19
+
20
+ __all__ = [
21
+ 'parse',
22
+ 'ParseResult',
23
+ 'format',
24
+ 'lint',
25
+ 'LintResult',
26
+ 'query_upcoming',
27
+ 'query_misc',
28
+ 'UpcomingResult',
29
+ 'MiscResult',
30
+ 'parse_scheme',
31
+ 'AST',
32
+ 'ItemNode',
33
+ 'ItemMetadata',
34
+ 'Warning',
35
+ 'UpcomingItem',
36
+ 'MiscItem',
37
+ 'Scheme',
38
+ ]
39
+
40
+ __version__ = '0.1.0'
@@ -0,0 +1,136 @@
1
+ """
2
+ Todoosy Formatter
3
+ """
4
+
5
+ from .parser import parse
6
+ from .types import ItemNode, ItemMetadata
7
+
8
+
9
+ def format_metadata(metadata: ItemMetadata) -> str:
10
+ """Format metadata as a canonical string."""
11
+ parts: list[str] = []
12
+
13
+ if metadata.due:
14
+ parts.append(f"due {metadata.due}")
15
+
16
+ if metadata.priority is not None:
17
+ parts.append(f"p{metadata.priority}")
18
+
19
+ if metadata.estimate_minutes is not None:
20
+ minutes = metadata.estimate_minutes
21
+ if minutes % 480 == 0 and minutes >= 480:
22
+ parts.append(f"{minutes // 480}d")
23
+ elif minutes % 60 == 0 and minutes >= 60:
24
+ parts.append(f"{minutes // 60}h")
25
+ else:
26
+ parts.append(f"{minutes}m")
27
+
28
+ return f"({' '.join(parts)})" if parts else ''
29
+
30
+
31
+ def format_item_line(item: ItemNode, indent: int = 0) -> str:
32
+ """Format a single item line."""
33
+ indent_str = ' ' * indent
34
+ meta_str = format_metadata(item.metadata)
35
+ title_with_meta = f"{item.title_text} {meta_str}" if meta_str else item.title_text
36
+
37
+ if item.type == 'heading':
38
+ hashes = '#' * (item.level or 1)
39
+ return f"{hashes} {title_with_meta}"
40
+
41
+ return f"{indent_str}- {title_with_meta}"
42
+
43
+
44
+ def format_comments(comments: list[str], is_list_item: bool, indent: int) -> list[str]:
45
+ """Format comments with proper indentation."""
46
+ if not comments:
47
+ return []
48
+
49
+ if is_list_item:
50
+ indent_str = ' ' * (indent + 1)
51
+ return [f"{indent_str}{c}" for c in comments]
52
+
53
+ return list(comments)
54
+
55
+
56
+ def format(text: str) -> str:
57
+ """Format a todoosy document."""
58
+ result = parse(text)
59
+ ast = result.ast
60
+ lines: list[str] = []
61
+ item_map = {item.id: item for item in ast.items}
62
+
63
+ # Track Misc section
64
+ misc_section_id = None
65
+ for item in ast.items:
66
+ if item.type == 'heading' and item.title_text == 'Misc' and item.level == 1:
67
+ misc_section_id = item.id
68
+ break
69
+
70
+ def format_item(item_id: str, list_indent: int = 0, is_under_misc: bool = False) -> None:
71
+ item = item_map[item_id]
72
+
73
+ # Skip Misc section during normal iteration
74
+ if item.id == misc_section_id and not is_under_misc:
75
+ return
76
+
77
+ # Add blank line before headings (except at start)
78
+ if item.type == 'heading' and lines and lines[-1] != '':
79
+ lines.append('')
80
+
81
+ lines.append(format_item_line(item, list_indent))
82
+
83
+ # Add blank line after heading before comments or children
84
+ if item.type == 'heading':
85
+ lines.append('')
86
+
87
+ # Add comments
88
+ formatted_comments = format_comments(
89
+ item.comments,
90
+ item.type == 'list',
91
+ list_indent
92
+ )
93
+ lines.extend(formatted_comments)
94
+
95
+ # Add blank line after heading comments before children
96
+ if item.type == 'heading' and item.comments and item.children:
97
+ lines.append('')
98
+
99
+ # Format children
100
+ for child_id in item.children:
101
+ child = item_map[child_id]
102
+ if child.type == 'list':
103
+ next_indent = 0 if item.type == 'heading' else list_indent + 1
104
+ format_item(child_id, next_indent, is_under_misc)
105
+ else:
106
+ format_item(child_id, 0, is_under_misc)
107
+
108
+ # Format all root items except Misc
109
+ for root_id in ast.root_ids:
110
+ if root_id != misc_section_id:
111
+ format_item(root_id, 0, False)
112
+
113
+ # Add Misc section at the end
114
+ if lines and lines[-1] != '':
115
+ lines.append('')
116
+ lines.append('# Misc')
117
+
118
+ # Add Misc items if they exist
119
+ if misc_section_id:
120
+ misc_item = item_map[misc_section_id]
121
+ if misc_item.comments:
122
+ lines.append('')
123
+ lines.extend(misc_item.comments)
124
+ if misc_item.children:
125
+ lines.append('')
126
+ for child_id in misc_item.children:
127
+ child = item_map[child_id]
128
+ lines.append(format_item_line(child, 0))
129
+ formatted_comments = format_comments(
130
+ child.comments,
131
+ child.type == 'list',
132
+ 0
133
+ )
134
+ lines.extend(formatted_comments)
135
+
136
+ return '\n'.join(lines) + '\n'