moleg-api 0.2.0__tar.gz → 0.2.1__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.
Files changed (27) hide show
  1. {moleg_api-0.2.0 → moleg_api-0.2.1}/PKG-INFO +1 -1
  2. {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api/cli.py +30 -6
  3. {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api/laws.py +206 -4
  4. {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api/normalization.py +55 -28
  5. {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api.egg-info/PKG-INFO +1 -1
  6. {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api.egg-info/SOURCES.txt +1 -0
  7. {moleg_api-0.2.0 → moleg_api-0.2.1}/pyproject.toml +1 -1
  8. {moleg_api-0.2.0 → moleg_api-0.2.1}/tests/test_laws.py +8 -1
  9. {moleg_api-0.2.0 → moleg_api-0.2.1}/tests/test_live_smoke.py +65 -0
  10. moleg_api-0.2.1/tests/test_sdk_fixes_0_2_1.py +375 -0
  11. {moleg_api-0.2.0 → moleg_api-0.2.1}/LICENSE +0 -0
  12. {moleg_api-0.2.0 → moleg_api-0.2.1}/README.md +0 -0
  13. {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api/__init__.py +0 -0
  14. {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api/__main__.py +0 -0
  15. {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api/errors.py +0 -0
  16. {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api/models.py +0 -0
  17. {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api/py.typed +0 -0
  18. {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api/source.py +0 -0
  19. {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api.egg-info/dependency_links.txt +0 -0
  20. {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api.egg-info/entry_points.txt +0 -0
  21. {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api.egg-info/requires.txt +0 -0
  22. {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api.egg-info/top_level.txt +0 -0
  23. {moleg_api-0.2.0 → moleg_api-0.2.1}/setup.cfg +0 -0
  24. {moleg_api-0.2.0 → moleg_api-0.2.1}/tests/test_cli.py +0 -0
  25. {moleg_api-0.2.0 → moleg_api-0.2.1}/tests/test_models.py +0 -0
  26. {moleg_api-0.2.0 → moleg_api-0.2.1}/tests/test_source.py +0 -0
  27. {moleg_api-0.2.0 → moleg_api-0.2.1}/tests/test_versions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moleg-api
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Task-level Python interface for loading Korean legal sources from law.go.kr
5
5
  Author: MOLEG-API maintainers
6
6
  License-Expression: MIT
@@ -28,6 +28,7 @@ from __future__ import annotations
28
28
  import argparse
29
29
  import json
30
30
  import sys
31
+ from importlib.metadata import PackageNotFoundError, version as _dist_version
31
32
  from typing import Any
32
33
 
33
34
  from .errors import (
@@ -42,6 +43,14 @@ from .errors import (
42
43
  from .laws import MolegApi
43
44
  from .models import DeferredLookup
44
45
 
46
+
47
+ def _pkg_version() -> str:
48
+ try:
49
+ return _dist_version("moleg-api")
50
+ except PackageNotFoundError:
51
+ return "unknown"
52
+
53
+
45
54
  # Exit codes distinguish outcomes a shell/agent must branch on.
46
55
  EXIT_OK = 0 # includes a zero-hit search (ok:true, count:0)
47
56
  EXIT_AMBIGUOUS = 2 # multiple plausible identities — surface, don't pick
@@ -464,9 +473,19 @@ def signals_for(command: str, result: Any, args: argparse.Namespace) -> dict[str
464
473
  discipline.append("선택 조문의 전후 문구 델타만 — 개정이유·입법의도·전체 변경 조문 망라 아님. trace-law-history·국회 법안자료로 보강.")
465
474
 
466
475
  elif tname == "DelegationGraph":
467
- if not getattr(result, "rules", None):
468
- flags["count"] = 0
476
+ rules = getattr(result, "rules", None) or []
477
+ flags["count"] = len(rules)
478
+ if not rules:
469
479
  discipline.append("이 조회로 위임규정 못 찾음 ≠ 위임 없음 — 법령 체계도·다른 조문 범위·행정규칙·별표 경로를 더 확인.")
480
+ else:
481
+ discipline.append("위임 목록은 하위법령·인용법령 위임만 — 별표(과태료·범칙금액·기준표 등) 자체는 여기 없다. 금액·기준표는 위임된 시행령·시행규칙의 별표를 search-annex-forms(또는 load-delegated-criteria)로 확인.")
482
+ ident = getattr(result, "identity", None)
483
+ name = (getattr(ident, "name", None) or getattr(ident, "law_id", None) or "") if ident else ""
484
+ if name:
485
+ next_cmds.append({
486
+ "why": "위임된 별표·금액·기준표 확인",
487
+ "cmd": f"moleg search-annex-forms {json.dumps(name, ensure_ascii=False)} --source law",
488
+ })
470
489
 
471
490
  elif tname == "LawStructure":
472
491
  discipline.append("체계도는 계층 컨텍스트일 뿐 — 시행령·규칙·행정규칙이 이 법 아래 있음은 보이나, 조문 단위 위임·하위규칙 본문·운영기준 증명 아님. find-delegated-rules로 확인.")
@@ -632,6 +651,12 @@ def build_parser() -> argparse.ArgumentParser:
632
651
  description="법제처 law.go.kr 법령 자료 조회 CLI. 항상 JSON 엔벨로프 1개를 stdout으로 출력.",
633
652
  )
634
653
  parser.add_argument("--raw", action="store_true", help="원본 소스 payload(raw)까지 직렬화(디버그).")
654
+ parser.add_argument(
655
+ "--version",
656
+ action="version",
657
+ version=f"moleg-api {_pkg_version()}",
658
+ help="설치된 moleg-api 버전을 출력하고 종료.",
659
+ )
635
660
  sub = parser.add_subparsers(dest="command", metavar="<command>")
636
661
 
637
662
  sub.add_parser("catalog", help="27개 서브커맨드·규약·kind 목록을 한 번에.")
@@ -740,9 +765,8 @@ def build_parser() -> argparse.ArgumentParser:
740
765
  p.add_argument("--date-from", dest="date_from", default=None)
741
766
  p.add_argument("--date-to", dest="date_to", default=None)
742
767
 
743
- p = sub.add_parser("compare-law-versions", help="개정 전후 조문 문구 비교.")
744
- _add_law(p); p.add_argument("--before", default=None); p.add_argument("--after", default=None)
745
- p.add_argument("--article", default=None)
768
+ p = sub.add_parser("compare-law-versions", help="개정 전후 조문 문구 비교(소스 제공 전후 쌍).")
769
+ _add_law(p); p.add_argument("--article", default=None)
746
770
 
747
771
  p = sub.add_parser("find-delegated-rules", help="위임 규정·하위 법령.")
748
772
  _add_law(p); p.add_argument("--article", default=None)
@@ -834,7 +858,7 @@ def _call(api: MolegApi, args: argparse.Namespace) -> Any:
834
858
  date_range = (args.date_from, args.date_to) if (args.date_from and args.date_to) else None
835
859
  return api.trace_law_history(args.law, date_range=date_range, article=args.article)
836
860
  if c == "compare-law-versions":
837
- return api.compare_law_versions(args.law, before=args.before, after=args.after, article=args.article)
861
+ return api.compare_law_versions(args.law, article=args.article)
838
862
  if c == "find-delegated-rules":
839
863
  return api.find_delegated_rules(args.law, article=args.article)
840
864
  if c == "get-law-structure":
@@ -59,6 +59,7 @@ from .models import (
59
59
  LawHistory,
60
60
  LawIdentity,
61
61
  LawStructure,
62
+ LawStructureNode,
62
63
  LawText,
63
64
  StructuredTableData,
64
65
  )
@@ -249,6 +250,9 @@ INTERPRETATION_SOURCE_VALUES = ("moleg", "ministry", "all", "all_ministries")
249
250
  COURT_VALUES = ("all", "supreme", "lower")
250
251
  BUNDLE_MODE_VALUES = ("question", "promulgated_bill", "statute_review")
251
252
  BUNDLE_BUDGET_VALUES = ("minimal", "standard", "broad")
253
+ # 헌재 사건번호 shape, e.g. 2005헌마1139 / 2015헌바9 (digits + 헌 + class letter(s)
254
+ # + digits) — distinct from the pure-digit 헌재결정례일련번호 detail id.
255
+ _CONSTITUTIONAL_CASE_NUMBER_RE = re.compile(r"^\d+헌[가-힣]+\d+$")
252
256
 
253
257
 
254
258
  @dataclass(frozen=True)
@@ -951,6 +955,7 @@ class MolegApi:
951
955
  `resolve_promulgated_law` before history when starting from a bill.
952
956
  """
953
957
  identity = identity_from_identifier(law_identifier, basis="effective")
958
+ identity = self._resolve_identity_name(identity)
954
959
  bill_id_map = normalize_history_bill_id_map(promulgation_bridge)
955
960
  if article is not None:
956
961
  params = identity_params(identity, as_of=None, basis="effective")
@@ -1017,10 +1022,7 @@ class MolegApi:
1017
1022
  # The lsHistory list search matches on the law *name*; a bare law_id
1018
1023
  # identity carries the id as its name, which returns a "no results"
1019
1024
  # table and a parse failure. Recover the real name first.
1020
- if not identity.name or str(identity.name).isdigit():
1021
- resolved_name = self._law_name_for(identity)
1022
- if resolved_name:
1023
- identity = replace(identity, name=resolved_name)
1025
+ identity = self._resolve_identity_name(identity)
1024
1026
  rows: list[dict[str, Any]] = []
1025
1027
  page = 1
1026
1028
  pages_seen = 0
@@ -1118,6 +1120,15 @@ class MolegApi:
1118
1120
  return str(name)
1119
1121
  return None
1120
1122
 
1123
+ def _resolve_identity_name(self, identity: LawIdentity) -> LawIdentity:
1124
+ """Backfill the real statute name when an identity still carries a bare
1125
+ law_id as its name (degrades to the input unchanged on lookup failure)."""
1126
+ if not identity.name or str(identity.name).isdigit():
1127
+ resolved_name = self._law_name_for(identity)
1128
+ if resolved_name:
1129
+ return replace(identity, name=resolved_name)
1130
+ return identity
1131
+
1121
1132
  def _load_versioned_law_raw(self, identity: LawIdentity, mst: str) -> dict[str, Any]:
1122
1133
  """Load one statute version's raw payload by MST (promulgated detail)."""
1123
1134
  versioned = replace(identity, mst=mst)
@@ -1212,6 +1223,7 @@ class MolegApi:
1212
1223
  payload = self.source.service("lsDelegated", params)
1213
1224
  raw_delegation = unwrap_service_payload(payload, "lsDelegated")
1214
1225
  root_identity = maybe_identity(raw_delegation.get("법령정보"), basis="effective") or identity
1226
+ root_identity = self._resolve_identity_name(root_identity)
1215
1227
  rules = normalize_delegated_rules(raw_delegation)
1216
1228
  if article is not None:
1217
1229
  wanted = article_label_for_filter(article)
@@ -1385,6 +1397,10 @@ class MolegApi:
1385
1397
  )
1386
1398
  if not text.strip():
1387
1399
  raise NoResultError("No annex/form body text returned")
1400
+ # A bare-id identity keeps the numeric id as its title (and no
1401
+ # related_name/annex_type); recover the authoritative label from the
1402
+ # body header before deciding on structuring or returning the identity.
1403
+ identity = enrich_annex_identity_from_body(identity, text)
1388
1404
  structured_data = (
1389
1405
  structure_annex_form_text(text, identity)
1390
1406
  if attempt_structuring and annex_form_is_table_like(identity)
@@ -1896,6 +1912,19 @@ class MolegApi:
1896
1912
  Related: call `search_constitutional_decisions` first; use `get_case`
1897
1913
  for ordinary judicial decisions.
1898
1914
  """
1915
+ # The detail endpoint keys on the internal 헌재결정례일련번호 (a pure-digit
1916
+ # serial), a different system from the 사건번호 (e.g. 2005헌마1139). When a
1917
+ # 사건번호 is passed, resolve it to the serial via search first.
1918
+ if isinstance(identifier, str) and _CONSTITUTIONAL_CASE_NUMBER_RE.match(identifier.strip()):
1919
+ case_number = identifier.strip()
1920
+ resolved = self.search_constitutional_decisions(
1921
+ case_number, case_number=case_number, display=1
1922
+ )
1923
+ if not resolved:
1924
+ raise NoResultError(
1925
+ f"No Constitutional Court decision found for case number {case_number!r}"
1926
+ )
1927
+ identifier = resolved[0].identity
1899
1928
  identity_hint = judicial_decision_identity_from_identifier(
1900
1929
  identifier,
1901
1930
  source_type="constitutional",
@@ -2912,6 +2941,49 @@ class MolegApi:
2912
2941
  annex_form_candidates = list(bundle.candidates.annex_forms)
2913
2942
  delegated_scope = delegated_criteria_target_scope(bundle)
2914
2943
 
2944
+ # Criteria/amount 별표 live inside the delegated 시행령·시행규칙, not the
2945
+ # parent Act, and law.go.kr's lsDelegated never surfaces 별표 pointers. On
2946
+ # the bare-anchor path (no --query) the inherited discovery searches only
2947
+ # the parent law's own name and finds nothing, so reach the subordinate
2948
+ # legislation's annexes directly by their resolved names. With an explicit
2949
+ # query the block below already drives annex discovery by that query.
2950
+ delegated_rule_names = (
2951
+ delegated_subordinate_rule_names(bundle, delegated_scope)
2952
+ if not explicit_query
2953
+ else []
2954
+ )
2955
+ if delegated_rule_names:
2956
+ subordinate_annex_limit = candidate_limits["annex_forms"]
2957
+ # Restrict to 별표 (criteria/amount tables); 서식·별지 forms are not
2958
+ # operational criteria and would otherwise crowd out the penalty/fine
2959
+ # tables the anchor articles delegate.
2960
+ delegated_annex_candidates = dedupe_candidates(
2961
+ [
2962
+ hit
2963
+ for rule_name in delegated_rule_names
2964
+ for hit in safe_list(
2965
+ lambda rule_name=rule_name: self.search_annex_forms(
2966
+ rule_name,
2967
+ source="law",
2968
+ search_scope="source",
2969
+ annex_type="별표",
2970
+ display=subordinate_annex_limit,
2971
+ ),
2972
+ source_notes,
2973
+ f"Delegated-criteria subordinate-rule annex/form search for {rule_name}",
2974
+ gaps=gaps,
2975
+ deferred=deferred,
2976
+ query=rule_name,
2977
+ recommended_interface="search_annex_forms",
2978
+ source_type="annex_form",
2979
+ filters={"source": "law", "search_scope": "source", "annex_type": "별표"},
2980
+ )
2981
+ ]
2982
+ )
2983
+ annex_form_candidates = dedupe_candidates(
2984
+ [*delegated_annex_candidates, *annex_form_candidates]
2985
+ )[:subordinate_annex_limit]
2986
+
2915
2987
  if explicit_query:
2916
2988
  query_administrative_candidates = dedupe_candidates(
2917
2989
  [
@@ -3084,6 +3156,12 @@ class MolegApi:
3084
3156
  )
3085
3157
  )
3086
3158
 
3159
+ # websearch_required is a generic "no first-party path succeeded" prompt
3160
+ # inherited from load_institutional_system. When the delegated annex
3161
+ # search did surface first-party 별표, that prompt is misleading — drop it.
3162
+ if loaded_annex_forms or annex_form_candidates:
3163
+ gaps = [gap for gap in gaps if getattr(gap, "kind", None) != "websearch_required"]
3164
+
3087
3165
  return replace(
3088
3166
  bundle,
3089
3167
  loaded=replace(
@@ -3929,6 +4007,82 @@ def annex_form_is_table_like(identity: AnnexFormIdentity) -> bool:
3929
4007
  return any(token in signals for token in ("별표", "기준표", "표", "기준", "부과기준"))
3930
4008
 
3931
4009
 
4010
+ _ANNEX_HEADER_RE = re.compile(
4011
+ r"^\s*■\s*(?P<law>.+?)\s*\[\s*(?P<label>(?:별표|별지)[^\]]*?)\s*\]"
4012
+ )
4013
+ _ANNEX_BOX_CHARS = "┏┓┗┛┃┠┨┯┷━│─┌┐└┘├┤┼╋┣┫┳┻╂┿"
4014
+ # Adopt a header's source-law as related_name only when it reads like a statute
4015
+ # or subordinate/administrative rule, so junk header text is not treated as a
4016
+ # citable source reference (which feeds delegated-criteria source verification).
4017
+ _LEGISLATION_SUFFIXES = ("법", "법률", "령", "규칙", "조례", "규정", "고시", "훈령", "예규", "지침", "준칙")
4018
+
4019
+
4020
+ def _looks_like_legislation_name(name: str) -> bool:
4021
+ return name.endswith(_LEGISLATION_SUFFIXES)
4022
+
4023
+
4024
+ def enrich_annex_identity_from_body(
4025
+ identity: AnnexFormIdentity, text: str
4026
+ ) -> AnnexFormIdentity:
4027
+ """Recover the authoritative annex/form label from the loaded body header.
4028
+
4029
+ The text-download endpoint returns plain text with no title field, so a
4030
+ bare-id identity (loaded via ``--id`` with no title) keeps the numeric id as
4031
+ its title, which also silences table structuring. Parse the
4032
+ ``■ <법령> [별표 N] ...`` header to backfill title/related_name/annex_type,
4033
+ filling only fields currently missing so a rich search-hit identity is never
4034
+ overwritten.
4035
+ """
4036
+ lines = text.splitlines()
4037
+ header_idx: int | None = None
4038
+ match: re.Match[str] | None = None
4039
+ for idx, line in enumerate(lines[:6]):
4040
+ m = _ANNEX_HEADER_RE.match(line)
4041
+ if m:
4042
+ header_idx = idx
4043
+ match = m
4044
+ break
4045
+ if match is None or header_idx is None:
4046
+ return identity
4047
+
4048
+ law = match.group("law").strip()
4049
+ label = match.group("label").strip()
4050
+ annex_type = "별표" if label.startswith("별표") else "별지"
4051
+
4052
+ updates: dict[str, Any] = {}
4053
+ if not identity.related_name and _looks_like_legislation_name(law):
4054
+ updates["related_name"] = law
4055
+ if not identity.annex_type:
4056
+ updates["annex_type"] = annex_type
4057
+ if not identity.annex_number:
4058
+ number = re.search(r"\d+", label)
4059
+ if number:
4060
+ updates["annex_number"] = number.group(0)
4061
+
4062
+ if not identity.title or identity.title == identity.annex_id:
4063
+ # 별표 bodies place the annex name on its own line after the header;
4064
+ # forms carry it inside the bracket. Prefer the name line, fall back to
4065
+ # the bracketed "<법령> [별표 N]" label.
4066
+ name_line: str | None = None
4067
+ for line in lines[header_idx + 1 : header_idx + 8]:
4068
+ stripped = line.strip()
4069
+ if not stripped or stripped in ("(앞쪽)", "(뒤쪽)"):
4070
+ continue
4071
+ if stripped[0] in _ANNEX_BOX_CHARS or stripped.startswith("■"):
4072
+ break
4073
+ if any("가" <= ch <= "힣" for ch in stripped):
4074
+ name_line = stripped
4075
+ break
4076
+ if name_line and annex_type == "별표":
4077
+ updates["title"] = f"{law} [{label}] {name_line}"
4078
+ else:
4079
+ updates["title"] = f"{law} [{label}]"
4080
+
4081
+ if not updates:
4082
+ return identity
4083
+ return replace(identity, **updates)
4084
+
4085
+
3932
4086
  def structure_annex_form_text(text: str, identity: AnnexFormIdentity) -> StructuredTableData:
3933
4087
  lines = [line.strip() for line in text.splitlines() if line.strip()]
3934
4088
  pipe_table = parse_pipe_table(lines, identity)
@@ -4526,6 +4680,54 @@ def delegated_criteria_target_scope(bundle: LegalContextBundle) -> dict[str, set
4526
4680
  return {"law_ids": law_ids, "law_msts": law_msts, "law_names": law_names, "articles": articles}
4527
4681
 
4528
4682
 
4683
+ def flatten_structure_nodes(nodes: list[LawStructureNode]) -> list[LawStructureNode]:
4684
+ flat: list[LawStructureNode] = []
4685
+ for node in nodes:
4686
+ flat.append(node)
4687
+ if node.children:
4688
+ flat.extend(flatten_structure_nodes(node.children))
4689
+ return flat
4690
+
4691
+
4692
+ def delegated_subordinate_rule_names(
4693
+ bundle: LegalContextBundle, scope: dict[str, set[str]]
4694
+ ) -> list[str]:
4695
+ """Names of subordinate legislation (시행령·시행규칙) delegated from the target
4696
+ statute, used to search their own annexes — criteria/amount 별표 live inside
4697
+ the lower rules, and lsDelegated never surfaces 별표 pointers.
4698
+
4699
+ Delegations whose source article is in the requested scope are preferred; the
4700
+ law-structure 시행령·시행규칙 nodes are always included as a fallback so a bare
4701
+ anchor still reaches them.
4702
+ """
4703
+ names: list[str] = []
4704
+ seen: set[str] = set()
4705
+ scope_articles = scope.get("articles") or set()
4706
+
4707
+ def add(name: str | None) -> None:
4708
+ if not name:
4709
+ return
4710
+ key = str(name).strip()
4711
+ if not key or key in seen:
4712
+ return
4713
+ seen.add(key)
4714
+ names.append(key)
4715
+
4716
+ for graph in bundle.loaded.delegations:
4717
+ for rule in graph.rules:
4718
+ if not rule.delegated_name:
4719
+ continue
4720
+ if scope_articles and rule.source_article:
4721
+ if comparable_article_label(rule.source_article) not in scope_articles:
4722
+ continue
4723
+ add(rule.delegated_name)
4724
+ for structure in bundle.loaded.law_structures:
4725
+ for node in flatten_structure_nodes(structure.instruments):
4726
+ if node.instrument_type in ("enforcement_decree", "enforcement_rule"):
4727
+ add(node.name)
4728
+ return names
4729
+
4730
+
4529
4731
  def delegated_criteria_scope_label(scope: dict[str, set[str]]) -> str:
4530
4732
  law_label = ", ".join(sorted(scope["law_names"] or scope["law_ids"]))
4531
4733
  article_label = ", ".join(sorted(scope["articles"]))
@@ -975,7 +975,23 @@ def unwrap_search_interpretations(payload: dict[str, Any], target: str) -> list[
975
975
  def unwrap_search_judicial_decisions(payload: dict[str, Any], target: str) -> list[dict[str, Any]]:
976
976
  for envelope in ("PrecSearch", "precSearch", "DetcSearch", "detcSearch"):
977
977
  if isinstance(payload.get(envelope), dict):
978
- rows = payload[envelope].get(target)
978
+ env = payload[envelope]
979
+ # law.go.kr names the row-element key by its own casing, not the
980
+ # request target: "prec" (lowercase) for cases but "Detc"
981
+ # (capitalized) for constitutional decisions. Match it
982
+ # case-insensitively, excluding the scalar "target" echo field.
983
+ rows = env.get(target)
984
+ if rows is None:
985
+ rows = next(
986
+ (
987
+ env[key]
988
+ for key in env
989
+ if isinstance(key, str)
990
+ and key.lower() == target.lower()
991
+ and key != "target"
992
+ ),
993
+ None,
994
+ )
979
995
  return [row for row in ensure_list(rows) if isinstance(row, dict)]
980
996
  rows = payload.get(target)
981
997
  if rows is not None:
@@ -1545,36 +1561,47 @@ def normalize_delegated_rules(payload: dict[str, Any]) -> list[DelegatedRule]:
1545
1561
  continue
1546
1562
  if "위임정보" in row or "조정보" in row:
1547
1563
  source = row.get("조정보") if isinstance(row.get("조정보"), dict) else {}
1548
- info = row.get("위임정보") if isinstance(row.get("위임정보"), dict) else {}
1564
+ raw_info = row.get("위임정보")
1565
+ # 위임정보 is list-valued when one article delegates to several
1566
+ # targets (e.g. 과태료·범칙금액 amount tables). Collapsing it to {}
1567
+ # silently drops every multi-target article, so emit one rule per
1568
+ # delegation target instead.
1569
+ if isinstance(raw_info, list):
1570
+ infos = [item for item in raw_info if isinstance(item, dict)]
1571
+ elif isinstance(raw_info, dict):
1572
+ infos = [raw_info]
1573
+ else:
1574
+ infos = [{}]
1549
1575
  else:
1550
1576
  source = row
1551
- info = row
1552
- rule = DelegatedRule(
1553
- source_article=article_label_from_parts(
1554
- first_value(source, "조문번호", "조번호", "조항호목"),
1555
- first_value(source, "조문가지번호", "조가지번호"),
1556
- ),
1557
- source_article_title=string_or_none(first_value(source, "조문제목", "조제목")),
1558
- delegated_type=string_or_none(first_value(info, "위임구분", "법종구분")),
1559
- delegated_name=string_or_none(
1560
- first_value(info, "위임법령제목", "위임행정규칙제목", "위임자치법규제목", "위임행정규칙명", "법령명")
1561
- ),
1562
- delegated_law_id=string_or_none(first_value(info, "법령ID", "위임법령ID")),
1563
- delegated_mst=string_or_none(first_value(info, "위임법령일련번호", "위임행정규칙일련번호", "위임자치법규일련번호", "법령일련번호")),
1564
- delegated_article=article_label_from_parts(
1565
- first_value(info, "위임법령조문번호", "위임행정규칙조번호"),
1566
- first_value(
1567
- info,
1568
- "위임법령조문가지번호",
1569
- "위임행정규칙조가지번호",
1570
- "위임행정규칙조문가지번호",
1577
+ infos = [row]
1578
+ for info in infos:
1579
+ rule = DelegatedRule(
1580
+ source_article=article_label_from_parts(
1581
+ first_value(source, "조문번호", "조번호", "조항호목"),
1582
+ first_value(source, "조문가지번호", "조가지번호"),
1571
1583
  ),
1572
- ),
1573
- text=string_or_none(first_value(info, "라인텍스트", "조내용", "링크텍스트")),
1574
- raw=row,
1575
- )
1576
- if rule.delegated_name or rule.text:
1577
- rules.append(rule)
1584
+ source_article_title=string_or_none(first_value(source, "조문제목", "조제목")),
1585
+ delegated_type=string_or_none(first_value(info, "위임구분", "법종구분")),
1586
+ delegated_name=string_or_none(
1587
+ first_value(info, "위임법령제목", "위임행정규칙제목", "위임자치법규제목", "위임행정규칙명", "법령명")
1588
+ ),
1589
+ delegated_law_id=string_or_none(first_value(info, "법령ID", "위임법령ID")),
1590
+ delegated_mst=string_or_none(first_value(info, "위임법령일련번호", "위임행정규칙일련번호", "위임자치법규일련번호", "법령일련번호")),
1591
+ delegated_article=article_label_from_parts(
1592
+ first_value(info, "위임법령조문번호", "위임행정규칙조번호"),
1593
+ first_value(
1594
+ info,
1595
+ "위임법령조문가지번호",
1596
+ "위임행정규칙조가지번호",
1597
+ "위임행정규칙조문가지번호",
1598
+ ),
1599
+ ),
1600
+ text=string_or_none(first_value(info, "라인텍스트", "조내용", "링크텍스트")),
1601
+ raw=row,
1602
+ )
1603
+ if rule.delegated_name or rule.text:
1604
+ rules.append(rule)
1578
1605
  return rules
1579
1606
 
1580
1607
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moleg-api
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Task-level Python interface for loading Korean legal sources from law.go.kr
5
5
  Author: MOLEG-API maintainers
6
6
  License-Expression: MIT
@@ -20,5 +20,6 @@ tests/test_cli.py
20
20
  tests/test_laws.py
21
21
  tests/test_live_smoke.py
22
22
  tests/test_models.py
23
+ tests/test_sdk_fixes_0_2_1.py
23
24
  tests/test_source.py
24
25
  tests/test_versions.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "moleg-api"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Task-level Python interface for loading Korean legal sources from law.go.kr"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -2124,7 +2124,14 @@ def test_get_annex_form_body_loads_law_text_export_from_candidate():
2124
2124
 
2125
2125
  body = MolegApi(source).get_annex_form_body(identity)
2126
2126
 
2127
- assert body.identity == identity
2127
+ # Caller-supplied identity fields are preserved; missing metadata
2128
+ # (annex_type/annex_number) is backfilled from the loaded body header.
2129
+ assert body.identity.annex_id == "17677511"
2130
+ assert body.identity.title == "과태료의 부과기준(제67조 관련)"
2131
+ assert body.identity.related_name == "식품위생법 시행령"
2132
+ assert body.identity.source_target == "licbyl"
2133
+ assert body.identity.annex_type == "별표"
2134
+ assert body.identity.annex_number == "2"
2128
2135
  assert "과태료의 부과기준" in body.text
2129
2136
  assert body.file_type == "text/plain"
2130
2137
  assert body.extraction_method == "lsBylTextDownLoad.do"
@@ -184,3 +184,68 @@ def test_live_expand_legal_query_smoke():
184
184
 
185
185
  assert expansion.original_query == "자동차 방치"
186
186
  assert expansion.follow_up_searches
187
+
188
+
189
+ def test_live_constitutional_search_high_frequency_term_smoke():
190
+ # 0.2.1 #4A: capitalized 'Detc' row key was dropping all hits to 0.
191
+ api = live_api()
192
+
193
+ hits = api.search_constitutional_decisions("개인정보", display=20)
194
+
195
+ assert hits, "high-frequency 헌재 term should return hits after the row-key fix"
196
+ assert hits[0].identity.decision_id
197
+
198
+
199
+ def test_live_constitutional_detail_by_case_number_smoke():
200
+ # 0.2.1 #4B: get_constitutional_decision resolves a 사건번호 to its serial.
201
+ api = live_api()
202
+
203
+ hits = api.search_constitutional_decisions("개인정보", display=5)
204
+ hit = first_hit_or_skip(hits, "constitutional decision")
205
+ case_number = hit.identity.case_number
206
+ if not case_number:
207
+ pytest.skip("No 사건번호 on the sampled hit")
208
+
209
+ text = api.get_constitutional_decision(case_number)
210
+
211
+ assert text.identity.decision_id
212
+ assert text.text
213
+
214
+
215
+ def test_live_find_delegated_rules_name_and_recall_smoke():
216
+ # 0.2.1 #2: resolved statute name + multi-target recall.
217
+ api = live_api()
218
+
219
+ graph = api.find_delegated_rules("001638") # 도로교통법
220
+
221
+ assert graph.identity.name and not graph.identity.name.isdigit()
222
+ articles = {rule.source_article for rule in graph.rules}
223
+ assert "제160조" in articles and "제162조" in articles
224
+
225
+
226
+ def test_live_load_delegated_criteria_reaches_subordinate_annex_smoke():
227
+ # 0.2.1 #1: 별표 in the delegated 시행령·시행규칙 are surfaced first-party,
228
+ # not pushed to websearch.
229
+ api = live_api()
230
+
231
+ bundle = api.load_delegated_criteria("001638", articles=["제160조", "제162조"])
232
+
233
+ annex_titles = [annex.identity.title for annex in bundle.loaded.annex_forms]
234
+ candidate_titles = [c.identity.title for c in bundle.candidates.annex_forms]
235
+ all_titles = " ".join(annex_titles + candidate_titles)
236
+ assert bundle.candidates.annex_forms or bundle.loaded.annex_forms
237
+ assert "과태료" in all_titles or "범칙" in all_titles
238
+ gap_kinds = [gap.kind for gap in bundle.gaps]
239
+ assert gap_kinds != ["websearch_required"]
240
+
241
+
242
+ def test_live_annex_form_body_recovers_label_smoke():
243
+ # 0.2.1 #3: a bare-id annex body load recovers its authoritative label.
244
+ api = live_api()
245
+
246
+ hits = api.search_annex_forms("도로교통법 시행령", source="law", annex_type="별표", display=5)
247
+ hit = first_hit_or_skip(hits, "law annex")
248
+ body = api.get_annex_form_body(hit.identity.annex_id) # bare id, no title
249
+
250
+ assert body.identity.title and body.identity.title != body.identity.annex_id
251
+ assert body.identity.related_name
@@ -0,0 +1,375 @@
1
+ """Regression tests for the 0.2.1 defect fixes.
2
+
3
+ Each group locks one consumer-facing defect surfaced by the published-package
4
+ end-to-end pass:
5
+
6
+ * #1 load-delegated-criteria reached 별표 in the delegated 시행령·시행규칙
7
+ * #2 find-delegated-rules recall, resolved identity name, populated signals
8
+ * #3 get-annex-form-body recovers the authoritative label from the body
9
+ * #4 헌재 search parses capitalized row keys; detail accepts a 사건번호
10
+ * #5 CLI: no dead --before/--after, a top-level --version
11
+ """
12
+
13
+ import argparse
14
+
15
+ import pytest
16
+
17
+ from moleg_api import MolegApi
18
+ from moleg_api.cli import build_parser, signals_for
19
+ from moleg_api.laws import (
20
+ _CONSTITUTIONAL_CASE_NUMBER_RE,
21
+ delegated_criteria_target_scope,
22
+ delegated_subordinate_rule_names,
23
+ enrich_annex_identity_from_body,
24
+ )
25
+ from moleg_api.models import (
26
+ AnnexFormIdentity,
27
+ BundleRequest,
28
+ CandidateContext,
29
+ DelegatedRule,
30
+ DelegationGraph,
31
+ LawIdentity,
32
+ LawStructure,
33
+ LawStructureNode,
34
+ LegalContextBundle,
35
+ LoadedContext,
36
+ )
37
+ from moleg_api.normalization import (
38
+ normalize_delegated_rules,
39
+ unwrap_search_judicial_decisions,
40
+ )
41
+
42
+
43
+ class FakeSource:
44
+ def __init__(self, *, search_payloads=None, service_payloads=None):
45
+ self.search_payloads = list(search_payloads or [])
46
+ self.service_payloads = list(service_payloads or [])
47
+ self.calls = []
48
+
49
+ def search(self, target, params):
50
+ self.calls.append(("search", target, params))
51
+ return self.search_payloads.pop(0)
52
+
53
+ def service(self, target, params):
54
+ self.calls.append(("service", target, params))
55
+ return self.service_payloads.pop(0)
56
+
57
+ def search_html(self, target, params): # pragma: no cover - unused here
58
+ raise AssertionError("search_html not expected")
59
+
60
+ def post_text(self, path, params): # pragma: no cover - unused here
61
+ raise AssertionError("post_text not expected")
62
+
63
+
64
+ def _args(**kw):
65
+ ns = argparse.Namespace(as_of=None)
66
+ for key, value in kw.items():
67
+ setattr(ns, key, value)
68
+ return ns
69
+
70
+
71
+ # --------------------------------------------------------------------------- #
72
+ # #4A — 헌재 search row-key casing (Detc vs prec)
73
+ # --------------------------------------------------------------------------- #
74
+
75
+ def test_unwrap_detc_capitalized_row_key_returns_rows():
76
+ payload = {
77
+ "DetcSearch": {
78
+ "totalCnt": 29,
79
+ "target": "detc",
80
+ "Detc": [
81
+ {"헌재결정례일련번호": "48654", "사건번호": "2015헌마1140"},
82
+ {"헌재결정례일련번호": "50600", "사건번호": "2016헌마492"},
83
+ ],
84
+ }
85
+ }
86
+ rows = unwrap_search_judicial_decisions(payload, "detc")
87
+ assert [row["헌재결정례일련번호"] for row in rows] == ["48654", "50600"]
88
+
89
+
90
+ def test_unwrap_prec_lowercase_row_key_unchanged():
91
+ payload = {"PrecSearch": {"prec": [{"판례일련번호": "1"}]}}
92
+ assert len(unwrap_search_judicial_decisions(payload, "prec")) == 1
93
+
94
+
95
+ def test_unwrap_detc_single_object_is_wrapped():
96
+ payload = {"DetcSearch": {"Detc": {"헌재결정례일련번호": "137475"}}}
97
+ rows = unwrap_search_judicial_decisions(payload, "detc")
98
+ assert len(rows) == 1 and rows[0]["헌재결정례일련번호"] == "137475"
99
+
100
+
101
+ def test_unwrap_detc_scalar_target_echo_not_treated_as_rows():
102
+ payload = {"DetcSearch": {"target": "detc", "Detc": [{"헌재결정례일련번호": "1"}]}}
103
+ rows = unwrap_search_judicial_decisions(payload, "detc")
104
+ assert len(rows) == 1
105
+
106
+
107
+ # --------------------------------------------------------------------------- #
108
+ # #4B — get-constitutional-decision accepts a 사건번호
109
+ # --------------------------------------------------------------------------- #
110
+
111
+ def test_constitutional_case_number_regex():
112
+ assert _CONSTITUTIONAL_CASE_NUMBER_RE.match("2005헌마1139")
113
+ assert _CONSTITUTIONAL_CASE_NUMBER_RE.match("2015헌바9")
114
+ assert not _CONSTITUTIONAL_CASE_NUMBER_RE.match("48654") # a bare serial
115
+ assert not _CONSTITUTIONAL_CASE_NUMBER_RE.match("개인정보") # free text
116
+
117
+
118
+ def test_get_constitutional_decision_resolves_case_number_via_search():
119
+ source = FakeSource(
120
+ search_payloads=[
121
+ {
122
+ "DetcSearch": {
123
+ "Detc": [
124
+ {
125
+ "헌재결정례일련번호": "137475",
126
+ "사건명": "공직자병역사항 위헌확인",
127
+ "사건번호": "2005헌마1139",
128
+ "종국일자": "20070101",
129
+ }
130
+ ]
131
+ }
132
+ }
133
+ ],
134
+ service_payloads=[
135
+ {
136
+ "detc": {
137
+ "헌재결정례일련번호": "137475",
138
+ "사건명": "공직자병역사항 위헌확인",
139
+ "사건번호": "2005헌마1139",
140
+ "종국일자": "20070101",
141
+ "전문": "결정 전문",
142
+ }
143
+ }
144
+ ],
145
+ )
146
+
147
+ text = MolegApi(source).get_constitutional_decision("2005헌마1139")
148
+
149
+ assert text.identity.decision_id == "137475"
150
+ assert source.calls[0][:2] == ("search", "detc")
151
+ assert source.calls[1] == ("service", "detc", {"ID": "137475"})
152
+
153
+
154
+ def test_get_constitutional_decision_numeric_serial_skips_search():
155
+ source = FakeSource(
156
+ service_payloads=[
157
+ {
158
+ "detc": {
159
+ "헌재결정례일련번호": "58400",
160
+ "사건명": "X",
161
+ "사건번호": "2020헌마1",
162
+ "종국일자": "20240229",
163
+ "전문": "전문",
164
+ }
165
+ }
166
+ ]
167
+ )
168
+
169
+ MolegApi(source).get_constitutional_decision("58400")
170
+
171
+ assert source.calls[0] == ("service", "detc", {"ID": "58400"})
172
+
173
+
174
+ # --------------------------------------------------------------------------- #
175
+ # #2a — normalize_delegated_rules keeps multi-target (list-valued) delegations
176
+ # --------------------------------------------------------------------------- #
177
+
178
+ def test_normalize_delegated_rules_splits_list_valued_info():
179
+ payload = {
180
+ "위임조문정보": [
181
+ {
182
+ "조정보": {"조문번호": "160", "조문제목": "과태료"},
183
+ "위임정보": [
184
+ {"위임구분": "위임규칙", "위임행정규칙제목": "도로교통법 시행규칙"},
185
+ {"위임구분": "위임법령", "위임법령제목": "도로교통법 시행령"},
186
+ ],
187
+ }
188
+ ]
189
+ }
190
+ rules = normalize_delegated_rules(payload)
191
+ assert len(rules) == 2
192
+ assert {rule.delegated_name for rule in rules} == {"도로교통법 시행규칙", "도로교통법 시행령"}
193
+ assert all(rule.source_article == "제160조" for rule in rules)
194
+
195
+
196
+ def test_normalize_delegated_rules_dict_valued_single_target_unchanged():
197
+ payload = {
198
+ "위임조문정보": [
199
+ {"조정보": {"조문번호": "163"}, "위임정보": {"위임법령제목": "도로교통법"}}
200
+ ]
201
+ }
202
+ rules = normalize_delegated_rules(payload)
203
+ assert len(rules) == 1 and rules[0].delegated_name == "도로교통법"
204
+
205
+
206
+ def test_normalize_delegated_rules_flat_row_shape_preserved():
207
+ payload = {"위임법령": [{"위임법령제목": "어떤 시행령", "조문번호": "5"}]}
208
+ rules = normalize_delegated_rules(payload)
209
+ assert len(rules) == 1 and rules[0].delegated_name == "어떤 시행령"
210
+
211
+
212
+ # --------------------------------------------------------------------------- #
213
+ # #2b / #5c — resolved statute name replaces a bare law_id
214
+ # --------------------------------------------------------------------------- #
215
+
216
+ class _NamedApi(MolegApi):
217
+ def _law_name_for(self, identity):
218
+ return "주택임대차보호법" if str(identity.name).isdigit() else identity.name
219
+
220
+
221
+ def test_resolve_identity_name_backfills_digit_name():
222
+ api = _NamedApi(source=object())
223
+ out = api._resolve_identity_name(LawIdentity(law_id="001248", name="001248", basis="effective"))
224
+ assert out.name == "주택임대차보호법"
225
+
226
+
227
+ def test_resolve_identity_name_keeps_real_name():
228
+ api = _NamedApi(source=object())
229
+ ident = LawIdentity(law_id="001248", name="주택임대차보호법", basis="effective")
230
+ assert api._resolve_identity_name(ident) is ident
231
+
232
+
233
+ # --------------------------------------------------------------------------- #
234
+ # #2c — DelegationGraph signals are populated for a non-empty result
235
+ # --------------------------------------------------------------------------- #
236
+
237
+ def test_delegation_graph_signals_populated_when_rules_present():
238
+ graph = DelegationGraph(
239
+ identity=LawIdentity(law_id="001638", name="도로교통법", basis="effective"),
240
+ rules=[DelegatedRule(source_article="제160조", delegated_name="도로교통법 시행령")],
241
+ )
242
+ sig = signals_for("find-delegated-rules", graph, _args())
243
+ assert sig["flags"]["count"] == 1
244
+ assert any("별표" in line for line in sig["discipline"])
245
+ assert any("search-annex-forms" in cmd["cmd"] for cmd in sig["next"])
246
+
247
+
248
+ def test_delegation_graph_signals_empty_keeps_absence_discipline():
249
+ graph = DelegationGraph(
250
+ identity=LawIdentity(law_id="x", name="x", basis="effective"), rules=[]
251
+ )
252
+ sig = signals_for("find-delegated-rules", graph, _args())
253
+ assert sig["flags"]["count"] == 0
254
+ assert sig["discipline"]
255
+
256
+
257
+ # --------------------------------------------------------------------------- #
258
+ # #3 — enrich_annex_identity_from_body recovers the authoritative label
259
+ # --------------------------------------------------------------------------- #
260
+
261
+ def _bare_annex(annex_id="18177371"):
262
+ return AnnexFormIdentity(
263
+ annex_id=annex_id, title=annex_id, source_type="law", source_target="licbyl"
264
+ )
265
+
266
+
267
+ def test_enrich_annex_byeolpyo_recovers_title_and_metadata():
268
+ text = (
269
+ "■ 도로교통법 시행령 [별표 8] <개정 2025. 6. 2.>\n"
270
+ "\n"
271
+ "범칙행위 및 범칙금액(운전자)(제93조제1항 관련)\n"
272
+ "┏━━━┓"
273
+ )
274
+ out = enrich_annex_identity_from_body(_bare_annex(), text)
275
+ assert out.related_name == "도로교통법 시행령"
276
+ assert out.annex_type == "별표"
277
+ assert out.annex_number == "8"
278
+ assert "범칙행위 및 범칙금액(운전자)" in out.title
279
+ assert out.title != out.annex_id
280
+
281
+
282
+ def test_enrich_annex_form_uses_bracket_label():
283
+ text = "■ 도로교통법 시행규칙 [별지 제159호의2서식] <개정 2024. 7. 31.>\n서식 본문"
284
+ out = enrich_annex_identity_from_body(_bare_annex("18148709"), text)
285
+ assert out.related_name == "도로교통법 시행규칙"
286
+ assert out.annex_type == "별지"
287
+ assert out.title == "도로교통법 시행규칙 [별지 제159호의2서식]"
288
+
289
+
290
+ def test_enrich_annex_does_not_clobber_rich_identity():
291
+ ident = AnnexFormIdentity(
292
+ annex_id="1",
293
+ title="진짜 제목",
294
+ source_type="law",
295
+ source_target="licbyl",
296
+ related_name="식품위생법 시행령",
297
+ annex_type="별표",
298
+ )
299
+ out = enrich_annex_identity_from_body(ident, "■ 다른법 시행령 [별표 3]\n다른 이름")
300
+ assert out.title == "진짜 제목"
301
+ assert out.related_name == "식품위생법 시행령"
302
+ assert out.annex_type == "별표"
303
+
304
+
305
+ def test_enrich_annex_without_header_leaves_identity():
306
+ out = enrich_annex_identity_from_body(_bare_annex("9"), "헤더 없는 그냥 본문")
307
+ assert out.title == "9"
308
+
309
+
310
+ def test_enrich_annex_non_legislation_source_not_adopted_as_reference():
311
+ # A header that is not a statute/rule name must not become a citable
312
+ # related_name (it feeds delegated-criteria source verification).
313
+ out = enrich_annex_identity_from_body(
314
+ AnnexFormIdentity(annex_id="44", title="무단방치 기준", source_type="law", source_target="licbyl"),
315
+ "■ 출처 미상 [별표]\n표 내용",
316
+ )
317
+ assert out.related_name is None
318
+
319
+
320
+ # --------------------------------------------------------------------------- #
321
+ # #1 — delegated_subordinate_rule_names resolves the 시행령·시행규칙 to search
322
+ # --------------------------------------------------------------------------- #
323
+
324
+ def test_delegated_subordinate_rule_names_scopes_and_includes_structures():
325
+ ident = LawIdentity(law_id="001638", name="도로교통법", basis="effective")
326
+ delegation = DelegationGraph(
327
+ identity=ident,
328
+ rules=[
329
+ DelegatedRule(source_article="제160조", delegated_name="도로교통법 시행규칙"),
330
+ DelegatedRule(source_article="제999조", delegated_name="무관 시행령"),
331
+ ],
332
+ )
333
+ structure = LawStructure(
334
+ identity=ident,
335
+ instruments=[
336
+ LawStructureNode(name="도로교통법 시행령", source_type="law", instrument_type="enforcement_decree"),
337
+ LawStructureNode(name="도로교통법 시행규칙", source_type="law", instrument_type="enforcement_rule"),
338
+ LawStructureNode(name="상위 법률", source_type="law", instrument_type="related_law"),
339
+ ],
340
+ )
341
+ bundle = LegalContextBundle(
342
+ request=BundleRequest(query=None, mode="institutional_system", budget="standard", articles=["제160조"]),
343
+ loaded=LoadedContext(delegations=[delegation], law_structures=[structure]),
344
+ candidates=CandidateContext(),
345
+ )
346
+ names = delegated_subordinate_rule_names(bundle, delegated_criteria_target_scope(bundle))
347
+ assert "도로교통법 시행령" in names
348
+ assert "도로교통법 시행규칙" in names
349
+ assert "무관 시행령" not in names # source article out of requested scope
350
+ assert "상위 법률" not in names # related_law instrument excluded
351
+
352
+
353
+ # --------------------------------------------------------------------------- #
354
+ # #5a / #5b — CLI: dead flags removed, --version added
355
+ # --------------------------------------------------------------------------- #
356
+
357
+ def test_compare_law_versions_rejects_removed_before_after():
358
+ with pytest.raises(SystemExit):
359
+ build_parser().parse_args(
360
+ ["compare-law-versions", "--law", "001248", "--before", "2020-01-01"]
361
+ )
362
+
363
+
364
+ def test_compare_law_versions_accepts_law_and_article():
365
+ ns = build_parser().parse_args(
366
+ ["compare-law-versions", "--law", "001248", "--article", "제3조"]
367
+ )
368
+ assert ns.law == "001248" and ns.article == "제3조"
369
+
370
+
371
+ def test_top_level_version_flag_prints_and_exits_zero(capsys):
372
+ with pytest.raises(SystemExit) as exc:
373
+ build_parser().parse_args(["--version"])
374
+ assert exc.value.code == 0
375
+ assert "moleg-api" in capsys.readouterr().out
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes