twlaw 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.
twlaw-0.1.0/LICENSE ADDED
@@ -0,0 +1,35 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yihsuan Chen
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.
22
+
23
+ ---
24
+
25
+ This license covers the twlaw source code only.
26
+
27
+ twlaw does not contain or distribute any legal data. Laws and regulations are
28
+ downloaded at runtime by LawDB.refresh() from the Ministry of Justice's
29
+ "Laws & Regulations Database of the Republic of China (Taiwan)"
30
+ (全國法規資料庫), and are licensed separately by the MOJ under the Open
31
+ Government Data License, version 1.0 (政府資料開放授權條款-第1版).
32
+
33
+ Users of that data must comply with its terms, including the requirement to
34
+ attribute the source. See https://data.gov.tw/license and
35
+ https://law.moj.gov.tw/ for details.
twlaw-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,282 @@
1
+ Metadata-Version: 2.4
2
+ Name: twlaw
3
+ Version: 0.1.0
4
+ Summary: Queryable local access to Taiwan's national laws and regulations database
5
+ Keywords: taiwan,law,legal,regulations,sqlite,全國法規資料庫
6
+ Author: Yihsuan Chen
7
+ Author-email: Yihsuan Chen <yhc0712.tw@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Legal Industry
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Database
19
+ Classifier: Topic :: Text Processing :: Indexing
20
+ Classifier: Natural Language :: Chinese (Traditional)
21
+ Classifier: Natural Language :: English
22
+ Requires-Dist: requests>=2.34.2
23
+ Requires-Dist: truststore>=0.10.4
24
+ Requires-Python: >=3.11
25
+ Project-URL: Homepage, https://github.com/yhc0712/twlaw
26
+ Project-URL: Repository, https://github.com/yhc0712/twlaw
27
+ Project-URL: Issues, https://github.com/yhc0712/twlaw/issues
28
+ Description-Content-Type: text/markdown
29
+
30
+ # twlaw
31
+
32
+ Queryable local access to Taiwan's national laws and regulations database
33
+ (全國法規資料庫).
34
+
35
+ 繁體中文說明:[README.zh-TW.md](README.zh-TW.md)
36
+
37
+ ## Why
38
+
39
+ The MOJ Open API only serves whole-database zip dumps. There is no search, no
40
+ per-record lookup, and chapter headings are flattened into the article list with
41
+ indentation as the only clue to structure. `twlaw` downloads those dumps,
42
+ reconstructs the hierarchy for every article, and stores the result in a local
43
+ SQLite database you can query.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ uv add twlaw # or: pip install twlaw
49
+ ```
50
+
51
+ ## Getting started
52
+
53
+ Building the local database is a separate, explicit step. Do it once:
54
+
55
+ ```python
56
+ from twlaw import LawDB
57
+
58
+ db = LawDB() # opens ~/.twlaw/law.db (created if missing)
59
+ db.refresh() # downloads all 4 datasets — ~2 min, ~520 MB on disk
60
+ ```
61
+
62
+ After that, every query is local and fast (single-digit milliseconds). Nothing
63
+ touches the network again until you call `refresh()` yourself.
64
+
65
+ ```python
66
+ from twlaw import LawDB
67
+
68
+ db = LawDB() # reopens the existing database
69
+ if db.is_empty: # guard for first run
70
+ db.refresh()
71
+
72
+ for hit in db.search("扣繳義務人", limit=5):
73
+ print(hit["law_name"], hit["article_no"])
74
+ print(" ", hit["chapter_path"])
75
+ ```
76
+
77
+ ```
78
+ 所得稅法 第 94 條
79
+ 第 四 章 稽徵程序 / 第 四 節 扣繳
80
+ ```
81
+
82
+ ## API
83
+
84
+ ### `LawDB(path=None)`
85
+
86
+ Opens (and creates if needed) the SQLite store. Defaults to `~/.twlaw/law.db`;
87
+ pass a path to keep it elsewhere. Usable as a context manager.
88
+
89
+ ```python
90
+ with LawDB("./law.db") as db:
91
+ ...
92
+ ```
93
+
94
+ ### `db.refresh(categories=("law", "order"), langs=("zh", "en"))`
95
+
96
+ Re-downloads datasets and replaces their rows. Takes ~2 minutes for everything.
97
+ Returns how many laws were stored per dataset. Narrow the scope if you only
98
+ need part of it:
99
+
100
+ ```python
101
+ db.refresh(categories=("law",), langs=("zh",)) # Chinese statutes only, ~20 s
102
+ ```
103
+
104
+ Safe to re-run — rows are replaced, not duplicated.
105
+
106
+ ### `db.search(query, lang="zh", category=None, limit=50, include_repealed=False)`
107
+
108
+ Full-text search over article text. Returns a list of dicts, best match first:
109
+
110
+ | key | meaning |
111
+ | --- | --- |
112
+ | `law_id` | law code, e.g. `G0340003` |
113
+ | `law_name` | e.g. `所得稅法` |
114
+ | `category` | `law` or `order` |
115
+ | `article_no` | e.g. `第 94 條` |
116
+ | `article_key` | citable number, e.g. `94` or `4-1` |
117
+ | `content` | the article text |
118
+ | `chapter_path` | reconstructed hierarchy |
119
+ | `seq` | article position within the law |
120
+
121
+ ```python
122
+ db.search("營業稅", category="law") # statutes only, skip 命令
123
+ db.search("income tax", lang="en") # English corpus
124
+ db.search("設籍", include_repealed=True) # include (刪除) articles
125
+ ```
126
+
127
+ ### `db.get_law(law, lang="zh")`
128
+
129
+ One law with all its articles, or `None` if not found. Takes a law **name** or
130
+ a MOJ law code — use whichever you have. Returns the metadata fields plus an
131
+ `articles` list of `{seq, article_no, content, chapter_path}`.
132
+
133
+ ```python
134
+ law = db.get_law("所得稅法") # by name
135
+ law = db.get_law("G0340003") # same law, by code
136
+
137
+ law["name"] # 所得稅法
138
+ law["id"] # G0340003
139
+ law["modified_date"] # 20260911
140
+ len(law["articles"]) # 198
141
+ ```
142
+
143
+ Names match exactly, not by prefix — `"所得稅法"` gives you 所得稅法, never
144
+ 所得稅法施行細則. Use `list_laws(name_like=...)` or `search()` when you don't
145
+ know the exact name.
146
+
147
+ ### `db.get_article(law, article, lang="zh")`
148
+
149
+ One article by the number you'd cite it by, or `None` if there is no such
150
+ article. `4` is 第 4 條 and `"4-1"` is 第 4 條之一 — you never deal with list
151
+ positions or MOJ's label strings.
152
+
153
+ ```python
154
+ db.get_article("所得稅法", 1) # 第 1 條
155
+ db.get_article("所得稅法", "4-1") # 第 4 條之一 — a distinct article from 第 4 條
156
+ ```
157
+
158
+ ```python
159
+ {'seq': 8, 'article_no': '第 4-1 條', 'article_key': '4-1',
160
+ 'content': '自中華民國七十九年一月一日起,證券交易所得停止課徵所得稅…',
161
+ 'chapter_path': '第 一 章 總則 / 第 一 節 一般規定',
162
+ 'law_id': 'G0340003', 'law_name': '所得稅法'}
163
+ ```
164
+
165
+ Every article everywhere carries this `article_key`, so a `search()` hit can be
166
+ re-fetched or cited directly:
167
+
168
+ ```python
169
+ hit = db.search("扣繳義務人")[0]
170
+ db.get_article(hit["law_name"], hit["article_key"])
171
+ ```
172
+
173
+ ### `db.list_laws(category=None, lang="zh", name_like=None)`
174
+
175
+ Law metadata without article bodies — for browsing or building a picker.
176
+
177
+ ```python
178
+ db.list_laws(name_like="所得稅")
179
+ db.list_laws(category="law")
180
+ ```
181
+
182
+ ### `db.update_date()` / `db.is_empty`
183
+
184
+ MOJ's own publication date for the stored data, and whether anything is stored
185
+ yet. Use `update_date()` to decide if a `refresh()` is worthwhile.
186
+
187
+ ## Examples
188
+
189
+ Look up a single article:
190
+
191
+ ```python
192
+ print(db.get_article("所得稅法", "4-1")["content"])
193
+ ```
194
+
195
+ Read one law article by article:
196
+
197
+ ```python
198
+ law = db.get_law("所得稅法")
199
+
200
+ for a in law["articles"]:
201
+ print(a["article_no"], a["chapter_path"])
202
+ print(a["content"])
203
+ ```
204
+
205
+ Find which laws mention a term:
206
+
207
+ ```python
208
+ for hit in db.search("營業稅", limit=10):
209
+ print(hit["law_name"], hit["article_no"])
210
+ ```
211
+
212
+ List every tax law, using MOJ's own classification:
213
+
214
+ ```python
215
+ for l in db.list_laws():
216
+ if "賦稅" in l["moj_category"]:
217
+ print(l["id"], l["name"])
218
+ ```
219
+
220
+ ### Chunking for RAG
221
+
222
+ Each article already carries its chapter path, so a chunk is interpretable on
223
+ its own without extra work:
224
+
225
+ ```python
226
+ law = db.get_law("所得稅法")
227
+
228
+ for a in law["articles"]:
229
+ chunk = f"{law['name']} {a['article_no']}\n{a['chapter_path']}\n{a['content']}"
230
+ print(chunk)
231
+ ```
232
+
233
+ ## Coverage
234
+
235
+ | dataset | records |
236
+ | --- | --- |
237
+ | laws, Chinese | 1,347 |
238
+ | orders, Chinese | 10,451 |
239
+ | laws, English | 972 |
240
+ | orders, English | 2,206 |
241
+
242
+ English records are a separate, smaller corpus — not a translation of every
243
+ Chinese record. A law present in both shares the same `law_id`, so
244
+ `get_law(id, lang="en")` gives you the English text of the same statute.
245
+
246
+ ## Notes
247
+
248
+ - **Concurrency.** WAL mode is on, so many processes can read at once. Only
249
+ `refresh()` writes.
250
+ - **Search.** FTS5 with the `trigram` tokenizer, which is what makes Chinese
251
+ substring search work: the default `unicode61` treats an unbroken run of CJK
252
+ as one token, which would find 50 articles for 所得稅 instead of ~1,400.
253
+ Queries under 3 characters fall back to `LIKE`.
254
+ - **Storage.** Plain SQLite tables (`laws`, `articles`), so the file is readable
255
+ from any language, not only Python.
256
+
257
+ ## Data source and licensing
258
+
259
+ `twlaw` itself is MIT licensed, and the package ships **no legal data**. Laws
260
+ and regulations are downloaded at runtime by `refresh()` from the
261
+ [MOJ Open API](https://law.moj.gov.tw/api/swagger/index.html).
262
+
263
+ That data is published by the Ministry of Justice under the
264
+ [Open Government Data License v1.0](https://data.gov.tw/license)
265
+ (政府資料開放授權條款-第1版), which permits free reuse, modification and
266
+ redistribution — **provided you attribute the source**. If you ship a product
267
+ built on this data, credit it, for example:
268
+
269
+ > Data source: Laws & Regulations Database of the Republic of China (Taiwan),
270
+ > Ministry of Justice — https://law.moj.gov.tw/
271
+
272
+ Two things worth knowing if you build on this:
273
+
274
+ - **The MOJ database is authoritative; this is a convenience mirror.** `twlaw`
275
+ reconstructs chapter hierarchy and splits articles, which is ordinary
276
+ permitted adaptation — but the result is a *parsed* view. Cite the official
277
+ text for anything that matters.
278
+ - **Don't misrepresent the content.** The license asks that the data not be
279
+ altered in ways that make the displayed information contradict the original.
280
+
281
+ This section is a summary, not legal advice. Read the license if the
282
+ distinction matters to you.
twlaw-0.1.0/README.md ADDED
@@ -0,0 +1,253 @@
1
+ # twlaw
2
+
3
+ Queryable local access to Taiwan's national laws and regulations database
4
+ (全國法規資料庫).
5
+
6
+ 繁體中文說明:[README.zh-TW.md](README.zh-TW.md)
7
+
8
+ ## Why
9
+
10
+ The MOJ Open API only serves whole-database zip dumps. There is no search, no
11
+ per-record lookup, and chapter headings are flattened into the article list with
12
+ indentation as the only clue to structure. `twlaw` downloads those dumps,
13
+ reconstructs the hierarchy for every article, and stores the result in a local
14
+ SQLite database you can query.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ uv add twlaw # or: pip install twlaw
20
+ ```
21
+
22
+ ## Getting started
23
+
24
+ Building the local database is a separate, explicit step. Do it once:
25
+
26
+ ```python
27
+ from twlaw import LawDB
28
+
29
+ db = LawDB() # opens ~/.twlaw/law.db (created if missing)
30
+ db.refresh() # downloads all 4 datasets — ~2 min, ~520 MB on disk
31
+ ```
32
+
33
+ After that, every query is local and fast (single-digit milliseconds). Nothing
34
+ touches the network again until you call `refresh()` yourself.
35
+
36
+ ```python
37
+ from twlaw import LawDB
38
+
39
+ db = LawDB() # reopens the existing database
40
+ if db.is_empty: # guard for first run
41
+ db.refresh()
42
+
43
+ for hit in db.search("扣繳義務人", limit=5):
44
+ print(hit["law_name"], hit["article_no"])
45
+ print(" ", hit["chapter_path"])
46
+ ```
47
+
48
+ ```
49
+ 所得稅法 第 94 條
50
+ 第 四 章 稽徵程序 / 第 四 節 扣繳
51
+ ```
52
+
53
+ ## API
54
+
55
+ ### `LawDB(path=None)`
56
+
57
+ Opens (and creates if needed) the SQLite store. Defaults to `~/.twlaw/law.db`;
58
+ pass a path to keep it elsewhere. Usable as a context manager.
59
+
60
+ ```python
61
+ with LawDB("./law.db") as db:
62
+ ...
63
+ ```
64
+
65
+ ### `db.refresh(categories=("law", "order"), langs=("zh", "en"))`
66
+
67
+ Re-downloads datasets and replaces their rows. Takes ~2 minutes for everything.
68
+ Returns how many laws were stored per dataset. Narrow the scope if you only
69
+ need part of it:
70
+
71
+ ```python
72
+ db.refresh(categories=("law",), langs=("zh",)) # Chinese statutes only, ~20 s
73
+ ```
74
+
75
+ Safe to re-run — rows are replaced, not duplicated.
76
+
77
+ ### `db.search(query, lang="zh", category=None, limit=50, include_repealed=False)`
78
+
79
+ Full-text search over article text. Returns a list of dicts, best match first:
80
+
81
+ | key | meaning |
82
+ | --- | --- |
83
+ | `law_id` | law code, e.g. `G0340003` |
84
+ | `law_name` | e.g. `所得稅法` |
85
+ | `category` | `law` or `order` |
86
+ | `article_no` | e.g. `第 94 條` |
87
+ | `article_key` | citable number, e.g. `94` or `4-1` |
88
+ | `content` | the article text |
89
+ | `chapter_path` | reconstructed hierarchy |
90
+ | `seq` | article position within the law |
91
+
92
+ ```python
93
+ db.search("營業稅", category="law") # statutes only, skip 命令
94
+ db.search("income tax", lang="en") # English corpus
95
+ db.search("設籍", include_repealed=True) # include (刪除) articles
96
+ ```
97
+
98
+ ### `db.get_law(law, lang="zh")`
99
+
100
+ One law with all its articles, or `None` if not found. Takes a law **name** or
101
+ a MOJ law code — use whichever you have. Returns the metadata fields plus an
102
+ `articles` list of `{seq, article_no, content, chapter_path}`.
103
+
104
+ ```python
105
+ law = db.get_law("所得稅法") # by name
106
+ law = db.get_law("G0340003") # same law, by code
107
+
108
+ law["name"] # 所得稅法
109
+ law["id"] # G0340003
110
+ law["modified_date"] # 20260911
111
+ len(law["articles"]) # 198
112
+ ```
113
+
114
+ Names match exactly, not by prefix — `"所得稅法"` gives you 所得稅法, never
115
+ 所得稅法施行細則. Use `list_laws(name_like=...)` or `search()` when you don't
116
+ know the exact name.
117
+
118
+ ### `db.get_article(law, article, lang="zh")`
119
+
120
+ One article by the number you'd cite it by, or `None` if there is no such
121
+ article. `4` is 第 4 條 and `"4-1"` is 第 4 條之一 — you never deal with list
122
+ positions or MOJ's label strings.
123
+
124
+ ```python
125
+ db.get_article("所得稅法", 1) # 第 1 條
126
+ db.get_article("所得稅法", "4-1") # 第 4 條之一 — a distinct article from 第 4 條
127
+ ```
128
+
129
+ ```python
130
+ {'seq': 8, 'article_no': '第 4-1 條', 'article_key': '4-1',
131
+ 'content': '自中華民國七十九年一月一日起,證券交易所得停止課徵所得稅…',
132
+ 'chapter_path': '第 一 章 總則 / 第 一 節 一般規定',
133
+ 'law_id': 'G0340003', 'law_name': '所得稅法'}
134
+ ```
135
+
136
+ Every article everywhere carries this `article_key`, so a `search()` hit can be
137
+ re-fetched or cited directly:
138
+
139
+ ```python
140
+ hit = db.search("扣繳義務人")[0]
141
+ db.get_article(hit["law_name"], hit["article_key"])
142
+ ```
143
+
144
+ ### `db.list_laws(category=None, lang="zh", name_like=None)`
145
+
146
+ Law metadata without article bodies — for browsing or building a picker.
147
+
148
+ ```python
149
+ db.list_laws(name_like="所得稅")
150
+ db.list_laws(category="law")
151
+ ```
152
+
153
+ ### `db.update_date()` / `db.is_empty`
154
+
155
+ MOJ's own publication date for the stored data, and whether anything is stored
156
+ yet. Use `update_date()` to decide if a `refresh()` is worthwhile.
157
+
158
+ ## Examples
159
+
160
+ Look up a single article:
161
+
162
+ ```python
163
+ print(db.get_article("所得稅法", "4-1")["content"])
164
+ ```
165
+
166
+ Read one law article by article:
167
+
168
+ ```python
169
+ law = db.get_law("所得稅法")
170
+
171
+ for a in law["articles"]:
172
+ print(a["article_no"], a["chapter_path"])
173
+ print(a["content"])
174
+ ```
175
+
176
+ Find which laws mention a term:
177
+
178
+ ```python
179
+ for hit in db.search("營業稅", limit=10):
180
+ print(hit["law_name"], hit["article_no"])
181
+ ```
182
+
183
+ List every tax law, using MOJ's own classification:
184
+
185
+ ```python
186
+ for l in db.list_laws():
187
+ if "賦稅" in l["moj_category"]:
188
+ print(l["id"], l["name"])
189
+ ```
190
+
191
+ ### Chunking for RAG
192
+
193
+ Each article already carries its chapter path, so a chunk is interpretable on
194
+ its own without extra work:
195
+
196
+ ```python
197
+ law = db.get_law("所得稅法")
198
+
199
+ for a in law["articles"]:
200
+ chunk = f"{law['name']} {a['article_no']}\n{a['chapter_path']}\n{a['content']}"
201
+ print(chunk)
202
+ ```
203
+
204
+ ## Coverage
205
+
206
+ | dataset | records |
207
+ | --- | --- |
208
+ | laws, Chinese | 1,347 |
209
+ | orders, Chinese | 10,451 |
210
+ | laws, English | 972 |
211
+ | orders, English | 2,206 |
212
+
213
+ English records are a separate, smaller corpus — not a translation of every
214
+ Chinese record. A law present in both shares the same `law_id`, so
215
+ `get_law(id, lang="en")` gives you the English text of the same statute.
216
+
217
+ ## Notes
218
+
219
+ - **Concurrency.** WAL mode is on, so many processes can read at once. Only
220
+ `refresh()` writes.
221
+ - **Search.** FTS5 with the `trigram` tokenizer, which is what makes Chinese
222
+ substring search work: the default `unicode61` treats an unbroken run of CJK
223
+ as one token, which would find 50 articles for 所得稅 instead of ~1,400.
224
+ Queries under 3 characters fall back to `LIKE`.
225
+ - **Storage.** Plain SQLite tables (`laws`, `articles`), so the file is readable
226
+ from any language, not only Python.
227
+
228
+ ## Data source and licensing
229
+
230
+ `twlaw` itself is MIT licensed, and the package ships **no legal data**. Laws
231
+ and regulations are downloaded at runtime by `refresh()` from the
232
+ [MOJ Open API](https://law.moj.gov.tw/api/swagger/index.html).
233
+
234
+ That data is published by the Ministry of Justice under the
235
+ [Open Government Data License v1.0](https://data.gov.tw/license)
236
+ (政府資料開放授權條款-第1版), which permits free reuse, modification and
237
+ redistribution — **provided you attribute the source**. If you ship a product
238
+ built on this data, credit it, for example:
239
+
240
+ > Data source: Laws & Regulations Database of the Republic of China (Taiwan),
241
+ > Ministry of Justice — https://law.moj.gov.tw/
242
+
243
+ Two things worth knowing if you build on this:
244
+
245
+ - **The MOJ database is authoritative; this is a convenience mirror.** `twlaw`
246
+ reconstructs chapter hierarchy and splits articles, which is ordinary
247
+ permitted adaptation — but the result is a *parsed* view. Cite the official
248
+ text for anything that matters.
249
+ - **Don't misrepresent the content.** The license asks that the data not be
250
+ altered in ways that make the displayed information contradict the original.
251
+
252
+ This section is a summary, not legal advice. Read the license if the
253
+ distinction matters to you.
@@ -0,0 +1,54 @@
1
+ [project]
2
+ name = "twlaw"
3
+ version = "0.1.0"
4
+ description = "Queryable local access to Taiwan's national laws and regulations database"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ keywords = [
10
+ "taiwan",
11
+ "law",
12
+ "legal",
13
+ "regulations",
14
+ "sqlite",
15
+ "全國法規資料庫",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Intended Audience :: Developers",
20
+ "Intended Audience :: Legal Industry",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Topic :: Database",
27
+ "Topic :: Text Processing :: Indexing",
28
+ "Natural Language :: Chinese (Traditional)",
29
+ "Natural Language :: English",
30
+ ]
31
+ dependencies = [
32
+ "requests>=2.34.2",
33
+ "truststore>=0.10.4",
34
+ ]
35
+
36
+ [[project.authors]]
37
+ name = "Yihsuan Chen"
38
+ email = "yhc0712.tw@gmail.com"
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/yhc0712/twlaw"
42
+ Repository = "https://github.com/yhc0712/twlaw"
43
+ Issues = "https://github.com/yhc0712/twlaw/issues"
44
+
45
+ [build-system]
46
+ requires = ["uv_build>=0.12.10,<0.13.0"]
47
+ build-backend = "uv_build"
48
+
49
+ [dependency-groups]
50
+ dev = ["pytest>=8"]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
54
+ pythonpath = ["tests"]
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "twlaw"
3
+ version = "0.1.0"
4
+ description = "Queryable local access to Taiwan's national laws and regulations database"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Yihsuan Chen", email = "yhc0712.tw@gmail.com" }]
10
+ keywords = ["taiwan", "law", "legal", "regulations", "sqlite", "全國法規資料庫"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "Intended Audience :: Legal Industry",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Programming Language :: Python :: 3.14",
20
+ "Topic :: Database",
21
+ "Topic :: Text Processing :: Indexing",
22
+ "Natural Language :: Chinese (Traditional)",
23
+ "Natural Language :: English",
24
+ ]
25
+ dependencies = [
26
+ "requests>=2.34.2",
27
+ "truststore>=0.10.4",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/yhc0712/twlaw"
32
+ Repository = "https://github.com/yhc0712/twlaw"
33
+ Issues = "https://github.com/yhc0712/twlaw/issues"
34
+
35
+ [build-system]
36
+ requires = ["uv_build>=0.12.10,<0.13.0"]
37
+ build-backend = "uv_build"
38
+
39
+ [dependency-groups]
40
+ dev = ["pytest>=8"]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
44
+ pythonpath = ["tests"]
@@ -0,0 +1,15 @@
1
+ """Queryable local access to Taiwan's national laws and regulations database.
2
+
3
+ from twlaw import LawDB
4
+
5
+ db = LawDB()
6
+ db.refresh() # download and build the local store
7
+ db.search("所得稅") # full-text search across articles
8
+ db.get_law("所得稅法") # one law with chapter-aware articles
9
+ db.get_article("所得稅法", "4-1") # 第 4 條之一
10
+ """
11
+
12
+ from .db import DEFAULT_PATH, SCHEMA_VERSION, LawDB
13
+ from .fetch import CATEGORIES, LANGS
14
+
15
+ __all__ = ["LawDB", "DEFAULT_PATH", "SCHEMA_VERSION", "CATEGORIES", "LANGS"]
@@ -0,0 +1,319 @@
1
+ """Local SQLite store and query API."""
2
+
3
+ import sqlite3
4
+ from pathlib import Path
5
+
6
+ from .fetch import CATEGORIES, LANGS, fetch_dataset
7
+ from .parse import iter_rows
8
+
9
+ DEFAULT_PATH = Path.home() / ".twlaw" / "law.db"
10
+
11
+ # Bump whenever the table layout changes. A database built by an older version
12
+ # is discarded and rebuilt rather than migrated: it is a cache of an upstream
13
+ # dataset, so re-downloading is simpler and cheaper than writing migrations.
14
+ SCHEMA_VERSION = 1
15
+
16
+ _SCHEMA = """
17
+ CREATE TABLE IF NOT EXISTS laws (
18
+ id TEXT NOT NULL,
19
+ lang TEXT NOT NULL,
20
+ category TEXT NOT NULL,
21
+ name TEXT NOT NULL,
22
+ name_en TEXT,
23
+ level TEXT,
24
+ moj_category TEXT,
25
+ modified_date TEXT,
26
+ effective_date TEXT,
27
+ effective_note TEXT,
28
+ abandon_note TEXT,
29
+ foreword TEXT,
30
+ histories TEXT,
31
+ url TEXT,
32
+ update_date TEXT,
33
+ PRIMARY KEY (id, lang)
34
+ );
35
+
36
+ CREATE TABLE IF NOT EXISTS articles (
37
+ rowid INTEGER PRIMARY KEY,
38
+ law_id TEXT NOT NULL,
39
+ lang TEXT NOT NULL,
40
+ seq INTEGER NOT NULL,
41
+ article_no TEXT,
42
+ article_key TEXT,
43
+ content TEXT,
44
+ chapter_path TEXT,
45
+ UNIQUE (law_id, lang, seq)
46
+ );
47
+
48
+ CREATE INDEX IF NOT EXISTS idx_articles_key ON articles(law_id, lang, article_key);
49
+
50
+ CREATE INDEX IF NOT EXISTS idx_laws_name ON laws(name);
51
+ CREATE INDEX IF NOT EXISTS idx_laws_category ON laws(category, lang);
52
+
53
+ -- 'trigram' rather than the default tokenizer: unicode61 treats an unbroken
54
+ -- run of CJK as a single token, so searching 所得稅 would miss every article
55
+ -- where it sits mid-phrase (50 hits instead of ~1400). Trigram indexes
56
+ -- 3-character windows, which matches Chinese substrings correctly. Its
57
+ -- tradeoff is that queries shorter than 3 characters never match, so search()
58
+ -- falls back to LIKE for those.
59
+ -- content='articles' makes this an external-content index: FTS stores only the
60
+ -- index, not a second copy of the text (which cost ~180MB).
61
+ CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
62
+ content,
63
+ chapter_path,
64
+ content = 'articles',
65
+ content_rowid = 'rowid',
66
+ tokenize = 'trigram'
67
+ );
68
+ """
69
+
70
+ _FTS_MIN_QUERY = 3
71
+
72
+ _LAW_COLUMNS = (
73
+ "id", "lang", "category", "name", "name_en", "level", "moj_category",
74
+ "modified_date", "effective_date", "effective_note", "abandon_note",
75
+ "foreword", "histories", "url", "update_date",
76
+ )
77
+
78
+
79
+ class LawDB:
80
+ """Query interface over a local copy of the MOJ law database."""
81
+
82
+ def __init__(self, path: str | Path | None = None):
83
+ self.path = Path(path) if path else DEFAULT_PATH
84
+ self.path.parent.mkdir(parents=True, exist_ok=True)
85
+ self._connect()
86
+
87
+ if self._stored_version() not in (0, SCHEMA_VERSION):
88
+ self._rebuild()
89
+ self._conn.executescript(_SCHEMA)
90
+ self._conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
91
+
92
+ def _stored_version(self) -> int:
93
+ """0 for a database this version can still use as-is, else its version."""
94
+ version = self._conn.execute("PRAGMA user_version").fetchone()[0]
95
+ if version:
96
+ return version
97
+ # Written before versioning existed; usable only if it already has the
98
+ # current columns.
99
+ columns = {r[1] for r in self._conn.execute("PRAGMA table_info(articles)")}
100
+ return 0 if not columns or "article_key" in columns else -1
101
+
102
+ def _connect(self) -> None:
103
+ self._conn = sqlite3.connect(self.path)
104
+ self._conn.row_factory = sqlite3.Row
105
+ self._conn.execute("PRAGMA journal_mode=WAL")
106
+
107
+ def _rebuild(self) -> None:
108
+ """Discard a database written by an incompatible version of twlaw.
109
+
110
+ Drops the objects rather than deleting the file: on Windows the file
111
+ cannot be unlinked while another process still has it open.
112
+ """
113
+ with self._conn:
114
+ for kind, name in self._conn.execute(
115
+ "SELECT type, name FROM sqlite_master"
116
+ " WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%'"
117
+ ).fetchall():
118
+ self._conn.execute(f'DROP {kind} IF EXISTS "{name}"')
119
+
120
+ def close(self) -> None:
121
+ self._conn.close()
122
+
123
+ def __enter__(self):
124
+ return self
125
+
126
+ def __exit__(self, *exc) -> None:
127
+ self.close()
128
+
129
+ @property
130
+ def is_empty(self) -> bool:
131
+ return self._conn.execute("SELECT COUNT(*) FROM laws").fetchone()[0] == 0
132
+
133
+ def update_date(self, category: str = "law", lang: str = "zh") -> str | None:
134
+ row = self._conn.execute(
135
+ "SELECT update_date FROM laws WHERE category=? AND lang=? LIMIT 1",
136
+ (category, lang),
137
+ ).fetchone()
138
+ return row[0] if row else None
139
+
140
+ def refresh(self, categories=CATEGORIES, langs=LANGS) -> dict[tuple[str, str], int]:
141
+ """Re-download the given datasets and replace their rows.
142
+
143
+ Returns the number of laws stored per ``(category, lang)``.
144
+ """
145
+ counts = {}
146
+ for category in categories:
147
+ for lang in langs:
148
+ dataset = fetch_dataset(category, lang)
149
+ counts[(category, lang)] = self._replace(dataset, category, lang)
150
+ # Replacing rows leaves the FTS index in many small segments; merging
151
+ # them keeps the file from growing on every refresh.
152
+ with self._conn:
153
+ self._conn.execute("INSERT INTO articles_fts (articles_fts) VALUES ('optimize')")
154
+ self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
155
+ return counts
156
+
157
+ def _replace(self, dataset: dict, category: str, lang: str) -> int:
158
+ law_params = []
159
+ article_params = []
160
+
161
+ for law_row, article_rows in iter_rows(dataset, category, lang):
162
+ law_params.append(tuple(law_row[c] for c in _LAW_COLUMNS))
163
+ for a in article_rows:
164
+ article_params.append(
165
+ (
166
+ a["law_id"], lang, a["seq"], a["article_no"],
167
+ a["article_key"], a["content"], a["chapter_path"],
168
+ )
169
+ )
170
+
171
+ placeholders = ",".join("?" * len(_LAW_COLUMNS))
172
+ with self._conn:
173
+ # The FTS index mirrors `articles` by rowid, so drop its entries for
174
+ # the rows being replaced before those rows disappear.
175
+ self._conn.execute(
176
+ "INSERT INTO articles_fts (articles_fts, rowid, content, chapter_path)"
177
+ " SELECT 'delete', a.rowid, a.content, a.chapter_path FROM articles a"
178
+ " JOIN laws l ON l.id = a.law_id AND l.lang = a.lang"
179
+ " WHERE l.category = ? AND l.lang = ?",
180
+ (category, lang),
181
+ )
182
+ self._conn.execute(
183
+ "DELETE FROM articles WHERE lang = ? AND law_id IN"
184
+ " (SELECT id FROM laws WHERE category = ? AND lang = ?)",
185
+ (lang, category, lang),
186
+ )
187
+ self._conn.execute("DELETE FROM laws WHERE category=? AND lang=?", (category, lang))
188
+
189
+ self._conn.executemany(f"INSERT INTO laws VALUES ({placeholders})", law_params)
190
+ self._conn.executemany(
191
+ "INSERT INTO articles"
192
+ " (law_id, lang, seq, article_no, article_key, content, chapter_path)"
193
+ " VALUES (?,?,?,?,?,?,?)",
194
+ article_params,
195
+ )
196
+ self._conn.execute(
197
+ "INSERT INTO articles_fts (rowid, content, chapter_path)"
198
+ " SELECT a.rowid, a.content, a.chapter_path FROM articles a"
199
+ " JOIN laws l ON l.id = a.law_id AND l.lang = a.lang"
200
+ " WHERE l.category = ? AND l.lang = ?",
201
+ (category, lang),
202
+ )
203
+ return len(law_params)
204
+
205
+ def _ensure_data(self) -> None:
206
+ if self.is_empty:
207
+ raise RuntimeError("Local database is empty; call refresh() first.")
208
+
209
+ def list_laws(self, category: str | None = None, lang: str = "zh", name_like: str | None = None) -> list[dict]:
210
+ sql = "SELECT * FROM laws WHERE lang=?"
211
+ params: list = [lang]
212
+ if category:
213
+ sql += " AND category=?"
214
+ params.append(category)
215
+ if name_like:
216
+ sql += " AND name LIKE ?"
217
+ params.append(f"%{name_like}%")
218
+ sql += " ORDER BY name"
219
+ return [dict(r) for r in self._conn.execute(sql, params)]
220
+
221
+ def get_law(self, law: str, lang: str = "zh") -> dict | None:
222
+ """Return one law with its articles, each carrying its chapter path.
223
+
224
+ ``law`` may be a law name (``"所得稅法"``) or a MOJ law code
225
+ (``"G0340003"``). Names are matched exactly, never by prefix, because
226
+ 法規 names nest — 所得稅法 is a prefix of 所得稅法施行細則.
227
+ """
228
+ self._ensure_data()
229
+ row = self._conn.execute(
230
+ "SELECT * FROM laws WHERE lang=? AND (id=? OR name=? OR name_en=?)",
231
+ (lang, law, law, law),
232
+ ).fetchone()
233
+ if row is None:
234
+ return None
235
+ result = dict(row)
236
+ result["articles"] = [
237
+ dict(r)
238
+ for r in self._conn.execute(
239
+ "SELECT seq, article_no, article_key, content, chapter_path FROM articles"
240
+ " WHERE law_id=? AND lang=? ORDER BY seq",
241
+ (result["id"], lang),
242
+ )
243
+ ]
244
+ return result
245
+
246
+ def get_article(self, law: str, article: str | int, lang: str = "zh") -> dict | None:
247
+ """Return one article by its number, or ``None`` if there is no such article.
248
+
249
+ ``article`` is the number as it is cited: ``4`` or ``"4"`` for 第 4 條,
250
+ ``"4-1"`` for 第 4 條之一. ``law`` accepts a name or a law code, as in
251
+ :meth:`get_law`.
252
+ """
253
+ self._ensure_data()
254
+ row = self._conn.execute(
255
+ "SELECT a.seq, a.article_no, a.article_key, a.content, a.chapter_path,"
256
+ " l.id AS law_id, l.name AS law_name"
257
+ " FROM articles a"
258
+ " JOIN laws l ON l.id = a.law_id AND l.lang = a.lang"
259
+ " WHERE a.lang = ? AND a.article_key = ?"
260
+ " AND (l.id = ? OR l.name = ? OR l.name_en = ?)",
261
+ (lang, str(article).strip(), law, law, law),
262
+ ).fetchone()
263
+ return dict(row) if row else None
264
+
265
+ def search(
266
+ self,
267
+ query: str,
268
+ lang: str = "zh",
269
+ category: str | None = None,
270
+ limit: int = 50,
271
+ include_repealed: bool = False,
272
+ ) -> list[dict]:
273
+ """Full-text search over article content.
274
+
275
+ Returns article rows annotated with their law's id and name, best match
276
+ first. ``chapter_path`` is searchable but weighted far below ``content``
277
+ so that matching a chapter title alone does not outrank a real hit.
278
+ Repealed articles (``(刪除)`` / "(Deleted)") are excluded by default.
279
+ """
280
+ self._ensure_data()
281
+ query = query.strip()
282
+ if not query:
283
+ return []
284
+
285
+ select = (
286
+ "SELECT a.law_id, l.name AS law_name, l.category, a.seq,"
287
+ " a.article_no, a.article_key, a.content, a.chapter_path"
288
+ )
289
+ params: list = []
290
+
291
+ if len(query) >= _FTS_MIN_QUERY:
292
+ sql = (
293
+ f"{select}"
294
+ " FROM articles_fts f"
295
+ " JOIN articles a ON a.rowid = f.rowid"
296
+ " JOIN laws l ON l.id = a.law_id AND l.lang = a.lang"
297
+ " WHERE articles_fts MATCH ? AND a.lang = ?"
298
+ )
299
+ params += [f'"{query}"', lang]
300
+ order = " ORDER BY bm25(articles_fts, 10.0, 1.0)"
301
+ else:
302
+ # Trigram FTS cannot match queries this short.
303
+ sql = (
304
+ f"{select}"
305
+ " FROM articles a"
306
+ " JOIN laws l ON l.id = a.law_id AND l.lang = a.lang"
307
+ " WHERE a.lang = ? AND a.content LIKE ?"
308
+ )
309
+ params += [lang, f"%{query}%"]
310
+ order = " ORDER BY a.law_id, a.seq"
311
+
312
+ if category:
313
+ sql += " AND l.category = ?"
314
+ params.append(category)
315
+ if not include_repealed:
316
+ sql += " AND a.content NOT LIKE '%刪除%' AND a.content NOT LIKE '%(Deleted)%'"
317
+
318
+ params.append(limit)
319
+ return [dict(r) for r in self._conn.execute(sql + order + " LIMIT ?", params)]
@@ -0,0 +1,40 @@
1
+ """Download raw law datasets from the MOJ Open API."""
2
+
3
+ import io
4
+ import json
5
+ import zipfile
6
+
7
+ import truststore
8
+
9
+ truststore.inject_into_ssl()
10
+
11
+ import requests
12
+
13
+ BASE_URL = "https://law.moj.gov.tw/api"
14
+
15
+ LANGS = ("zh", "en")
16
+ CATEGORIES = ("law", "order")
17
+
18
+ _LANG_PATH = {"zh": "Ch", "en": "En"}
19
+ _CATEGORY_PATH = {"law": "Law", "order": "Order"}
20
+
21
+
22
+ def fetch_dataset(category: str, lang: str, timeout: int = 120) -> dict:
23
+ """Return the decoded dataset for one (category, lang) pair.
24
+
25
+ The endpoint serves a zip archive; the payload is the single .json member.
26
+ """
27
+ if lang not in LANGS:
28
+ raise ValueError(f"lang must be one of {LANGS}, got {lang!r}")
29
+ if category not in CATEGORIES:
30
+ raise ValueError(f"category must be one of {CATEGORIES}, got {category!r}")
31
+
32
+ url = f"{BASE_URL}/{_LANG_PATH[lang]}/{_CATEGORY_PATH[category]}/JSON"
33
+ resp = requests.get(url, timeout=timeout)
34
+ resp.raise_for_status()
35
+
36
+ with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
37
+ name = next(n for n in zf.namelist() if n.lower().endswith(".json"))
38
+ with zf.open(name) as fh:
39
+ # The MOJ files carry a UTF-8 BOM.
40
+ return json.loads(fh.read().decode("utf-8-sig"))
@@ -0,0 +1,124 @@
1
+ """Normalize MOJ records into flat law/article rows.
2
+
3
+ MOJ interleaves structural headings and real articles in one article list:
4
+ ``ArticleType == "C"`` rows are headings whose leading indentation encodes depth
5
+ (0=編, 3=章, 6=節, 9=款, 12=目), while ``ArticleType == "A"`` rows are articles
6
+ carrying no hierarchy of their own. Each article's position in the hierarchy is
7
+ therefore implicit, and is resolved here by walking the list and tracking the
8
+ heading stack.
9
+
10
+ The English datasets use the same layout but prefix every field name with
11
+ ``Eng`` and omit the category/effective-date fields, so field access goes
12
+ through a per-language field map.
13
+ """
14
+
15
+ import re
16
+
17
+ _PCODE_RE = re.compile(r"pcode=([A-Za-z0-9]+)", re.IGNORECASE)
18
+ _INDENT_RE = re.compile(r"^(\s*)")
19
+ _INDENT_UNIT = 3
20
+
21
+ # "第 4-1 條" / "Article 4-1" -> "4-1". Both languages also use a bare number
22
+ # for a handful of appendix-style entries.
23
+ _ARTICLE_NO_RE = re.compile(r"(\d+(?:-\d+)?)")
24
+
25
+
26
+ def article_key(article_no: str) -> str:
27
+ """Return the citable number in an article label, e.g. ``第 4-1 條`` -> ``4-1``."""
28
+ match = _ARTICLE_NO_RE.search(article_no or "")
29
+ return match.group(1) if match else ""
30
+
31
+ # The two language variants name the same fields differently.
32
+ _FIELDS = {
33
+ "zh": {
34
+ "url": "LawURL",
35
+ "modified_date": "LawModifiedDate",
36
+ "abandon_note": "LawAbandonNote",
37
+ "foreword": "LawForeword",
38
+ "histories": "LawHistories",
39
+ "articles": "LawArticles",
40
+ "article_type": "ArticleType",
41
+ "article_no": "ArticleNo",
42
+ "article_content": "ArticleContent",
43
+ },
44
+ "en": {
45
+ "url": "EngLawURL",
46
+ "modified_date": "EngLawModifiedDate",
47
+ "abandon_note": "EngLawAbandonNote",
48
+ "foreword": "EngLawForeword",
49
+ "histories": "EngLawHistories",
50
+ "articles": "EngLawArticles",
51
+ "article_type": "EngArticleType",
52
+ "article_no": "EngArticleNo",
53
+ "article_content": "EngArticleContent",
54
+ },
55
+ }
56
+
57
+
58
+ def _pcode(law: dict, url_field: str) -> str | None:
59
+ match = _PCODE_RE.search(law.get(url_field, "") or "")
60
+ return match.group(1) if match else None
61
+
62
+
63
+ def _heading_depth(content: str) -> int:
64
+ return len(_INDENT_RE.match(content).group(1)) // _INDENT_UNIT
65
+
66
+
67
+ def iter_rows(dataset: dict, category: str, lang: str):
68
+ """Yield ``(law_row, article_rows)`` pairs for one fetched dataset."""
69
+ update_date = dataset.get("UpdateDate", "")
70
+ f = _FIELDS[lang]
71
+
72
+ for law in dataset.get("Laws", []):
73
+ law_id = _pcode(law, f["url"])
74
+ if law_id is None:
75
+ continue
76
+
77
+ law_row = {
78
+ "id": law_id,
79
+ "lang": lang,
80
+ "category": category,
81
+ "name": law.get("LawName", ""),
82
+ "name_en": law.get("EngLawName", ""),
83
+ "level": law.get("LawLevel", ""),
84
+ "moj_category": law.get("LawCategory", ""),
85
+ "modified_date": law.get(f["modified_date"], ""),
86
+ "effective_date": law.get("LawEffectiveDate", ""),
87
+ "effective_note": law.get("LawEffectiveNote", ""),
88
+ "abandon_note": law.get(f["abandon_note"], ""),
89
+ "foreword": law.get(f["foreword"], ""),
90
+ "histories": law.get(f["histories"], ""),
91
+ "url": law.get(f["url"], ""),
92
+ "update_date": update_date,
93
+ }
94
+
95
+ article_rows = []
96
+ stack: list[str] = []
97
+ seq = 0
98
+
99
+ for entry in law.get(f["articles"]) or []:
100
+ content = entry.get(f["article_content"], "") or ""
101
+
102
+ if entry.get(f["article_type"]) == "C":
103
+ depth = _heading_depth(content)
104
+ del stack[depth:]
105
+ # Pad when a level is skipped so depth stays the index.
106
+ while len(stack) < depth:
107
+ stack.append("")
108
+ stack.append(content.strip())
109
+ continue
110
+
111
+ article_no = (entry.get(f["article_no"]) or "").strip()
112
+ article_rows.append(
113
+ {
114
+ "law_id": law_id,
115
+ "seq": seq,
116
+ "article_no": article_no,
117
+ "article_key": article_key(article_no),
118
+ "content": content,
119
+ "chapter_path": " / ".join(p for p in stack if p),
120
+ }
121
+ )
122
+ seq += 1
123
+
124
+ yield law_row, article_rows