"""compose 파일만 읽으면 되는 최소 YAML 파서 (표준 라이브러리만 사용). PyYAML 이 없는 폐쇄망 호스트에서 _compose_to_plan.py 가 되돌아오는 경로다. 범용 YAML 이 아니라 이 번들의 compose.yml 이 쓰는 문법만 다룬다. - 블록 매핑 / 블록 시퀀스 (들여쓰기) - 앵커(&x) · 별칭(*x) · 병합 키(<<:) - 따옴표 스칼라("..", '..') 와 맨 스칼라 - 블록 스칼라 ( | ) - 한 줄 흐름 시퀀스 / 매핑 ([a, b], {k: v}) - 주석(#), 빈 줄 - 문서 최상단의 x- 확장 키 여러 문서(---), 태그, 복합 키, 여러 줄에 걸친 흐름 스타일은 다루지 않는다. 그런 문법이 compose 에 들어오면 parse 가 예외를 던지고 호출부가 그 사실을 그대로 보고한다. """ class MiniYamlError(ValueError): pass def _split_comment(text): """따옴표 밖의 # 부터를 주석으로 떼어 낸다.""" out = [] quote = None i = 0 while i < len(text): ch = text[i] if quote: out.append(ch) if ch == "\\" and i + 1 < len(text): out.append(text[i + 1]) i += 2 continue if ch == quote: quote = None elif ch in "\"'": quote = ch out.append(ch) elif ch == "#" and (not out or out[-1] in " \t"): break else: out.append(ch) i += 1 return "".join(out).rstrip() def _scalar(text): text = text.strip() if not text: return "" if len(text) >= 2 and text[0] == text[-1] and text[0] in "\"'": body = text[1:-1] if text[0] == '"': return body.encode().decode("unicode_escape") return body low = text.lower() if low in ("true", "yes", "on"): return True if low in ("false", "no", "off"): return False if low in ("null", "~", ""): return None try: return int(text) except ValueError: pass try: return float(text) except ValueError: pass return text def _map_colon(text): """따옴표·괄호 밖에 있는 매핑 콜론(뒤가 공백이거나 줄 끝)의 위치. 없으면 -1. "- 127.0.0.1:${PORT}:5432" 나 "- vol:/path:ro" 처럼 값 안에 콜론이 들어 있는 시퀀스 항목을 매핑으로 잘못 읽지 않기 위해 필요하다. """ quote = None depth = 0 for i, ch in enumerate(text): if quote: if ch == quote: quote = None continue if ch in "\"'": quote = ch elif ch in "[{": depth += 1 elif ch in "]}": depth -= 1 elif ch == ":" and depth == 0: if i + 1 == len(text) or text[i + 1] in " \t": return i return -1 def _split_flow(body): """흐름 스타일의 최상위 항목을 콤마로 나눈다 (따옴표·중첩 괄호 존중).""" items, cur, quote, depth = [], [], None, 0 for ch in body: if quote: cur.append(ch) if ch == quote: quote = None continue if ch in "\"'": quote = ch cur.append(ch) elif ch in "[{": depth += 1 cur.append(ch) elif ch in "]}": depth -= 1 cur.append(ch) elif ch == "," and depth == 0: items.append("".join(cur)) cur = [] else: cur.append(ch) if "".join(cur).strip(): items.append("".join(cur)) return [i.strip() for i in items] def _flow(text): """한 줄 흐름 시퀀스/매핑을 값으로 바꾼다. 흐름이 아니면 None 을 돌려준다.""" text = text.strip() if len(text) >= 2 and text[0] == "[" and text[-1] == "]": return [_value(i) for i in _split_flow(text[1:-1])] if len(text) >= 2 and text[0] == "{" and text[-1] == "}": out = {} for item in _split_flow(text[1:-1]): idx = _map_colon(item) if idx < 0: raise MiniYamlError("흐름 매핑 항목에 콜론이 없다: %r" % item) out[_scalar(item[:idx])] = _value(item[idx + 1:]) return out return None def _value(text): """흐름 스타일이면 구조로, 아니면 스칼라로.""" flow = _flow(text) return _scalar(text) if flow is None else flow class _Line: __slots__ = ("indent", "text", "no") def __init__(self, indent, text, no): self.indent = indent self.text = text self.no = no def _tokenize(source): lines = [] raw = source.splitlines() i = 0 while i < len(raw): line = raw[i].replace("\t", " ") stripped = _split_comment(line) if not stripped.strip(): i += 1 continue indent = len(stripped) - len(stripped.lstrip(" ")) body = stripped.strip() if body == "---": i += 1 continue # 블록 스칼라: 뒤따르는 더 깊은 들여쓰기를 통째로 값으로 삼는다. if body.endswith("|") or body.endswith("|-") or body.endswith(">"): keep = body.endswith("|") head = body.rstrip("|->").rstrip() block = [] j = i + 1 base = None while j < len(raw): nxt = raw[j].replace("\t", " ") if not nxt.strip(): block.append("") j += 1 continue nxt_indent = len(nxt) - len(nxt.lstrip(" ")) if nxt_indent <= indent: break if base is None: base = nxt_indent block.append(nxt[base:]) j += 1 value = "\n".join(block) if keep and not value.endswith("\n"): value += "\n" lines.append(_Line(indent, (head, value, True), i + 1)) i = j continue lines.append(_Line(indent, (body, None, False), i + 1)) i += 1 return lines def _parse_block(lines, pos, indent, anchors): """indent 이상으로 이어지는 블록 하나를 (값, 다음 위치) 로 돌려준다.""" if pos >= len(lines): return None, pos if lines[pos].text[0].startswith("- "): return _parse_seq(lines, pos, indent, anchors) if lines[pos].text[0] == "-": return _parse_seq(lines, pos, indent, anchors) return _parse_map(lines, pos, indent, anchors) def _parse_seq(lines, pos, indent, anchors): out = [] while pos < len(lines) and lines[pos].indent == indent: head, block, is_block = lines[pos].text if not (head == "-" or head.startswith("- ")): break rest = head[1:].lstrip() if head != "-" else "" if is_block: out.append(block) pos += 1 continue if not rest: # "- " 다음 줄부터가 항목의 내용이다. pos += 1 if pos < len(lines) and lines[pos].indent > indent: value, pos = _parse_block(lines, pos, lines[pos].indent, anchors) out.append(value) else: out.append(None) continue if _map_colon(rest) >= 0 and not rest.startswith("*") and _flow(rest) is None: # "- key: value" — 항목 자체가 매핑이다. 가상의 들여쓰기로 다시 읽는다. sub = [_Line(indent + 2, (rest, None, False), lines[pos].no)] pos += 1 while pos < len(lines) and lines[pos].indent > indent: sub.append(lines[pos]) pos += 1 value, _ = _parse_map(sub, 0, indent + 2, anchors) out.append(value) continue if rest.startswith("*"): out.append(anchors[rest[1:].strip()]) pos += 1 continue out.append(_value(rest)) pos += 1 return out, pos def _parse_map(lines, pos, indent, anchors): out = {} while pos < len(lines) and lines[pos].indent == indent: head, block, is_block = lines[pos].text if head.startswith("- "): break colon = _map_colon(head) if colon < 0: raise MiniYamlError("line %d: 매핑이 아닌 줄 %r" % (lines[pos].no, head)) key = _scalar(head[:colon]) rest = head[colon + 1:].strip() if is_block: out[key] = block pos += 1 continue anchor = None if rest.startswith("&"): parts = rest[1:].split(None, 1) anchor = parts[0] rest = parts[1].strip() if len(parts) > 1 else "" if rest.startswith("*"): value = anchors[rest[1:].strip()] elif rest: value = _value(rest) pos_next = pos + 1 if key == "<<": raise MiniYamlError("line %d: 병합 키에 스칼라" % lines[pos].no) if anchor: anchors[anchor] = value out[key] = value pos = pos_next continue else: pos += 1 if pos < len(lines) and lines[pos].indent > indent: value, pos = _parse_block(lines, pos, lines[pos].indent, anchors) else: value = None if anchor: anchors[anchor] = value if key == "<<": _merge(out, value) else: out[key] = value continue if anchor: anchors[anchor] = value if key == "<<": _merge(out, value) else: out[key] = value pos += 1 return out, pos def _merge(target, source): """병합 키(<<) — 이미 있는 키는 덮지 않는다 (YAML 사양과 같다).""" if source is None: return items = source if isinstance(source, list) else [source] for item in items: if not isinstance(item, dict): raise MiniYamlError("병합 키의 값이 매핑이 아니다: %r" % (item,)) for k, v in item.items(): target.setdefault(k, v) def safe_load(source): lines = _tokenize(source) if not lines: return {} value, pos = _parse_block(lines, 0, lines[0].indent, {}) if pos != len(lines): raise MiniYamlError("line %d 이후를 읽지 못했다" % lines[pos].no) return value