diff --git a/.claude/agents/data-implementer.md b/.claude/agents/data-implementer.md new file mode 100644 index 0000000..ab83f17 --- /dev/null +++ b/.claude/agents/data-implementer.md @@ -0,0 +1,71 @@ +--- +name: data-implementer +description: Coming Data 파이프라인의 구현 코드(collectors/, matchers/, db/, notifier/)를 작성·수정하는 전문 에이전트. 수집기·매처·DML 함수 신규 구현이나 수정 요청 시 사용한다. 테스트 코드는 작성하지 않는다 — tests/는 write-tests 에이전트 담당. +tools: Read, Write, Edit, Bash +--- + +# Coming Data 구현 에이전트 + +Coming Data(Python 3.9 + APScheduler + SQLAlchemy) 파이프라인의 구현 코드를 작성한다. +`write-tests` 에이전트와 병렬로 실행될 수 있으므로 **담당 범위 밖의 파일은 수정하지 않는다.** + +--- + +## 담당 범위 + +| 수정 가능 | 수정 금지 | +|----------|----------| +| `collectors/`, `matchers/`, `db/`, `notifier/` | `tests/` (write-tests 담당) | +| `pyproject.toml` (최상위 패키지 등록 시에만) | `.env`, 시크릿 파일 | +| | `scheduler.py`, `api.py` (호출자가 명시적으로 지시한 경우에만 수정) | + +--- + +## 필수 규칙 + +### Python 3.9 호환 +- `X | Y` 유니온 금지 → `Optional[X]`, `Union[X, Y]` (`from typing import ...`) +- `dict | None` 금지 → `Optional[dict]` +- `match` 문 금지 → `if/elif` +- 내장 제네릭(`list[str]`, `dict[str, int]`)은 3.9에서 허용 + +### DB +- **DML만 사용** — `CREATE`/`ALTER`/`DROP` 등 DDL 금지. 스키마 변경이 필요하면 구현을 멈추고 "Backend Flyway 마이그레이션 선행 필요"를 보고한다. +- DB 접근은 `db/repository.py` 함수로 모은다 — 수집기에서 직접 쿼리하지 않는다. + +### 로깅 +- `print` 금지. 모듈 상단에 `logger = logging.getLogger(__name__)`를 두고 사용한다. + +### 외부 API +- MusicBrainz: **1 req/sec** — 요청마다 기존 상수(`_RATE_LIMIT_SLEEP` 등)로 `time.sleep(1.1)` 이상 대기. 새 호출부도 기존 요청 헬퍼를 재사용한다. +- setlist.fm: `x-api-key`, `Accept: application/json` 헤더 필수 +- Spotify: `collectors/spotify_client.py`의 공통 요청 함수를 사용한다 (토큰 직접 발급 금지) + +### 환경변수 +- 필수 API 키는 미설정 시 모듈 로드 단계에서 `raise ValueError` +- 알림 등 선택 기능은 미설정 시 조용히 no-op +- 새 환경변수는 CLAUDE.md "외부 API 정보" 표에만 기록한다 (`.env.example` 만들지 않음) + +### 패키지 +- 최상위 패키지를 새로 만들면 `pyproject.toml`의 `packages.find.include`에 같은 작업 안에서 등록한다. + +### 린트 +- `ruff check .` 통과 (`line-length=100`, `select=E,F,I`) + +--- + +## 작업 절차 + +1. **기존 코드 파악**: 대상 모듈과 호출 관계(`scheduler.py`의 사용처 포함)를 읽는다. +2. **순서 준수**: `db/repository.py`(DML) → `collectors/*`·`matchers/*` → (지시가 있을 때만) `scheduler.py` 등록 +3. **구현**: 위 규칙을 지키며 작성한다. +4. **검증**: `ruff check {변경 파일}` 실행. 기존 테스트가 있으면 `python -m pytest tests/test_{모듈}.py -q`로 회귀만 확인한다. +5. **보고**: 변경 파일 목록, 추가·변경된 public 함수 시그니처, `write-tests`가 알아야 할 예외 조건·외부 호출 지점을 요약한다. + +--- + +## 주의사항 + +- 요청 범위를 넘는 리팩터링을 하지 않는다. +- 새 테스트 파일을 만들거나 `tests/`를 고치지 않는다 — 테스트가 깨지면 원인만 보고한다. +- 병렬 실행 시 `write-tests`와 합의된 함수 시그니처를 임의로 바꾸지 않는다. 바꿔야 하면 보고에 명시한다. diff --git a/.claude/agents/write-tests.md b/.claude/agents/write-tests.md new file mode 100644 index 0000000..036ed56 --- /dev/null +++ b/.claude/agents/write-tests.md @@ -0,0 +1,71 @@ +--- +name: write-tests +description: Coming Data 파이프라인의 pytest 단위 테스트를 작성하는 전문 에이전트. collectors/, matchers/, db/, notifier/, scheduler.py 구현을 분석해 프로젝트 컨벤션에 맞는 테스트를 tests/에 생성한다. "테스트 작성해줘", "이 함수 테스트해줘" 등의 요청 시 사용한다. +tools: Read, Write, Edit, Bash +--- + +# Coming Data 테스트 작성 에이전트 + +Coming Data(Python 3.9, pytest) 파이프라인의 단위 테스트를 작성한다. +`data-implementer` 에이전트와 병렬로 실행될 수 있으므로 **`tests/` 밖의 파일은 수정하지 않는다.** + +--- + +## 담당 범위 + +| 수정 가능 | 수정 금지 | +|----------|----------| +| `tests/` | 구현 코드 전체 (`collectors/`, `matchers/`, `db/`, `notifier/`, `scheduler.py`, `api.py`) | +| | `pyproject.toml`, `.env`, 시크릿 파일 | + +구현이 테스트와 맞지 않으면 구현을 고치지 말고 불일치 내용을 보고한다. + +--- + +## 프로젝트 컨벤션 + +### 파일 위치·이름 +- 소스 모듈 하나당 `tests/test_{모듈명}.py` (예: `collectors/kopis.py` → `tests/test_kopis.py`) +- 기존 파일이 있으면 새로 만들지 않고 그 파일에 추가한다. + +### 구조 +- 대상 함수별로 `class Test{대상}:`로 묶고, 메서드는 `test_{동작}_{조건}` 형식 +- 각 테스트에 **한국어 docstring**으로 기대 동작을 적는다 (예: `"""429 응답 시 재시도해야 한다."""`) +- assertion은 pytest 기본 `assert` 사용 + +### 환경변수·마커 +- `tests/conftest.py`가 필수 환경변수를 기본값으로 주입한다 — 새 필수 키가 생기면 `_TEST_ENV`에 추가한다. +- 기본 실행은 `-m 'not integration'`이다. 실제 DB·외부 API가 필요한 테스트는 `@pytest.mark.integration`을 붙인다. **단위 테스트에서 실제 네트워크·DB에 접근하지 않는다.** + +### Mocking +- 외부 HTTP: `patch("collectors.{모듈}.requests.get")` 또는 모듈 내부 헬퍼(`_fetch_detail` 등)를 patch +- rate limit 대기: `patch("collectors.{모듈}.time.sleep")`로 반드시 무력화 — 테스트가 실제로 sleep하면 안 된다. +- 로그 검증: `caplog.at_level(logging.INFO, logger="{모듈 경로}")` +- 환경변수 변경: `monkeypatch.setenv` / `monkeypatch.delenv` + +### `scheduler.py` patch 경로 +import 방식이 대상마다 달라 patch 경로도 다르다: +- `collectors/*`: 모듈째 import(`from collectors import kopis`) → `patch("scheduler.kopis.collect")` +- `db/repository.py`, `matchers/*`: 함수 단위 import → `patch("scheduler.get_all_aliases")`처럼 함수명 직접 patch + +### Python 3.9 호환 +- `X | Y`, `match` 문 금지 — 테스트 코드도 동일하다. + +--- + +## 작성 절차 + +1. **대상 분석**: 구현 파일(병렬 실행 중이면 호출자가 전달한 함수 시그니처·동작 명세)을 읽어 public 함수, 외부 호출 지점, 예외 조건을 파악한다. +2. **케이스 도출**: 정상 흐름 + 경계·실패 흐름(빈 응답, 404/429, 필수 키 누락, 중복 데이터 등) +3. **작성**: 위 컨벤션대로 작성한다. +4. **실행**: `python -m pytest tests/test_{모듈}.py -q` — 구현이 아직 없어 실패하면 그 사실을 보고한다. +5. **린트**: `ruff check tests/test_{모듈}.py` +6. **보고**: 추가한 테스트 목록과 통과 여부, 구현과의 불일치를 요약한다. + +--- + +## 주의사항 + +- 구현에 없는 동작을 테스트하지 않는다. +- 픽스처 데이터는 의미 있는 값을 쓴다 (예: 아티스트명 `"YOASOBI"`, KOPIS id `"PF123456"`). +- 테스트 메서드 하나는 동작 하나만 검증한다. diff --git a/.claude/hooks/block_secrets.py b/.claude/hooks/block_secrets.py new file mode 100644 index 0000000..e39f299 --- /dev/null +++ b/.claude/hooks/block_secrets.py @@ -0,0 +1,79 @@ +"""PreToolUse 훅 — 시크릿 파일 읽기·쓰기 차단 (Read·Write·Edit·Grep·Bash). + +Read·Write·Edit는 대상 경로의 파일명을, Grep은 검색 경로와 glob을, +Bash는 명령을 토큰으로 나눠 각 토큰의 파일명을 검사한다. +따옴표·heredoc 안의 문장(커밋 메시지, PR 본문)은 한 토큰으로 묶이거나 제거되므로 걸리지 않는다. +문자열 조립(`f=.e; f=${f}nv`) 같은 의도적 우회까지 막지는 않는다 — 실수 방지용이다. +""" + +import fnmatch +import json +import os +import re +import shlex +import sys + +SECRET_NAME = re.compile(r"^\.env(rc|\..+)?$|\.secret|credentials") +HEREDOC = re.compile(r"<<-?\s*(['\"]?)(\w+)\1.*?\n(.*?)^\s*\2\s*$", re.S | re.M) +REDIRECT_PREFIX = re.compile(r"^[0-9&]*[<>|]+") +# Grep glob이 시크릿 파일을 겨냥하는지 판별할 대표 파일명 (rg -g는 .gitignore를 무시한다) +# `*`처럼 일반 파일에도 맞는 glob은 겨냥한 것이 아니므로 통과시킨다 +SECRET_SAMPLES = [".env", ".env.local", ".envrc", "x.secret", "credentials.json"] +NORMAL_SAMPLES = ["a.py", "README.md", "config.json"] + + +def _is_secret(path): + return bool(SECRET_NAME.search(os.path.basename(path.rstrip("/")))) + + +def _targets_secret(pattern): + if not pattern: + return False + hit = any(fnmatch.fnmatch(name, pattern) for name in SECRET_SAMPLES) + return hit and not any(fnmatch.fnmatch(name, pattern) for name in NORMAL_SAMPLES) + + +def _bash_targets(command): + command = HEREDOC.sub("", command) + lexer = shlex.shlex(command, posix=True, punctuation_chars=";&|<>()") + lexer.whitespace_split = True + try: + tokens = list(lexer) + except ValueError: + tokens = command.split() + # 공백이 든 토큰은 따옴표로 묶인 문장이므로 제외, `--env-file=.env`는 `=` 뒤만 본다 + tokens = [t for t in tokens if not re.search(r"\s", t)] + return [REDIRECT_PREFIX.sub("", t).rsplit("=", 1)[-1] for t in tokens] + + +def main(): + data = json.load(sys.stdin) + tool = data.get("tool_name", "") + tool_input = data.get("tool_input", {}) + + if tool == "Bash": + hits = [t for t in _bash_targets(tool_input.get("command", "")) if t and _is_secret(t)] + elif tool == "Grep": + path = tool_input.get("path", "") + pattern = tool_input.get("glob", "") + hits = [path] if path and _is_secret(path) else [] + if _targets_secret(pattern): + hits.append(pattern) + else: + path = tool_input.get("file_path", "") + hits = [path] if path and _is_secret(path) else [] + + if hits: + reason = "시크릿 파일 접근 차단: " + ", ".join(hits) + output = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + } + print(json.dumps(output, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/post_edit.py b/.claude/hooks/post_edit.py new file mode 100644 index 0000000..84ccd3b --- /dev/null +++ b/.claude/hooks/post_edit.py @@ -0,0 +1,63 @@ +"""PostToolUse 훅 — .py 파일 Write·Edit 후 ruff 정리 + 관련 테스트 실행. + +- ruff check --fix 는 F401(미사용 import)을 고치지 않는다 — import를 먼저 넣고 + 다음 Edit에서 사용하는 흐름에서 import가 지워지는 것을 막는다. +- 테스트 대상: 수정한 파일이 tests/test_*.py면 그 파일, 아니면 tests/test_{모듈}*.py +- 테스트가 실패하면 stderr + exit 2로 Claude에게 결과를 전달한다. +""" + +import glob +import json +import os +import subprocess +import sys + + +def main(): + data = json.load(sys.stdin) + path = data.get("tool_input", {}).get("file_path", "") + project = os.environ.get("CLAUDE_PROJECT_DIR") or data.get("cwd", "") + if not path.endswith(".py") or not project: + return + path = os.path.realpath(path) + project = os.path.realpath(project) + if not path.startswith(project + os.sep) or not os.path.exists(path): + return + + python = os.path.join(project, ".venv", "bin", "python") + if not os.path.exists(python): + python = sys.executable + + subprocess.run( + [python, "-m", "ruff", "check", "--fix", "--unfixable", "F401", path], + cwd=project, + capture_output=True, + ) + subprocess.run([python, "-m", "ruff", "format", path], cwd=project, capture_output=True) + + name = os.path.basename(path) + if os.path.dirname(path) == os.path.join(project, "tests"): + tests = [path] if name.startswith("test_") else [] + else: + stem = name[: -len(".py")] + tests = sorted(glob.glob(os.path.join(project, "tests", "test_" + stem + "*.py"))) + if not tests: + return + + result = subprocess.run( + [python, "-m", "pytest", *tests, "-q", "--no-header", "--no-cov"], + cwd=project, + capture_output=True, + text=True, + ) + if result.returncode not in (0, 5): # 5: 수집된 테스트 없음 (integration만 있는 경우) + tail = "\n".join(result.stdout.strip().splitlines()[-15:]) + print( + "테스트 실패 ({}):\n{}".format(", ".join(os.path.basename(t) for t in tests), tail), + file=sys.stderr, + ) + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/.claude/settings.json b/.claude/settings.json index ef26c57..8e4341c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -17,20 +17,32 @@ "WebSearch", "WebFetch(domain:musicbrainz.org)", "WebFetch(domain:api.setlist.fm)", - "WebFetch(domain:coverartarchive.org)", "WebFetch(domain:kopis.or.kr)", "WebFetch(domain:openapi.kopis.or.kr)", "WebFetch(domain:wiki.musicbrainz.org)" ] }, "hooks": { + "PreToolUse": [ + { + "matcher": "Read|Write|Edit|Grep|Bash", + "hooks": [ + { + "type": "command", + "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/block_secrets.py\"", + "statusMessage": "시크릿 파일 검사 중..." + } + ] + } + ], "PostToolUse": [ { - "matcher": "Edit|Write", + "matcher": "Write|Edit", "hooks": [ { "type": "command", - "command": "if echo \"$CLAUDE_TOOL_INPUT_FILE_PATH\" | grep -q '\\.py$'; then FILE_DIR=$(dirname \"$CLAUDE_TOOL_INPUT_FILE_PATH\"); PROJ=$(git -C \"$FILE_DIR\" rev-parse --show-toplevel 2>/dev/null); PYTHON=\"$PROJ/.venv/bin/python\"; if [ ! -f \"$PYTHON\" ]; then PYTHON=$(which python3); fi; \"$PYTHON\" -m ruff check --fix \"$CLAUDE_TOOL_INPUT_FILE_PATH\" 2>/dev/null; \"$PYTHON\" -m ruff format \"$CLAUDE_TOOL_INPUT_FILE_PATH\" 2>/dev/null; BASENAME=$(basename \"$CLAUDE_TOOL_INPUT_FILE_PATH\" .py); TEST_FILE=\"$PROJ/tests/test_${BASENAME}.py\"; if [ -n \"$PROJ\" ] && [ -f \"$TEST_FILE\" ]; then cd \"$PROJ\" && \"$PYTHON\" -m pytest \"$TEST_FILE\" -q --no-header 2>&1 | tail -5; fi; fi" + "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/post_edit.py\"", + "timeout": 120 } ] } diff --git a/.claude/skills/data-review/SKILL.md b/.claude/skills/data-review/SKILL.md new file mode 100644 index 0000000..47f1e15 --- /dev/null +++ b/.claude/skills/data-review/SKILL.md @@ -0,0 +1,57 @@ +--- +name: data-review +description: Coming Data 코드 작성 후 커밋·PR 전 필수 실행하는 Data 전문 코드 리뷰 스킬. DML-only, logging 사용, Python 3.9 문법, 외부 API rate limit, pyproject 패키지 등록 등 CLAUDE.md 규칙 준수 여부를 검토한다. "data-review 해줘", "Data 코드 리뷰해줘", "파이프라인 리뷰해줘" 또는 코드 작성 완료 후 커밋 전 검토를 요청하는 모든 상황에서 반드시 이 스킬을 사용한다. +--- + +# data-review + +변경된 Python 코드를 `references/data-checklist.md` 기준으로 검토하고 심각도별 이슈를 보고한다. + +## 참조 파일 + +- `references/data-checklist.md` — 검토 항목, 자동 검사 명령, 심각도 기준 + +## 실행 순서 + +1. 검토 대상 파일을 확정한다 — 커밋 전 변경과 브랜치에 이미 커밋된 변경을 모두 포함한다 + ```bash + git fetch -q origin main + BASE=$(git merge-base HEAD origin/main) + { git diff --name-only "$BASE"; git ls-files --others --exclude-standard; } | grep '\.py$' | sort -u + git diff "$BASE" -- '*.py' + git ls-files --others --exclude-standard -- '*.py' # untracked 대상 + ``` + - untracked 파일은 `git diff`에 내용이 나오지 않으므로 파일 전체를 읽어 검토한다 + - 대상 파일이 없으면 "검토할 Python 변경 없음"을 보고하고 종료한다 + +2. 프로젝트 규칙을 로드한다 — 레포 루트 `CLAUDE.md`의 "파이썬 버전", "코딩 규칙", "외부 API 정보" 섹션 + +3. `references/data-checklist.md`의 **자동 검사**를 대상 파일에 실행한다 + - grep 결과는 후보일 뿐이다 — 주석·문자열·테스트 픽스처 안의 매치는 diff를 읽고 걸러낸다 + +4. 같은 체크리스트의 **수동 검토** 항목을 diff 기준으로 확인한다 — 변경된 줄과 그 줄이 영향을 주는 호출부만 본다 + +5. 심각도별로 정리해 보고한다 + +6. 🔴 critical 이슈가 있으면 수정 후 재실행을 요청한다. 없으면 커밋 진행을 승인한다. + +## 결과 보고 + +``` +=== Data 코드 리뷰 결과 === +검토 파일: {파일 목록} + +🔴 critical: {N}건 +🟡 warning: {N}건 +🔵 suggestion: {N}건 + +{이슈 상세 목록 — `파일:라인` + 위반 규칙 + 수정 방향} + +{이슈 없으면: "✅ 커밋 진행 가능"} +{🔴 있으면: "🚫 커밋 전 critical 이슈를 수정하세요"} +``` + +## 주의사항 + +- 코드를 직접 수정하지 않는다 — 리뷰 결과만 보고한다. +- 변경되지 않은 기존 코드의 위반은 보고하지 않는다 (별도 이슈 제안으로만 언급). diff --git a/.claude/skills/data-review/references/data-checklist.md b/.claude/skills/data-review/references/data-checklist.md new file mode 100644 index 0000000..b5fabd1 --- /dev/null +++ b/.claude/skills/data-review/references/data-checklist.md @@ -0,0 +1,118 @@ +# Data 코드 리뷰 체크리스트 + +## 심각도 기준 + +| 심각도 | 의미 | +|--------|------| +| 🔴 critical | 즉시 수정 필요 (규칙 위반, 런타임 오류 가능, 외부 API 차단 위험) | +| 🟡 warning | 개선 권장 (운영 중 장애·누락 가능성, 유지보수성 저하) | +| 🔵 suggestion | 선택적 개선 (테스트 보강, 네이밍) | + +--- + +## 자동 검사 + +`{FILES}`는 SKILL.md 1단계에서 확정한 대상 파일 목록이다. 매치는 후보일 뿐이므로 diff를 읽고 확정한다 +(예: 정규식 문자열 `r"(?:twitter|x)\.com"`도 유니온 패턴에 걸린다). + +zsh는 변수를 단어 분리하지 않으므로 `$FILES`로 넘기지 말고 파일 경로를 직접 나열하거나 +`printf '%s\n' ... | xargs grep ...` 형태로 실행한다. + +```bash +# 1. DDL (critical) +grep -nE '\b(CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+(TABLE|INDEX|SCHEMA|SEQUENCE|VIEW|COLUMN|TYPE)\b' -i {FILES} + +# 2. print (critical) +grep -nE '(^|[^.[:alnum:]_])print\(' {FILES} + +# 3. Python 3.10+ 문법 (critical) +# ruff target-version이 py311이라 ruff로는 잡히지 않는다 — 반드시 grep으로 확인 +grep -nE '(:|->)\s*[^=#]*[][A-Za-z_]\s*\|\s*[A-Za-z_]' {FILES} # X | Y 타입 유니온 +grep -nE '^\s*match\s+.+:\s*$' {FILES} # match 문 + +# 4. 린트 +ruff check {FILES} +``` + +--- + +## 1. DB (DML only) + +### 🔴 critical +- DDL 구문(`CREATE`/`ALTER`/`DROP`/`TRUNCATE`) 또는 `metadata.create_all()`·`drop_all()` 호출 — 스키마는 Backend Flyway가 단일 관리 +- 스키마에 없는 컬럼·테이블을 가정한 쿼리 (ERD와 다르면 "Backend 마이그레이션 선행 필요"로 보고) + +### 🟡 warning +- `collectors/`·`matchers/`에서 세션·쿼리를 직접 다룸 (`db/repository.py` 함수로 모아야 함) +- 문자열 포맷으로 조립한 SQL (`f"... {value}"`) — 바인드 파라미터 사용 +- 루프 안에서 건별 커밋 (대량 수집 시 느려지고 중간 실패 시 부분 반영) + +--- + +## 2. 로깅 + +### 🔴 critical +- `print` 사용 + +### 🟡 warning +- `logging.getLogger(__name__)` 대신 root logger(`logging.info(...)`) 직접 사용 +- 예외를 삼키면서 로그도 남기지 않는 `except: pass` / `except Exception: pass` +- API 키·토큰·webhook URL을 로그에 출력 + +--- + +## 3. Python 3.9 호환 + +### 🔴 critical +- `X | Y` 타입 유니온 (`dict | None` 포함) → `Optional[X]` / `Union[X, Y]` +- `match` 문 → `if/elif` +- 3.10+ 전용 API (`zip(strict=True)`, `int.bit_count()`, `itertools.pairwise` 등) + +--- + +## 4. 외부 API + +### 🔴 critical +- MusicBrainz 요청 경로에 `time.sleep(1.1)` 이상 대기가 없음 — 기존 요청 헬퍼·`_RATE_LIMIT_SLEEP` 상수를 거치지 않는 새 호출부 포함 +- setlist.fm 요청에 `x-api-key` 또는 `Accept: application/json` 헤더 누락 +- Spotify 토큰을 `spotify_client.py` 밖에서 직접 발급 + +### 🟡 warning +- `requests` 호출에 `timeout` 미지정 +- 429/5xx 재시도 로직 없이 한 번 실패로 수집 전체 중단 +- Discord 알림 실패가 수집 로직으로 전파 (선택 기능은 실패해도 수집에 영향 없어야 함) + +--- + +## 5. 환경변수·패키지 + +### 🔴 critical +- 필수 API 키 미설정 시 모듈 로드 단계 `raise ValueError`가 없음 (호출 시점에 `None`으로 요청) +- 신규 최상위 패키지가 `pyproject.toml`의 `packages.find.include`에 미등록 +- `.env` 또는 실제 키 값이 diff에 포함 + +### 🟡 warning +- 선택 기능(알림 등)이 미설정 시 예외를 던짐 — 조용히 no-op 해야 함 +- 새 환경변수가 CLAUDE.md "외부 API 정보" 표에 기록되지 않음 (`.env.example`은 만들지 않음) +- 새 필수 환경변수가 `tests/conftest.py`의 `_TEST_ENV`에 없음 (테스트 수집 단계에서 실패) + +--- + +## 6. 테스트 + +### 🟡 warning +- 새 public 함수에 대응하는 `tests/test_{모듈}.py` 테스트 없음 +- 단위 테스트가 `time.sleep`을 patch하지 않아 실제로 대기 +- 실제 네트워크·DB 접근 테스트에 `@pytest.mark.integration` 누락 + +### 🔵 suggestion +- `scheduler.py` 테스트의 patch 경로가 import 방식과 불일치 (`collectors/*`는 `scheduler.{모듈}.함수`, `db`/`matchers`는 `scheduler.{함수}`) +- 실패 흐름(빈 응답, 404/429, 중복 데이터) 케이스 부족 + +--- + +## 7. 스케줄러 등록 + +### 🟡 warning +- 로직 없이 `scheduler.py`에 잡만 등록 (수집 로직 → 스케줄 등록 순서) +- 새 잡이 `run-job --job` 선택지나 CLAUDE.md "수집 파이프라인 단계" 표에 반영되지 않음 diff --git a/CLAUDE.md b/CLAUDE.md index 655f7ac..375cc2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # coming-data (jpop-data-collector) -Jpop 아티스트 및 내한공연 수집 파이프라인 (KOPIS / MusicBrainz / setlist.fm → DB) +Jpop 아티스트 및 내한공연 수집 파이프라인 (KOPIS / MusicBrainz / Spotify / setlist.fm → DB) ## 레포지토리 구조 @@ -11,7 +11,7 @@ coming-data/ │ ├── kopis.py # KOPIS API 수집 │ ├── ja_romanize.py # sort_name 로마자 표기 → 한글 alias 규칙 변환 │ ├── musicbrainz.py # MusicBrainz 아티스트·멤버 수집 -│ ├── release.py # MusicBrainz 릴리즈(앨범·싱글·EP) + 트랙·커버 수집 +│ ├── release.py # Spotify 릴리즈(앨범·싱글) + 트랙·커버 수집 │ ├── setlist.py # setlist.fm 셋리스트 수집 │ └── spotify_client.py # Spotify Client Credentials 토큰 발급·공통 요청 ├── matchers/ @@ -36,11 +36,12 @@ ruff check . # 린트 python scheduler.py # 상시 데몬 (APScheduler 크론 + 내부 API 서버, 기본 포트 8000) # 초기 1회 수집 (아티스트 → KOPIS 매칭 → 릴리즈). 플래그는 init 전용: -# --skip-artists / --skip-kopis / --skip-ja-romanize / --force-artists +# --skip-artists / --force-artists / --skip-kopis / --skip-ja-romanize +# --skip-releases / --skip-artist-image / --skip-setlist / --log-file {경로} python scheduler.py init [플래그...] python scheduler.py run-job --job {concert-status-update|new-concert-collect|release-update|ja-romanize|artist-image|setlist} # 단일 잡 즉시 실행 -python scheduler.py recover # 누락 이미지·릴리즈 재수집 +python scheduler.py recover # 누락 이미지·릴리즈 재수집 (--skip-releases / --skip-artist-image) python scheduler.py collect-release --artist-id {id} # 단건 릴리즈 수집 python scheduler.py collect-setlist # 단건 setlist 수집 ``` @@ -66,10 +67,9 @@ python scheduler.py collect-setlist # 단건 setlist 수집 | API | 엔드포인트 | Rate Limit | 비고 | |-----|-----------|------------|------| | KOPIS | `GET /openApi/restful/pblprfr` | 없음 | 주 1회 이상 권장 | -| MusicBrainz | `GET /ws/2/artist/`, `/ws/2/release-group/`, `/ws/2/release/` | **1 req/sec** | `time.sleep(1.1)` 필수 | -| Cover Art Archive | `GET https://coverartarchive.org/release-group/{mbid}/front` | 1 req/sec | 404 시 null 허용 | +| MusicBrainz | `GET /ws/2/artist/` | **1 req/sec** | `time.sleep(1.1)` 필수 | | setlist.fm | `GET https://api.setlist.fm/rest/1.0/search/setlists` | - | Header: `x-api-key`, `Accept: application/json` 필수 | -| Spotify Web API | `POST https://accounts.spotify.com/api/token`, `GET https://api.spotify.com/v1/artists/{id}`, `GET /v1/search` | rolling 30초 윈도우 (관대함) | Client Credentials Flow; `Authorization: Bearer {token}`; access_token 1h 캐시 | +| Spotify Web API | `POST https://accounts.spotify.com/api/token`, `GET https://api.spotify.com/v1/artists/{id}`, `GET /v1/artists/{id}/albums`, `GET /v1/albums/{id}`, `GET /v1/search` | rolling 30초 윈도우 (관대함) | Client Credentials Flow; `Authorization: Bearer {token}`; access_token 1h 캐시 | | Discord Webhook | `POST {webhook_url}` | 웹훅당 분당 약 30건 | 신규 공연 수집 결과 알림; `DISCORD_WEBHOOK_URL` 미설정 시 알림 생략 | ## 수집 파이프라인 단계 @@ -103,5 +103,30 @@ python scheduler.py collect-setlist # 단건 setlist 수집 ### 서브에이전트 패턴 -새 기능 구현 시 두 에이전트를 병렬로 사용한다: **Agent A**(구현: `collectors/`, `matchers/`, `db/`) + **Agent B**(테스트: `tests/`). 이후 `/test` → `/commit` → `/pr` 순으로 마무리. +로컬 에이전트(`.claude/agents/`) 두 개로 구현과 테스트를 나눈다. 담당 범위가 겹치지 않아 병렬 실행해도 충돌하지 않는다. + +| 에이전트 | 담당 | 수정 금지 | +|---------|------|----------| +| `data-implementer` | `collectors/`, `matchers/`, `db/`, `notifier/` (+ 패키지 등록 시 `pyproject.toml`) | `tests/`, 지시 없는 `scheduler.py`·`api.py` | +| `write-tests` | `tests/` | 구현 코드 전체 | + +**병렬 실행 임계치** — 아래 중 하나라도 해당하면 두 에이전트를 병렬로 띄운다: +- 새 public 함수(수집기·매처·DML)를 추가한다 +- 구현 파일 2개 이상을 수정한다 +- 새 테스트 파일이 필요하다 + +병렬로 띄울 때는 두 프롬프트에 **같은 함수 시그니처·동작 명세**를 넣는다 (write-tests는 구현 완료 전에 작성을 시작하므로). + +그 외(구현 파일 1개의 버그 수정·소규모 변경, 기존 테스트 파일에 케이스 1~2개 추가)는 에이전트 없이 직접 처리하거나 `data-implementer` → `write-tests` 순차 실행한다. + +### 커밋 전 체크리스트 + +`/test` → `/data-review` → `/commit` → `/pr` 순으로 마무리한다. + +- `/test`: 변경 파일 관련 테스트 통과 +- `/data-review`: 🔴 critical 0건 (DML-only, `print` 금지, Python 3.9 문법, rate limit, 패키지 등록 등) +- `ruff check .` 통과 — `target-version`이 py311이라 `X | Y`·`match`는 ruff가 못 잡는다. `/data-review`에서 확인한다 +- `.env`·시크릿 파일은 Read/Write/Edit/Grep/Bash PreToolUse 훅이 읽기·쓰기 모두 차단한다 (`.claude/hooks/block_secrets.py`) + — `.env` 값이 필요하면 직접 읽으려 하지 말고 변수 이름을 알려 사용자에게 확인을 요청한다 +- `.py` 편집 후 PostToolUse 훅이 ruff 정리 + 관련 테스트를 실행하고, 실패하면 결과를 돌려준다 (`.claude/hooks/post_edit.py`) diff --git a/README.md b/README.md index f8d27f9..38a5921 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,9 @@ `coming-data`는 Coming의 데이터 파트로, 다음 역할을 담당합니다. - KOPIS(공연예술통합전산망)에서 국내 공연 정보를 수집하고, MusicBrainz 아티스트 데이터와 매칭 -- MusicBrainz에서 아티스트·멤버·릴리즈(앨범/싱글/EP) 정보를 수집 +- MusicBrainz에서 아티스트·멤버 정보를 수집 - setlist.fm에서 공연 완료 후 셋리스트를 수집 -- Spotify에서 아티스트 프로필 이미지를 수집 +- Spotify에서 릴리즈(앨범/싱글)·트랙·커버와 아티스트 프로필 이미지를 수집 - 위 파이프라인을 APScheduler로 정기 실행하고, 백엔드(Spring)의 트리거 요청을 내부 API로 수신 ## 기술 스택 @@ -49,8 +49,8 @@ | 단계 | 수집 주기 | 진입점 | |------|---------|--------| | ① MusicBrainz 아티스트 | 초기 1회 | `run_initial_collect()` | -| ② 공연 상태 갱신 | 매일 04:00 | `run_concert_status_update()` | -| ③ 신규 공연 수집·매칭 | 매일 04:30 | `run_new_concert_collect()` | +| ② 공연 상태 갱신 | 매일 00:00 | `run_concert_status_update()` | +| ③ 신규 공연 수집·매칭 | 매일 00:30 | `run_new_concert_collect()` | | ④ 릴리즈 (앨범·트랙·커버) | 초기 + 매일 05:00 | `run_release_update()` | | ⑤ 아티스트 이미지 | 매주 목 02:00 | `run_artist_image_update()` | | ⑥ 로마자→한글 alias 변환 | 매주 목 03:00 | `run_ja_romanize_collect()` | @@ -63,10 +63,9 @@ | API | 엔드포인트 | Rate Limit | 비고 | |-----|-----------|------------|------| | KOPIS | `GET /openApi/restful/pblprfr` | 없음 | 주 1회 이상 권장 | -| MusicBrainz | `GET /ws/2/artist/`, `/ws/2/release-group/`, `/ws/2/release/` | 1 req/sec | | -| Cover Art Archive | `GET /release-group/{mbid}/front` | 1 req/sec | 404 시 null 허용 | -| setlist.fm | `GET /rest/1.0/search/setlists` | - | `x-api-key` 헤더 필요 | -| Spotify Web API | `POST /api/token`, `GET /v1/artists/{id}`, `GET /v1/search` | rolling 30초 윈도우 | Client Credentials Flow | +| MusicBrainz | `GET /ws/2/artist/` | 1 req/sec | 요청마다 1.1초 대기 | +| setlist.fm | `GET /rest/1.0/search/setlists` | - | `x-api-key`, `Accept: application/json` 헤더 필요 | +| Spotify Web API | `POST /api/token`, `GET /v1/artists/{id}`, `GET /v1/artists/{id}/albums`, `GET /v1/albums/{id}`, `GET /v1/search` | rolling 30초 윈도우 | Client Credentials Flow, 릴리즈·이미지 수집 | | Discord Webhook | `POST {webhook_url}` | 웹훅당 분당 약 30건 | 신규 공연 알림, 미설정 시 생략 | ## 레포지토리 구조 @@ -78,7 +77,7 @@ coming-data/ │ ├── kopis.py # KOPIS API 수집 │ ├── ja_romanize.py # sort_name 로마자 표기 → 한글 alias 규칙 변환 │ ├── musicbrainz.py # MusicBrainz 아티스트·멤버 수집 -│ ├── release.py # MusicBrainz 릴리즈(앨범·싱글·EP) + 트랙·커버 수집 +│ ├── release.py # Spotify 릴리즈(앨범·싱글) + 트랙·커버 수집 │ ├── setlist.py # setlist.fm 셋리스트 수집 │ └── spotify_client.py # Spotify Client Credentials 토큰 발급·공통 요청 ├── matchers/ @@ -89,8 +88,16 @@ coming-data/ │ ├── connection.py # SQLAlchemy 엔진·세션 설정 │ └── repository.py # DB 저장 함수 (DML) ├── tests/ # pytest 단위 테스트 +├── docs/ +│ └── pipeline.md # 파이프라인 상세 로직·매칭 알고리즘 +├── .claude/ # Claude Code 설정 (아래 "AI 협업 워크플로우" 참고) +│ ├── agents/ # data-implementer, write-tests 로컬 에이전트 +│ ├── skills/ # data-review, test 로컬 스킬 +│ ├── hooks/ # 훅 스크립트 (시크릿 차단, 편집 후 ruff·테스트) +│ └── settings.json # 권한·훅 등록 ├── scheduler.py # APScheduler 진입점 (내부 API 서버도 함께 기동) ├── api.py # 내부 FastAPI 서버 — BE→Data 수집 트리거 (X-Internal-Secret 인증) +├── Dockerfile └── pyproject.toml ``` @@ -120,8 +127,10 @@ MUSICBRAINZ_USER_AGENT= SETLISTFM_API_KEY= SPOTIFY_CLIENT_ID= SPOTIFY_CLIENT_SECRET= -INTERNAL_SECRET= +INTERNAL_SECRET= # 미설정 시 내부 API 요청이 모두 401 DISCORD_WEBHOOK_URL= # 선택 — 미설정 시 알림 생략 +API_HOST= # 선택 — 내부 API 바인딩 호스트 (기본 0.0.0.0) +API_PORT= # 선택 — 내부 API 포트 (기본 8000) ``` ### 실행 @@ -129,6 +138,8 @@ DISCORD_WEBHOOK_URL= # 선택 — 미설정 시 알림 생략 ```bash # 초기 1회 수집 (아티스트 → KOPIS 매칭 → 릴리즈) python scheduler.py init +# init 전용 플래그: --skip-artists / --force-artists / --skip-kopis / --skip-ja-romanize +# --skip-releases / --skip-artist-image / --skip-setlist / --log-file {경로} # 상시 데몬 (APScheduler 크론 + 내부 API 서버, 기본 포트 8000) python scheduler.py @@ -136,8 +147,12 @@ python scheduler.py # 단일 잡 즉시 실행 python scheduler.py run-job --job {concert-status-update|new-concert-collect|release-update|ja-romanize|artist-image|setlist} -# 누락 이미지·릴리즈 재수집 +# 누락 이미지·릴리즈 재수집 (--skip-releases / --skip-artist-image 사용 가능) python scheduler.py recover + +# 단건 수집 +python scheduler.py collect-release --artist-id {id} # 아티스트 1명의 릴리즈 +python scheduler.py collect-setlist # 공연완료 공연 셋리스트 ``` ## 테스트 & 린트 @@ -152,6 +167,66 @@ ruff check . # 린트 - **CI** ([`ci.yml`](.github/workflows/ci.yml)): 모든 브랜치 push 및 main 대상 PR에서 `ruff check .` + `pytest` 실행 - **CD** ([`cd.yml`](.github/workflows/cd.yml)): main 브랜치 push 시 Docker 이미지를 빌드해 GHCR에 푸시하고, SSH로 운영 서버에 접속해 `docker compose`로 배포 +## AI 협업 워크플로우 + +Claude Code 에이전트·스킬·훅으로 이슈부터 PR까지 진행합니다. 프로젝트 규칙은 [`CLAUDE.md`](CLAUDE.md)에 모여 있고, 아래 도구들이 그 규칙을 역할별로 나눠 강제합니다. + +### 흐름 + +``` +/issue → /plan-issue → data-implementer ∥ write-tests → /test → /data-review → /commit → /pr +``` + +| 단계 | 도구 | 하는 일 | +|------|------|--------| +| 이슈·브랜치 | `/issue` | GitHub 이슈 생성 + `{type}/#{번호}-...` 브랜치 체크아웃 | +| 계획 | `/plan-issue` | 이슈 체크리스트를 코드 현황과 대조하고, 남은 작업을 커밋 단위로 순서화 | +| 구현 | `data-implementer` 에이전트 | `collectors/`, `matchers/`, `db/`, `notifier/` 구현 | +| 테스트 작성 | `write-tests` 에이전트 | `tests/` 단위 테스트 작성 | +| 테스트 실행 | `/test` | 변경 파일과 매핑되는 테스트만 선별 실행, 실패 시 원인 분석 | +| 리뷰 | `/data-review` | 프로젝트 규칙 위반 검토 — 🔴 critical 0건이어야 커밋 | +| 커밋·PR | `/commit`, `/pr` | 컨벤션(`[{type}] 요약`)에 맞춘 커밋·PR 작성 | + +`data-review`, `test` 스킬과 두 에이전트는 이 레포의 [`.claude/`](.claude/)에 포함돼 있습니다. `/issue`, `/plan-issue`, `/commit`, `/pr`은 작성자의 전역 Claude Code 스킬이라 레포에는 없습니다. + +### 에이전트 역할 분리와 병렬 실행 + +두 에이전트는 수정 가능한 디렉터리가 겹치지 않아 동시에 실행해도 충돌하지 않습니다. + +| 에이전트 | 수정 가능 | 수정 금지 | +|---------|----------|----------| +| [`data-implementer`](.claude/agents/data-implementer.md) | `collectors/`, `matchers/`, `db/`, `notifier/`, (패키지 등록 시) `pyproject.toml` | `tests/`, 지시 없는 `scheduler.py`·`api.py` | +| [`write-tests`](.claude/agents/write-tests.md) | `tests/` | 구현 코드 전체 | + +다음 중 하나라도 해당하면 병렬로 실행하고, 두 에이전트에 같은 함수 시그니처·동작 명세를 전달합니다. + +- 새 public 함수(수집기·매처·DML)를 추가한다 +- 구현 파일 2개 이상을 수정한다 +- 새 테스트 파일이 필요하다 + +구현 파일 1개의 소규모 수정은 에이전트 없이 처리하거나 구현 → 테스트 순으로 순차 실행합니다. + +### 코드 리뷰 (`/data-review`) + +[체크리스트](.claude/skills/data-review/references/data-checklist.md)는 grep 자동 검사와 diff 기반 수동 검토로 나뉩니다. + +- **DB**: DML만 허용, DDL 금지 (스키마는 백엔드 Flyway가 관리) +- **로깅**: `print` 금지, `logging.getLogger(__name__)` 사용 +- **Python 3.9 문법**: `X | Y` 유니온·`match` 문 금지 — ruff `target-version`이 py311이라 린트로는 잡히지 않아 별도로 검사 +- **외부 API**: MusicBrainz 1.1초 대기, setlist.fm 필수 헤더, Spotify 토큰은 공통 클라이언트로만 발급 +- **환경변수·패키지**: 필수 키 미설정 시 모듈 로드 단계에서 `ValueError`, 선택 기능은 no-op, 신규 최상위 패키지의 `pyproject.toml` 등록 + +### 훅 ([`.claude/hooks/`](.claude/hooks/), 등록은 [`.claude/settings.json`](.claude/settings.json)) + +| 시점 | 대상 | 동작 | +|------|------|------| +| PreToolUse ([`block_secrets.py`](.claude/hooks/block_secrets.py)) | Read·Write·Edit·Grep·Bash | 파일명이 `.env`·`.env.*`·`.envrc`이거나 `.secret`·`credentials`를 포함하면 읽기·쓰기 모두 차단. Grep은 검색 경로와 glob, Bash는 명령의 각 토큰을 검사하고 따옴표·heredoc 안 문장은 제외 | +| PostToolUse ([`post_edit.py`](.claude/hooks/post_edit.py)) | `.py` 파일 Write·Edit | `ruff check --fix`(F401 미사용 import는 자동 삭제 안 함) + `ruff format` 후 관련 테스트 실행 — `tests/test_*.py` 수정 시 그 파일, 그 외엔 `tests/test_{모듈}*.py`. 실패하면 결과를 Claude에게 전달 | + +Bash 훅은 실수 방지용입니다. 문자열 조립처럼 의도적으로 우회하는 명령까지 막지는 않습니다. + +시크릿이 대화 기록에 남지 않도록 읽기도 막습니다. 따라서 Claude는 `.env` 내용을 직접 볼 수 없으므로, 환경변수 문제는 변수 이름을 알려주면 사용자가 값을 확인하는 방식으로 디버깅합니다. + ## 관련 레포지토리 - [Backend](https://github.com/Cominggg/Backend) — Spring Boot API 서버 diff --git a/collectors/artist_image.py b/collectors/artist_image.py index 1e4cc9b..190c5b1 100644 --- a/collectors/artist_image.py +++ b/collectors/artist_image.py @@ -103,8 +103,11 @@ def collect_artist_image( if not mb_name: logger.info("MusicBrainz 아티스트 정보 없음: mbid=%s", mbid) return None, None - logger.info("MB URL relations에 Spotify 없음 — 이름 검색 fallback: mbid=%s name=%s", - mbid, mb_name) + logger.info( + "MB URL relations에 Spotify 없음 — 이름 검색 fallback: mbid=%s name=%s", + mbid, + mb_name, + ) spotify_id = _search_spotify_artist(mb_name) if not spotify_id: diff --git a/collectors/ja_romanize.py b/collectors/ja_romanize.py index e037228..8659c8b 100644 --- a/collectors/ja_romanize.py +++ b/collectors/ja_romanize.py @@ -16,37 +16,109 @@ # 청음(무성 파열음/파찰음) 계열: 어두=예사소리, 어중=거센소리로 갈라 적는다 (표기법 세칙). # 그 외 계열(탁음·비음·유음·마찰음 등)은 위치와 무관하게 고정 표기. _ALTERNATING = { - "ka": ("가", "카"), "ki": ("기", "키"), "ku": ("구", "쿠"), - "ke": ("게", "케"), "ko": ("고", "코"), - "kya": ("갸", "캬"), "kyu": ("규", "큐"), "kyo": ("교", "쿄"), - "ta": ("다", "타"), "te": ("데", "테"), "to": ("도", "토"), + "ka": ("가", "카"), + "ki": ("기", "키"), + "ku": ("구", "쿠"), + "ke": ("게", "케"), + "ko": ("고", "코"), + "kya": ("갸", "캬"), + "kyu": ("규", "큐"), + "kyo": ("교", "쿄"), + "ta": ("다", "타"), + "te": ("데", "테"), + "to": ("도", "토"), "chi": ("지", "치"), - "cha": ("자", "차"), "chu": ("주", "추"), "cho": ("조", "초"), + "cha": ("자", "차"), + "chu": ("주", "추"), + "cho": ("조", "초"), } _FIXED = { - "a": "아", "i": "이", "u": "우", "e": "에", "o": "오", - "ga": "가", "gi": "기", "gu": "구", "ge": "게", "go": "고", - "gya": "갸", "gyu": "규", "gyo": "교", - "sa": "사", "shi": "시", "su": "스", "se": "세", "so": "소", - "sha": "샤", "shu": "슈", "sho": "쇼", - "za": "자", "ji": "지", "zu": "즈", "ze": "제", "zo": "조", - "ja": "자", "ju": "주", "jo": "조", + "a": "아", + "i": "이", + "u": "우", + "e": "에", + "o": "오", + "ga": "가", + "gi": "기", + "gu": "구", + "ge": "게", + "go": "고", + "gya": "갸", + "gyu": "규", + "gyo": "교", + "sa": "사", + "shi": "시", + "su": "스", + "se": "세", + "so": "소", + "sha": "샤", + "shu": "슈", + "sho": "쇼", + "za": "자", + "ji": "지", + "zu": "즈", + "ze": "제", + "zo": "조", + "ja": "자", + "ju": "주", + "jo": "조", "tsu": "쓰", - "da": "다", "de": "데", "do": "도", - "na": "나", "ni": "니", "nu": "누", "ne": "네", "no": "노", - "nya": "냐", "nyu": "뉴", "nyo": "뇨", - "ha": "하", "hi": "히", "fu": "후", "he": "헤", "ho": "호", - "hya": "햐", "hyu": "휴", "hyo": "효", - "ba": "바", "bi": "비", "bu": "부", "be": "베", "bo": "보", - "bya": "뱌", "byu": "뷰", "byo": "뵤", - "pa": "파", "pi": "피", "pu": "푸", "pe": "페", "po": "포", - "pya": "퍄", "pyu": "퓨", "pyo": "표", - "ma": "마", "mi": "미", "mu": "무", "me": "메", "mo": "모", - "mya": "먀", "myu": "뮤", "myo": "묘", - "ya": "야", "yu": "유", "yo": "요", - "ra": "라", "ri": "리", "ru": "루", "re": "레", "ro": "로", - "rya": "랴", "ryu": "류", "ryo": "료", - "wa": "와", "wo": "오", + "da": "다", + "de": "데", + "do": "도", + "na": "나", + "ni": "니", + "nu": "누", + "ne": "네", + "no": "노", + "nya": "냐", + "nyu": "뉴", + "nyo": "뇨", + "ha": "하", + "hi": "히", + "fu": "후", + "he": "헤", + "ho": "호", + "hya": "햐", + "hyu": "휴", + "hyo": "효", + "ba": "바", + "bi": "비", + "bu": "부", + "be": "베", + "bo": "보", + "bya": "뱌", + "byu": "뷰", + "byo": "뵤", + "pa": "파", + "pi": "피", + "pu": "푸", + "pe": "페", + "po": "포", + "pya": "퍄", + "pyu": "퓨", + "pyo": "표", + "ma": "마", + "mi": "미", + "mu": "무", + "me": "메", + "mo": "모", + "mya": "먀", + "myu": "뮤", + "myo": "묘", + "ya": "야", + "yu": "유", + "yo": "요", + "ra": "라", + "ri": "리", + "ru": "루", + "re": "레", + "ro": "로", + "rya": "랴", + "ryu": "류", + "ryo": "료", + "wa": "와", + "wo": "오", } _SOKUON_PREFIXES = ("kk", "ss", "tt", "pp", "cch", "tch") _NON_ROMAJI_RE = re.compile(r"[^a-z0-9\s]") @@ -73,7 +145,7 @@ def _add_batchim(syllable: str, jong: int) -> str: def _match_mora(word: str, pos: int) -> Optional[tuple]: """word[pos:]에서 가장 긴 모라를 찾아 (음소 키, 소비한 길이)를 반환한다. 실패 시 None.""" for length in (3, 2, 1): - chunk = word[pos:pos + length] + chunk = word[pos : pos + length] if chunk in _ALTERNATING or chunk in _FIXED: return chunk, length return None diff --git a/collectors/kopis.py b/collectors/kopis.py index d0f3f85..6ca7ded 100644 --- a/collectors/kopis.py +++ b/collectors/kopis.py @@ -73,8 +73,13 @@ def _parse_relates(relates_elem: Optional[ET.Element]) -> list[dict]: _DETAIL_FALLBACK = { - "prfcast": None, "poster_url": None, "price": None, - "relates": [], "updatedate": None, "visit": None, "still_urls": [], + "prfcast": None, + "poster_url": None, + "price": None, + "relates": [], + "updatedate": None, + "visit": None, + "still_urls": [], } _DETAIL_WORKERS = 8 @@ -96,7 +101,8 @@ def _fetch_detail(kopis_id: str) -> dict: styurls_elem = db.find("styurls") still_urls = ( [el.text for el in styurls_elem.findall("styurl") if el.text] - if styurls_elem is not None else [] + if styurls_elem is not None + else [] ) return { "prfcast": _text(db, "prfcast"), @@ -153,8 +159,7 @@ def search_concerts(title: str) -> list[dict]: "end_date": _parse_kopis_date(_text(item, "prfpdto")), "venue": _text(item, "fcltynm"), "url": ( - "https://kopis.or.kr/por/db/pblprfr/pblprfrView.do" - f"?mt20Id={_text(item, 'mt20id')}" + f"https://kopis.or.kr/por/db/pblprfr/pblprfrView.do?mt20Id={_text(item, 'mt20id')}" ), } for item in root.findall("db") @@ -177,7 +182,8 @@ def collect_by_id(kopis_id: str) -> Optional[dict]: styurls_elem = db.find("styurls") still_urls = ( [el.text for el in styurls_elem.findall("styurl") if el.text] - if styurls_elem is not None else [] + if styurls_elem is not None + else [] ) return { "kopis_id": _text(db, "mt20id") or kopis_id, @@ -207,17 +213,25 @@ def _fetch_and_merge(concert: dict) -> Optional[dict]: if attempt == 3: logger.warning( "상세 API 3회 실패 — 폴백 적용: kopis_id=%s, %s", - concert["kopis_id"], e, + concert["kopis_id"], + e, + ) + concert.update( + { + "poster_url": None, + "price": None, + "relates": [], + "updatedate": None, + "visit": None, + "still_urls": [], + } ) - concert.update({ - "poster_url": None, "price": None, - "relates": [], "updatedate": None, "visit": None, - "still_urls": [], - }) else: logger.debug( "상세 API 재시도 %d/3: kopis_id=%s, %s", - attempt, concert["kopis_id"], e, + attempt, + concert["kopis_id"], + e, ) time.sleep(5 * attempt) if concert.get("visit") != "Y": @@ -251,8 +265,10 @@ def collect( while True: logger.debug("KOPIS 페이지 조회: cpage=%d", cpage) params = { - **_DEFAULT_PARAMS, "cpage": cpage, - "stdate": resolved_stdate, "eddate": resolved_eddate, + **_DEFAULT_PARAMS, + "cpage": cpage, + "stdate": resolved_stdate, + "eddate": resolved_eddate, } if afterdate: params["afterdate"] = afterdate @@ -273,18 +289,18 @@ def collect( raise logger.warning( "KOPIS 페이지 조회 실패 (attempt %d/3, status=%s): cpage=%d", - attempt, status, cpage, + attempt, + status, + cpage, ) - time.sleep(2 ** attempt) + time.sleep(2**attempt) batch = root.findall("db") if not batch: break concerts_to_fetch = [ - _parse_concert(item) - for item in batch - if _text(item, "genrenm") == "대중음악" + _parse_concert(item) for item in batch if _text(item, "genrenm") == "대중음악" ] with ThreadPoolExecutor(max_workers=_DETAIL_WORKERS) as pool: diff --git a/collectors/musicbrainz.py b/collectors/musicbrainz.py index 292043a..e11c869 100644 --- a/collectors/musicbrainz.py +++ b/collectors/musicbrainz.py @@ -36,10 +36,10 @@ # youtu.be는 영상 단축 URL이므로 아티스트 채널로 부적합 → 제외 _URL_PATTERNS: dict = { - "Spotify": re.compile(r"open\.spotify\.com/artist/[A-Za-z0-9]+"), - "YouTube": re.compile(r"youtube\.com/(@|channel/|c/|user/)"), - "Instagram": re.compile(r"instagram\.com/[^/?#]+"), - "Twitter": re.compile(r"(?:twitter|x)\.com/[^/?#]+"), + "Spotify": re.compile(r"open\.spotify\.com/artist/[A-Za-z0-9]+"), + "YouTube": re.compile(r"youtube\.com/(@|channel/|c/|user/)"), + "Instagram": re.compile(r"instagram\.com/[^/?#]+"), + "Twitter": re.compile(r"(?:twitter|x)\.com/[^/?#]+"), "AppleMusic": re.compile(r"music\.apple\.com/"), } diff --git a/collectors/release.py b/collectors/release.py index 22fce16..75bee98 100644 --- a/collectors/release.py +++ b/collectors/release.py @@ -148,9 +148,7 @@ def collect_releases( tracks_data = (album.get("tracks") or {}).get("items") or [] if (album.get("tracks") or {}).get("next"): - logger.warning( - "트랙 50개 초과 — 일부 누락 가능: spotify_id=%s", album.get("id") - ) + logger.warning("트랙 50개 초과 — 일부 누락 가능: spotify_id=%s", album.get("id")) images = album.get("images") or [] results.append( @@ -166,7 +164,5 @@ def collect_releases( } ) - logger.info( - "릴리즈 수집 완료: spotify_artist_id=%s, 총 %d건", spotify_artist_id, len(results) - ) + logger.info("릴리즈 수집 완료: spotify_artist_id=%s, 총 %d건", spotify_artist_id, len(results)) return results, spotify_total diff --git a/collectors/setlist.py b/collectors/setlist.py index 2028387..b995941 100644 --- a/collectors/setlist.py +++ b/collectors/setlist.py @@ -52,11 +52,13 @@ def _parse_tracks(sets_data: dict) -> list[dict]: position = 1 for s in sets_data.get("set", []): for song in s.get("song", []): - tracks.append({ - "position": position, - "song_name": song.get("name", ""), - "info": song.get("info"), - }) + tracks.append( + { + "position": position, + "song_name": song.get("name", ""), + "info": song.get("info"), + } + ) position += 1 return tracks @@ -75,7 +77,10 @@ def _event_date_in_range( except (ValueError, TypeError) as e: logger.warning( "날짜 파싱 실패: event_date=%s, from=%s, to=%s, %s", - event_date_str, prfpdfrom, prfpdto, e, + event_date_str, + prfpdfrom, + prfpdto, + e, ) return False @@ -111,8 +116,12 @@ def collect_for_concert(concert: dict) -> Optional[dict]: if not _event_date_in_range(event_date, concert["start_date"], concert["end_date"]): continue tracks = _parse_tracks(item.get("sets", {})) - logger.info("셋리스트 수집: concert_id=%d, setlist_fm_id=%s, 트랙 %d개", - concert_id, item.get("id"), len(tracks)) + logger.info( + "셋리스트 수집: concert_id=%d, setlist_fm_id=%s, 트랙 %d개", + concert_id, + item.get("id"), + len(tracks), + ) return { "concert_id": concert_id, "setlist_fm_id": item.get("id"), @@ -161,7 +170,9 @@ def collect() -> list[dict]: else: logger.warning( "setlist.fm API 오류 (3회 실패): concert_id=%d, page=%d, %s", - concert_id, page, e, + concert_id, + page, + e, ) update_concert_fetch_attempted(concert_id) break @@ -178,16 +189,20 @@ def collect() -> list[dict]: continue tracks = _parse_tracks(item.get("sets", {})) - results.append({ - "concert_id": concert_id, - "setlist_fm_id": item.get("id"), - "attribution_url": item.get("url"), - "tracks": tracks, - }) + results.append( + { + "concert_id": concert_id, + "setlist_fm_id": item.get("id"), + "attribution_url": item.get("url"), + "tracks": tracks, + } + ) seen_concert_ids.add(concert_id) logger.info( "셋리스트 수집: concert_id=%d, setlist_fm_id=%s, 트랙 %d개", - concert_id, item.get("id"), len(tracks), + concert_id, + item.get("id"), + len(tracks), ) update_concert_fetch_attempted(concert_id) matched = True diff --git a/collectors/spotify_client.py b/collectors/spotify_client.py index 72cc9c9..a024fb2 100644 --- a/collectors/spotify_client.py +++ b/collectors/spotify_client.py @@ -17,6 +17,7 @@ def __init__(self, *args, retry_after: int = 0, **kwargs): super().__init__(*args, **kwargs) self.retry_after = retry_after + logger = logging.getLogger(__name__) _SPOTIFY_AUTH_URL = "https://accounts.spotify.com/api/token" @@ -65,7 +66,9 @@ def spotify_get(path: str, params: Optional[dict] = None) -> dict: if response.status_code == 429: retry_after = int(response.headers.get("Retry-After", 0)) logger.error( - "Spotify 429 — 수집 중단 (Retry-After=%ds): %s", retry_after, path, + "Spotify 429 — 수집 중단 (Retry-After=%ds): %s", + retry_after, + path, ) raise SpotifyRateLimitError( f"Spotify 429: Retry-After={retry_after}s, path={path}", diff --git a/db/repository.py b/db/repository.py index 0391f66..8745134 100644 --- a/db/repository.py +++ b/db/repository.py @@ -224,7 +224,9 @@ def save_concerts(concerts: list[dict], use_prfstate: bool = False) -> set: logger.info( "동일 공연명·기간 또는 동일 아티스트·기간 이미 존재 — 저장 대신 " "kopis_id 연동: title=%s, kopis_id=%s, concert_id=%s", - concert["prfnm"], concert["kopis_id"], matched_id, + concert["prfnm"], + concert["kopis_id"], + matched_id, ) duplicate_kopis_ids.add(concert["kopis_id"]) continue @@ -299,7 +301,9 @@ def save_concerts(concerts: list[dict], use_prfstate: bool = False) -> set: logger.error("공연 저장 실패 — 건너뜀: kopis_id=%s, 오류=%s", concert["kopis_id"], e) logger.info( "공연 저장 완료: %d / %d건 처리 (title·기간 중복 %d건 제외)", - saved, len(concerts), len(duplicate_kopis_ids), + saved, + len(concerts), + len(duplicate_kopis_ids), ) return duplicate_kopis_ids @@ -380,7 +384,8 @@ def save_setlists(setlists: list[dict]) -> None: except SQLAlchemyError as e: logger.error( "셋리스트 저장 실패 — 건너뜀: setlist_fm_id=%s, 오류=%s", - item.get("setlist_fm_id"), e, + item.get("setlist_fm_id"), + e, ) logger.info("셋리스트 저장 완료: %d / %d건 처리", saved, len(setlists)) @@ -447,7 +452,9 @@ def save_aliases(aliases: list[dict]) -> None: except SQLAlchemyError as e: logger.error( "alias 저장 실패 — 건너뜀: artist_id=%s, name=%s, 오류=%s", - alias["artist_id"], alias["name"], e, + alias["artist_id"], + alias["name"], + e, ) logger.info("alias 저장 완료: %d / %d건 처리", saved, len(aliases)) @@ -548,7 +555,7 @@ def get_artist_names_by_ids(artist_ids: list) -> dict: return {} with get_session() as session: rows = session.execute( - text('SELECT id, name FROM artist WHERE id = ANY(:ids)'), + text("SELECT id, name FROM artist WHERE id = ANY(:ids)"), {"ids": artist_ids}, ).fetchall() return {row[0]: row[1] for row in rows} @@ -686,7 +693,9 @@ def save_concert_artists(matches: list[dict]) -> None: except SQLAlchemyError as e: logger.error( "공연-아티스트 매칭 저장 실패 — 건너뜀: concert_id=%s, artist_id=%s, 오류=%s", - match["concert_id"], match["artist_id"], e, + match["concert_id"], + match["artist_id"], + e, ) logger.info("공연-아티스트 매칭 저장 완료: %d / %d건 처리", saved, len(matches)) @@ -716,12 +725,13 @@ def save_concert_artist_candidates(matches: list[dict]) -> None: except SQLAlchemyError as e: logger.error( "공연-아티스트 후보 저장 실패 — 건너뜀: concert_id=%s, artist_id=%s, 오류=%s", - match["concert_id"], match["artist_id"], e, + match["concert_id"], + match["artist_id"], + e, ) logger.info("공연-아티스트 후보 저장 완료: %d / %d건 처리", saved, len(matches)) - def update_artist_is_coming() -> int: """오늘 이후 공연 보유 여부에 따라 artist.is_coming을 갱신한다. @@ -784,8 +794,7 @@ def get_active_concerts() -> list[dict]: """) ).fetchall() return [ - {"kopis_id": row[0], "kopis_update_date": str(row[1]) if row[1] else None} - for row in rows + {"kopis_id": row[0], "kopis_update_date": str(row[1]) if row[1] else None} for row in rows ] diff --git a/docs/pipeline.md b/docs/pipeline.md index 78d2cc6..1d15eb4 100644 --- a/docs/pipeline.md +++ b/docs/pipeline.md @@ -11,11 +11,15 @@ ## ② 릴리즈 수집 (초기 + 매일 05:00 갱신) -- release-group별 대표 release MBID 취득 후 트랙·커버 연속 수집 -- `first-release-date` 기준 DB에 없는 항목만 INSERT -- 앨범 커버: Cover Art Archive 404 시 null 허용 - -## ③ KOPIS 수집 (매일: 상태 갱신 04:00 / 신규 공연 탐지 04:30) +- 소스: Spotify Web API (`collectors/release.py`) — MusicBrainz release-group·Cover Art Archive는 사용하지 않는다 +- 대상: 공연 매칭(`concert_artist`)이 있고 `artist_url`에 Spotify 아티스트 URL을 보유한 아티스트 +- `GET /v1/artists/{id}/albums`(`include_groups=album,single`, `market=JP`)로 앨범 ID 목록 조회 → `GET /v1/albums/{id}`로 상세·트랙 수집 (compilation 제외) +- 증분 수집: DB에 캐시된 Spotify album total과 같으면 1콜로 종료, 이미 저장된 `spotify_id`는 상세 조회 생략 +- 저장: `release_group`에 `spotify_id` 기준 `ON CONFLICT DO UPDATE` +- 앨범 커버: Spotify `images[0].url`, 이미지 없으면 null 허용 +- 발매일: `YYYY-MM-DD` 형식만 저장, 부분 날짜(`YYYY`·`YYYY-MM`)는 null + +## ③ KOPIS 수집 (매일: 상태 갱신 00:00 / 신규 공연 탐지 00:30) - 조건: `visit=Y`, `genrenm=대중음악(GGGA)` - 저장 전 `has_match()`로 alias 매칭 공연만 필터링하여 저장 (비매칭 공연은 DB에 저장하지 않음) diff --git a/matchers/artist_matcher.py b/matchers/artist_matcher.py index 000a93f..8f621df 100644 --- a/matchers/artist_matcher.py +++ b/matchers/artist_matcher.py @@ -28,9 +28,9 @@ def _phrase_match_title(title: str, aliases: list[dict]) -> list[dict]: name_norm = _normalize(name) name_words = name_norm.split() matched = ( - name_norm in title_tokens # 단일 단어: token exact + name_norm in title_tokens # 단일 단어: token exact if len(name_words) == 1 - else f" {name_norm} " in title_padded # 다중 단어: 단어 경계 구문 포함 + else f" {name_norm} " in title_padded # 다중 단어: 단어 경계 구문 포함 ) if matched: artist_id = alias["artist_id"] @@ -69,11 +69,12 @@ def match_concert(concert: dict, aliases: list[dict]) -> tuple[list[dict], list[ for alias in matched_aliases: logger.debug( "매칭(title): concert_id=%s, artist_id=%s, title=%s", - concert_id, alias["artist_id"], title, + concert_id, + alias["artist_id"], + title, ) matches = [ - {"concert_id": concert_id, "artist_id": alias["artist_id"]} - for alias in matched_aliases + {"concert_id": concert_id, "artist_id": alias["artist_id"]} for alias in matched_aliases ] return matches, [] diff --git a/scheduler.py b/scheduler.py index 38ef9d9..12bf5b5 100644 --- a/scheduler.py +++ b/scheduler.py @@ -381,7 +381,6 @@ def run_ja_romanize_collect() -> None: logger.info("=== 로마자→한글 alias 변환 잡 완료 ===") - def run_concert_status_update() -> None: """활성 공연 상태 갱신 (매일). DB의 진행 중 공연을 개별 API로 최신 상태로 갱신한다.""" with _job_timer("공연 상태 갱신 잡"): @@ -394,8 +393,7 @@ def run_concert_status_update() -> None: logger.info("활성 공연 %d건 상태 갱신 시작", len(active)) with ThreadPoolExecutor(max_workers=8) as pool: futures = { - pool.submit(kopis.collect_by_id, c["kopis_id"]): c["kopis_id"] - for c in active + pool.submit(kopis.collect_by_id, c["kopis_id"]): c["kopis_id"] for c in active } fetched = [] for future, kopis_id in futures.items(): @@ -434,8 +432,7 @@ def run_new_concert_collect(stdate: Optional[str] = None, use_prfstate: bool = F existing_ids = get_existing_kopis_ids() aliases = get_all_aliases() new_concerts = [ - c for c in concerts - if c["kopis_id"] not in existing_ids and has_match(c, aliases) + c for c in concerts if c["kopis_id"] not in existing_ids and has_match(c, aliases) ] for c in new_concerts: c["_matched_artist_ids"] = matched_artist_ids(c["prfnm"], aliases) @@ -460,9 +457,9 @@ def run_new_concert_collect(stdate: Optional[str] = None, use_prfstate: bool = F matches_by_concert: dict = {} for match in all_matches: if match["concert_id"] in title_by_concert_id: - matches_by_concert.setdefault( - match["concert_id"], [] - ).append(match["artist_id"]) + matches_by_concert.setdefault(match["concert_id"], []).append( + match["artist_id"] + ) if matches_by_concert: all_artist_ids = {aid for ids in matches_by_concert.values() for aid in ids} @@ -476,7 +473,6 @@ def run_new_concert_collect(stdate: Optional[str] = None, use_prfstate: bool = F logger.info("=== 신규 공연 탐지 잡 완료 ===") - def run_artist_image_update() -> None: """image_url 미수집 아티스트의 프로필 이미지를 수집한다 (주 1회, 목요일). @@ -520,9 +516,7 @@ def run_artist_image_update() -> None: else: failed[a["id"]] = today if not a.get("spotify_url") and spotify_id: - upsert_artist_url( - a["id"], "Spotify", artist_image.spotify_artist_url(spotify_id) - ) + upsert_artist_url(a["id"], "Spotify", artist_image.spotify_artist_url(spotify_id)) _save_failed_image_artists(failed) logger.info("=== 아티스트 이미지 수집 잡 완료 ===") @@ -725,7 +719,6 @@ def collect_and_save_concert(kopis_id: str) -> dict: } - def collect_and_save_releases_for_artist(artist_id: int) -> bool: """단건 아티스트의 릴리즈를 수집해 DB에 저장한다. 성공 시 True 반환. @@ -922,9 +915,7 @@ def main() -> None: "setlist": run_setlist_collect, } _ts = datetime.now().strftime("%Y%m%d_%H%M%S") - _log_path = os.path.join( - os.path.dirname(__file__), "logs", f"run_job_{args.job}_{_ts}.log" - ) + _log_path = os.path.join(os.path.dirname(__file__), "logs", f"run_job_{args.job}_{_ts}.log") _attach_file_handler(_log_path) _job_map[args.job]() return diff --git a/tests/test_api.py b/tests/test_api.py index de25c2d..8ea90be 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,4 +1,5 @@ """api.py 단위 테스트.""" + from unittest.mock import patch import pytest @@ -13,12 +14,14 @@ def set_secret(monkeypatch): import importlib import api + importlib.reload(api) @pytest.fixture() def client(): import api + return TestClient(api.app, raise_server_exceptions=False) @@ -85,15 +88,14 @@ def test_returns_200_with_success_false_when_skipped(self, client): def test_called_with_kopis_id(self, client): """collect_and_save_concert가 kopis_id로 직접 호출되어야 한다.""" - with patch( - "api.collect_and_save_concert", return_value={"status": "not_found"} - ) as mock_fn: + with patch("api.collect_and_save_concert", return_value={"status": "not_found"}) as mock_fn: client.post("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/collect/concert", json={"kopis_id": "PF123"}, headers=_AUTH) mock_fn.assert_called_once_with("PF123") def test_duplicate_request_returns_409(self, client): """동일 kopis_id가 이미 실행 중이면 409를 반환해야 한다.""" import api as api_module + key = ("concert", "PF_DUP") api_module._running_tasks.add(key) try: @@ -120,6 +122,7 @@ def test_background_task_called_with_artist_id(self, client): def test_duplicate_request_not_accepted(self, client): """동일 artist_id가 이미 실행 중이면 accepted:false를 반환해야 한다.""" import api as api_module + key = ("releases", 99) api_module._running_tasks.add(key) try: @@ -165,15 +168,14 @@ def test_returns_200_with_success_false_when_no_setlist(self, client): def test_called_with_concert_id(self, client): """collect_and_save_setlist가 concert_id로 직접 호출되어야 한다.""" - with patch( - "api.collect_and_save_setlist", return_value={"status": "not_found"} - ) as mock_fn: + with patch("api.collect_and_save_setlist", return_value={"status": "not_found"}) as mock_fn: client.post("/collect/concert/7/setlist", headers=_AUTH) mock_fn.assert_called_once_with(7) def test_duplicate_request_returns_409(self, client): """동일 concert_id가 이미 실행 중이면 409를 반환해야 한다.""" import api as api_module + key = ("setlist", 7) api_module._running_tasks.add(key) try: @@ -216,15 +218,14 @@ def test_returns_404_when_not_found(self, client): def test_called_with_mbid(self, client): """register_artist_by_mbid가 mbid로 직접 호출되어야 한다.""" - with patch( - "api.register_artist_by_mbid", return_value={"status": "not_found"} - ) as mock_fn: + with patch("api.register_artist_by_mbid", return_value={"status": "not_found"}) as mock_fn: client.post("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/collect/artist", json={"mbid": "mbid-xyz"}, headers=_AUTH) mock_fn.assert_called_once_with("mbid-xyz") def test_duplicate_request_returns_409(self, client): """동일 mbid가 이미 실행 중이면 409를 반환해야 한다.""" import api as api_module + key = ("artist", "mbid-dup") api_module._running_tasks.add(key) try: @@ -275,10 +276,15 @@ def test_missing_title_returns_422(self, client): def test_returns_concert_list(self, client): """검색 결과 리스트를 그대로 반환해야 한다.""" - concerts = [{ - "kopis_id": "PF001", "title": "BTS WORLD TOUR", - "start_date": "2024-06-01", "end_date": "2024-06-02", "venue": "KSPO DOME", - }] + concerts = [ + { + "kopis_id": "PF001", + "title": "BTS WORLD TOUR", + "start_date": "2024-06-01", + "end_date": "2024-06-02", + "venue": "KSPO DOME", + } + ] with patch("collectors.kopis.search_concerts", return_value=concerts) as mock_fn: res = client.get("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/search/concerts?title=BTS", headers=_AUTH) mock_fn.assert_called_once_with("BTS") diff --git a/tests/test_artist_image.py b/tests/test_artist_image.py index 3880f2d..1ccb00f 100644 --- a/tests/test_artist_image.py +++ b/tests/test_artist_image.py @@ -1,4 +1,5 @@ """collectors/artist_image.py 단위 테스트.""" + from unittest.mock import MagicMock, patch import pytest @@ -48,10 +49,13 @@ def test_returns_image_url_via_url_rels(self): images=[{"url": "https://i.scdn.co/image/large.jpg", "width": 640, "height": 640}] ) - with self._patch_env(), patch("collectors.artist_image.requests.post", - return_value=_make_token_response()), \ - patch("collectors.artist_image.requests.get", - side_effect=[mb_response, artist_response]): + with ( + self._patch_env(), + patch("collectors.artist_image.requests.post", return_value=_make_token_response()), + patch( + "collectors.artist_image.requests.get", side_effect=[mb_response, artist_response] + ), + ): image_url, spotify_id = collect_artist_image("mbid-001") assert image_url == "https://i.scdn.co/image/large.jpg" @@ -62,17 +66,19 @@ def test_returns_image_url_via_name_search(self): mb_response = _make_mb_response(name="Mrs. GREEN APPLE") search_response = MagicMock() search_response.status_code = 200 - search_response.json.return_value = { - "artists": {"items": [{"id": "spotify-xyz"}]} - } + search_response.json.return_value = {"artists": {"items": [{"id": "spotify-xyz"}]}} artist_response = _make_spotify_artist_response( images=[{"url": "https://i.scdn.co/image/fallback.jpg", "width": 640, "height": 640}] ) - with self._patch_env(), patch("collectors.artist_image.requests.post", - return_value=_make_token_response()), \ - patch("collectors.artist_image.requests.get", - side_effect=[mb_response, search_response, artist_response]): + with ( + self._patch_env(), + patch("collectors.artist_image.requests.post", return_value=_make_token_response()), + patch( + "collectors.artist_image.requests.get", + side_effect=[mb_response, search_response, artist_response], + ), + ): image_url, spotify_id = collect_artist_image("mbid-002") assert image_url == "https://i.scdn.co/image/fallback.jpg" @@ -83,9 +89,11 @@ def test_returns_none_when_mb_artist_not_found(self): mb_response = MagicMock() mb_response.status_code = 404 - with self._patch_env(), patch("collectors.artist_image.requests.post", - return_value=_make_token_response()), \ - patch("collectors.artist_image.requests.get", return_value=mb_response): + with ( + self._patch_env(), + patch("collectors.artist_image.requests.post", return_value=_make_token_response()), + patch("collectors.artist_image.requests.get", return_value=mb_response), + ): result = collect_artist_image("mbid-unknown") assert result == (None, None) @@ -97,10 +105,13 @@ def test_returns_none_when_spotify_id_not_found(self): search_response.status_code = 200 search_response.json.return_value = {"artists": {"items": []}} - with self._patch_env(), patch("collectors.artist_image.requests.post", - return_value=_make_token_response()), \ - patch("collectors.artist_image.requests.get", - side_effect=[mb_response, search_response]): + with ( + self._patch_env(), + patch("collectors.artist_image.requests.post", return_value=_make_token_response()), + patch( + "collectors.artist_image.requests.get", side_effect=[mb_response, search_response] + ), + ): result = collect_artist_image("mbid-003") assert result == (None, None) @@ -113,10 +124,13 @@ def test_returns_none_when_images_empty(self): ) artist_response = _make_spotify_artist_response(images=[]) - with self._patch_env(), patch("collectors.artist_image.requests.post", - return_value=_make_token_response()), \ - patch("collectors.artist_image.requests.get", - side_effect=[mb_response, artist_response]): + with ( + self._patch_env(), + patch("collectors.artist_image.requests.post", return_value=_make_token_response()), + patch( + "collectors.artist_image.requests.get", side_effect=[mb_response, artist_response] + ), + ): image_url, spotify_id = collect_artist_image("mbid-004") assert image_url is None @@ -129,9 +143,11 @@ def test_skips_mb_api_when_spotify_url_provided(self): ) mock_get = MagicMock(return_value=artist_response) - with self._patch_env(), patch("collectors.artist_image.requests.post", - return_value=_make_token_response()), \ - patch("collectors.artist_image.requests.get", mock_get): + with ( + self._patch_env(), + patch("collectors.artist_image.requests.post", return_value=_make_token_response()), + patch("collectors.artist_image.requests.get", mock_get), + ): image_url, spotify_id = collect_artist_image( "mbid-005", spotify_url="https://open.spotify.com/artist/direct-id", @@ -153,10 +169,14 @@ def test_skips_mb_api_when_name_provided(self): images=[{"url": "https://i.scdn.co/image/by-name.jpg", "width": 640, "height": 640}] ) - with self._patch_env(), patch("collectors.artist_image.requests.post", - return_value=_make_token_response()), \ - patch("collectors.artist_image.requests.get", - side_effect=[search_response, artist_response]) as mock_get: + with ( + self._patch_env(), + patch("collectors.artist_image.requests.post", return_value=_make_token_response()), + patch( + "collectors.artist_image.requests.get", + side_effect=[search_response, artist_response], + ) as mock_get, + ): image_url, spotify_id = collect_artist_image("mbid-006", name="YOASOBI") assert image_url == "https://i.scdn.co/image/by-name.jpg" @@ -168,14 +188,21 @@ def test_skips_mb_api_when_name_provided(self): def test_raises_when_credentials_missing(self): """SPOTIFY_CLIENT_ID 또는 SPOTIFY_CLIENT_SECRET 미설정 → ValueError.""" empty_cache = {"token": None, "expires_at": 0.0} - with patch.dict("os.environ", {}, clear=True), \ - patch("collectors.spotify_client._token_cache", empty_cache): + with ( + patch.dict("os.environ", {}, clear=True), + patch("collectors.spotify_client._token_cache", empty_cache), + ): with pytest.raises(ValueError, match="SPOTIFY_CLIENT_ID"): collect_artist_image("mbid-001", name="Test Artist") def test_raises_on_network_error(self): """네트워크 오류 시 RequestException 전파.""" - with self._patch_env(), patch("collectors.artist_image.requests.post", - side_effect=requests.ConnectionError("timeout")): + with ( + self._patch_env(), + patch( + "collectors.artist_image.requests.post", + side_effect=requests.ConnectionError("timeout"), + ), + ): with pytest.raises(requests.RequestException): collect_artist_image("mbid-001") diff --git a/tests/test_discord_notifier.py b/tests/test_discord_notifier.py index b64dbc7..e3ba635 100644 --- a/tests/test_discord_notifier.py +++ b/tests/test_discord_notifier.py @@ -1,4 +1,5 @@ """notifier/discord.py 단위 테스트.""" + from unittest.mock import patch from notifier.discord import notify_new_concert diff --git a/tests/test_kopis.py b/tests/test_kopis.py index 7e84b6c..ee2222c 100644 --- a/tests/test_kopis.py +++ b/tests/test_kopis.py @@ -1,4 +1,5 @@ """collectors/kopis.py 단위 테스트.""" + import xml.etree.ElementTree as ET from typing import Optional from unittest.mock import MagicMock, patch @@ -22,6 +23,7 @@ # ─── XML 헬퍼 ──────────────────────────────────────────────────────────────── + def _make_list_content(*items: dict) -> bytes: """목록 API XML 응답 bytes 생성. 각 item은 필드 dict.""" root = ET.Element("dbs") @@ -106,14 +108,18 @@ def _make_db_elem(**fields) -> ET.Element: # ─── TestFetchAndMerge ──────────────────────────────────────────────────────── + class TestFetchAndMerge: def test_returns_merged_concert_when_visit_y(self): """detail 병합 후 visit=Y이면 concert dict를 반환해야 한다.""" concert = {"kopis_id": "PF123456", "prfnm": "공연명"} detail = { - "visit": "Y", "poster_url": "http://poster.jpg", - "price": None, "relates": [], - "updatedate": "2024-01-15", "prfcast": "아티스트", + "visit": "Y", + "poster_url": "http://poster.jpg", + "price": None, + "relates": [], + "updatedate": "2024-01-15", + "prfcast": "아티스트", } with patch("collectors.kopis._fetch_detail", return_value=detail): result = _fetch_and_merge(concert) @@ -126,8 +132,12 @@ def test_returns_none_when_visit_not_y(self): """visit!=Y이면 None을 반환해야 한다.""" concert = {"kopis_id": "PF123456", "prfnm": "공연명"} detail = { - "visit": "N", "poster_url": None, - "price": None, "relates": [], "updatedate": None, "prfcast": None, + "visit": "N", + "poster_url": None, + "price": None, + "relates": [], + "updatedate": None, + "prfcast": None, } with patch("collectors.kopis._fetch_detail", return_value=detail): result = _fetch_and_merge(concert) @@ -144,9 +154,12 @@ def flaky_fetch(kopis_id): if call_count["n"] < 3: raise requests.ConnectionError("timeout") return { - "visit": "Y", "poster_url": "http://poster.jpg", - "price": None, "relates": [], - "updatedate": None, "prfcast": None, + "visit": "Y", + "poster_url": "http://poster.jpg", + "price": None, + "relates": [], + "updatedate": None, + "prfcast": None, } with patch("collectors.kopis._fetch_detail", side_effect=flaky_fetch): @@ -196,6 +209,7 @@ def test_fallback_sets_empty_still_urls(self): # ─── TestKopisCollect ───────────────────────────────────────────────────────── + class TestKopisCollect: def test_collect_returns_list(self): """collect() 호출 결과가 리스트여야 한다.""" @@ -204,8 +218,10 @@ def test_collect_returns_list(self): "PF123456", poster="http://poster.jpg", adres="서울", visit="Y" ) - with patch("collectors.kopis.requests.get", - side_effect=_mock_get_factory(list_content, {"PF123456": detail_content})): + with patch( + "collectors.kopis.requests.get", + side_effect=_mock_get_factory(list_content, {"PF123456": detail_content}), + ): result = collect() assert isinstance(result, list) @@ -215,12 +231,12 @@ def test_collect_returns_list(self): def test_collect_merges_detail_fields(self): """collect() 결과에 poster_url이 포함되어야 한다.""" list_content = _make_list_content(_sample_item()) - detail_content = _make_detail_content( - "PF123456", poster="http://poster.jpg", visit="Y" - ) + detail_content = _make_detail_content("PF123456", poster="http://poster.jpg", visit="Y") - with patch("collectors.kopis.requests.get", - side_effect=_mock_get_factory(list_content, {"PF123456": detail_content})): + with patch( + "collectors.kopis.requests.get", + side_effect=_mock_get_factory(list_content, {"PF123456": detail_content}), + ): result = collect() assert result[0]["poster_url"] == "http://poster.jpg" @@ -268,9 +284,8 @@ def test_stdate_custom_param_is_used(self): def test_includes_eddate_param(self): """eddate 미전달 시 오늘 기준 365일 후 날짜가 파라미터로 전달되어야 한다.""" import datetime - expected = ( - datetime.date.today() + datetime.timedelta(days=365) - ).strftime("%Y%m%d") + + expected = (datetime.date.today() + datetime.timedelta(days=365)).strftime("%Y%m%d") with patch("collectors.kopis.requests.get") as mock_get: mock_get.return_value.content = b"" @@ -289,8 +304,10 @@ def test_collect_includes_still_urls(self): styurls=["http://still1.jpg", "http://still2.jpg"], ) - with patch("collectors.kopis.requests.get", - side_effect=_mock_get_factory(list_content, {"PF123456": detail_content})): + with patch( + "collectors.kopis.requests.get", + side_effect=_mock_get_factory(list_content, {"PF123456": detail_content}), + ): result = collect() assert result[0]["still_urls"] == ["http://still1.jpg", "http://still2.jpg"] @@ -300,8 +317,10 @@ def test_collect_still_urls_empty_when_absent(self): list_content = _make_list_content(_sample_item()) detail_content = _make_detail_content("PF123456", visit="Y") - with patch("collectors.kopis.requests.get", - side_effect=_mock_get_factory(list_content, {"PF123456": detail_content})): + with patch( + "collectors.kopis.requests.get", + side_effect=_mock_get_factory(list_content, {"PF123456": detail_content}), + ): result = collect() assert result[0]["still_urls"] == [] @@ -313,14 +332,18 @@ def test_stores_booking_links(self): _sample_item(mt20id="PF002"), ) detail_with = _make_detail_content( - "PF001", visit="Y", + "PF001", + visit="Y", relates=[{"relatenm": "예스24", "relateurl": "https://yes24.com"}], ) detail_without = _make_detail_content("PF002", visit="Y") - with patch("collectors.kopis.requests.get", side_effect=_mock_get_factory( - list_content, {"PF001": detail_with, "PF002": detail_without} - )): + with patch( + "collectors.kopis.requests.get", + side_effect=_mock_get_factory( + list_content, {"PF001": detail_with, "PF002": detail_without} + ), + ): result = collect() result_by_id = {r["kopis_id"]: r for r in result} @@ -357,9 +380,10 @@ def test_excludes_non_visit_y_concerts(self): detail_y = _make_detail_content("PF001", visit="Y") detail_n = _make_detail_content("PF002", visit="N") - with patch("collectors.kopis.requests.get", side_effect=_mock_get_factory( - list_content, {"PF001": detail_y, "PF002": detail_n} - )): + with patch( + "collectors.kopis.requests.get", + side_effect=_mock_get_factory(list_content, {"PF001": detail_y, "PF002": detail_n}), + ): result = collect() assert len(result) == 1 @@ -374,9 +398,10 @@ def test_fetches_multiple_concerts(self): detail1 = _make_detail_content("PF001", visit="Y", poster="http://p1.jpg") detail2 = _make_detail_content("PF002", visit="Y", poster="http://p2.jpg") - with patch("collectors.kopis.requests.get", side_effect=_mock_get_factory( - list_content, {"PF001": detail1, "PF002": detail2} - )): + with patch( + "collectors.kopis.requests.get", + side_effect=_mock_get_factory(list_content, {"PF001": detail1, "PF002": detail2}), + ): result = collect() assert len(result) == 2 @@ -403,6 +428,7 @@ def test_detects_status_change_by_updatedate(self): # ─── TestKopisDetailRetry ───────────────────────────────────────────────────── + class TestKopisDetailRetry: def test_retries_on_network_error_then_succeeds(self): """상세 API 네트워크 오류 후 재시도에서 성공 시 결과에 포함되어야 한다.""" @@ -433,6 +459,7 @@ def flaky_get(url, params=None, **kwargs): # ─── TestFetchDetail ────────────────────────────────────────────────────────── + class TestFetchDetail: def _mock_resp(self, content: bytes) -> MagicMock: resp = MagicMock() @@ -451,7 +478,8 @@ def test_returns_poster_url(self): def test_returns_relates(self): """상세 API 응답에서 relates를 파싱해야 한다.""" content = _make_detail_content( - "PF123456", visit="Y", + "PF123456", + visit="Y", relates=[{"relatenm": "예스24", "relateurl": "https://yes24.com"}], ) with patch("collectors.kopis.requests.get", return_value=self._mock_resp(content)): @@ -534,6 +562,7 @@ def test_propagates_http_error(self): # ─── TestParseKopisDate ─────────────────────────────────────────────────────── + class TestParseKopisDate: def test_dot_date_normalized(self): assert _parse_kopis_date("2024.01.15") == "2024-01-15" @@ -567,6 +596,7 @@ def test_parse_concert_applies_normalization(self): # ─── TestParseRelates ───────────────────────────────────────────────────────── + class TestParseRelates: def _make_relates_elem(self, relates: list) -> ET.Element: elem = ET.Element("relates") @@ -577,10 +607,12 @@ def _make_relates_elem(self, relates: list) -> ET.Element: return elem def test_parses_relate_list(self): - elem = self._make_relates_elem([ - {"relatenm": "예스24", "relateurl": "https://yes24.com"}, - {"relatenm": "멜론티켓", "relateurl": "https://ticket.melon.com"}, - ]) + elem = self._make_relates_elem( + [ + {"relatenm": "예스24", "relateurl": "https://yes24.com"}, + {"relatenm": "멜론티켓", "relateurl": "https://ticket.melon.com"}, + ] + ) result = _parse_relates(elem) assert len(result) == 2 assert result[0] == {"relatenm": "예스24", "relateurl": "https://yes24.com"} @@ -602,6 +634,7 @@ def test_returns_empty_list_for_none(self): # ─── TestSaveConcerts ───────────────────────────────────────────────────────── + class TestSaveConcerts: def _make_concert(self, **kwargs) -> dict: base = { @@ -630,7 +663,8 @@ def test_insert_sql_contains_on_conflict(self): save_concerts([self._make_concert()]) insert_sqls = [ - str(c.args[0]) for c in mock_session.execute.call_args_list + str(c.args[0]) + for c in mock_session.execute.call_args_list if "INSERT" in str(c.args[0]) ] assert len(insert_sqls) == 1 @@ -646,7 +680,8 @@ def test_insert_sql_includes_poster_url(self): save_concerts([self._make_concert(poster_url="http://poster.jpg")]) insert_sqls = [ - str(c.args[0]) for c in mock_session.execute.call_args_list + str(c.args[0]) + for c in mock_session.execute.call_args_list if "INSERT INTO concert" in str(c.args[0]) ] assert "poster_url" in insert_sqls[0] @@ -661,7 +696,8 @@ def test_poster_url_param_passed(self): save_concerts([self._make_concert(poster_url="http://poster.jpg")]) insert_call = [ - c for c in mock_session.execute.call_args_list + c + for c in mock_session.execute.call_args_list if "INSERT INTO concert" in str(c.args[0]) ][0] assert insert_call.args[1]["poster_url"] == "http://poster.jpg" @@ -676,7 +712,8 @@ def test_insert_sql_includes_price(self): save_concerts([self._make_concert(price="전석 110,000원")]) insert_sqls = [ - str(c.args[0]) for c in mock_session.execute.call_args_list + str(c.args[0]) + for c in mock_session.execute.call_args_list if "INSERT INTO concert" in str(c.args[0]) ] assert "price" in insert_sqls[0] @@ -691,7 +728,8 @@ def test_price_param_passed(self): save_concerts([self._make_concert(price="전석 110,000원")]) insert_call = [ - c for c in mock_session.execute.call_args_list + c + for c in mock_session.execute.call_args_list if "INSERT INTO concert" in str(c.args[0]) ][0] assert insert_call.args[1]["price"] == "전석 110,000원" @@ -703,12 +741,17 @@ def test_booking_links_inserted_for_new_concert(self): with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) mock_get_session.return_value.__exit__ = MagicMock(return_value=False) - save_concerts([self._make_concert( - relates=[{"relatenm": "예스24", "relateurl": "https://yes24.com"}] - )]) + save_concerts( + [ + self._make_concert( + relates=[{"relatenm": "예스24", "relateurl": "https://yes24.com"}] + ) + ] + ) booking_link_inserts = [ - c for c in mock_session.execute.call_args_list + c + for c in mock_session.execute.call_args_list if "concert_booking_link" in str(c.args[0]) ] assert len(booking_link_inserts) == 1 @@ -723,12 +766,17 @@ def test_booking_links_inserted_for_existing_concert(self): with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) mock_get_session.return_value.__exit__ = MagicMock(return_value=False) - save_concerts([self._make_concert( - relates=[{"relatenm": "인터파크", "relateurl": "https://interpark.com"}] - )]) + save_concerts( + [ + self._make_concert( + relates=[{"relatenm": "인터파크", "relateurl": "https://interpark.com"}] + ) + ] + ) booking_link_inserts = [ - c for c in mock_session.execute.call_args_list + c + for c in mock_session.execute.call_args_list if "concert_booking_link" in str(c.args[0]) ] assert len(booking_link_inserts) == 1 @@ -741,12 +789,17 @@ def test_booking_link_insert_has_on_conflict(self): with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) mock_get_session.return_value.__exit__ = MagicMock(return_value=False) - save_concerts([self._make_concert( - relates=[{"relatenm": "예스24", "relateurl": "https://yes24.com"}] - )]) + save_concerts( + [ + self._make_concert( + relates=[{"relatenm": "예스24", "relateurl": "https://yes24.com"}] + ) + ] + ) booking_link_sqls = [ - str(c.args[0]) for c in mock_session.execute.call_args_list + str(c.args[0]) + for c in mock_session.execute.call_args_list if "concert_booking_link" in str(c.args[0]) ] assert len(booking_link_sqls) == 1 @@ -796,7 +849,8 @@ def test_skips_insert_when_title_and_dates_match_existing(self): duplicates = save_concerts([self._make_concert()]) insert_sqls = [ - str(c.args[0]) for c in mock_session.execute.call_args_list + str(c.args[0]) + for c in mock_session.execute.call_args_list if "INSERT INTO concert" in str(c.args[0]) ] assert insert_sqls == [] @@ -813,7 +867,9 @@ def test_title_date_check_uses_concert_fields(self): check_call = mock_session.execute.call_args_list[0] assert check_call.args[1] == { - "title": "공연명", "start_date": "2024-01-01", "end_date": "2024-01-31", + "title": "공연명", + "start_date": "2024-01-01", + "end_date": "2024-01-31", } def test_concert_image_not_inserted_when_still_urls_empty(self): @@ -834,6 +890,7 @@ def test_concert_image_not_inserted_when_still_urls_empty(self): # ─── TestUpdateConcertStatus ────────────────────────────────────────────────── + class TestUpdateConcertStatus: def test_no_update_when_updatedate_unchanged(self): """updatedate가 같으면 UPDATE가 실행되지 않아야 한다.""" @@ -894,6 +951,7 @@ def test_one_failure_does_not_stop_others(self): # ─── TestKopisHttpErrors ────────────────────────────────────────────────────── + class TestKopisHttpErrors: def test_propagates_http_error_on_4xx(self): """4xx 응답 시 collect()가 HTTPError를 전파해야 한다.""" diff --git a/tests/test_musicbrainz.py b/tests/test_musicbrainz.py index e5c3529..2d43f28 100644 --- a/tests/test_musicbrainz.py +++ b/tests/test_musicbrainz.py @@ -63,12 +63,36 @@ def test_allows_instagram(self): def test_allows_all_target_platforms(self): """타입별 1개씩 총 5개 플랫폼이 수집되어야 한다.""" relations = [ - {"target-type": "url", "type": "social network", "url": {"resource": "https://twitter.com/artist"}}, - {"target-type": "url", "type": "social network", "url": {"resource": "https://x.com/artist"}}, - {"target-type": "url", "type": "social network", "url": {"resource": "https://www.instagram.com/artist"}}, - {"target-type": "url", "type": "youtube", "url": {"resource": "https://www.youtube.com/channel/abc"}}, - {"target-type": "url", "type": "free streaming", "url": {"resource": "https://open.spotify.com/artist/abc"}}, - {"target-type": "url", "type": "free streaming", "url": {"resource": "https://music.apple.com/artist/abc"}}, + { + "target-type": "url", + "type": "social network", + "url": {"resource": "https://twitter.com/artist"}, + }, + { + "target-type": "url", + "type": "social network", + "url": {"resource": "https://x.com/artist"}, + }, + { + "target-type": "url", + "type": "social network", + "url": {"resource": "https://www.instagram.com/artist"}, + }, + { + "target-type": "url", + "type": "youtube", + "url": {"resource": "https://www.youtube.com/channel/abc"}, + }, + { + "target-type": "url", + "type": "free streaming", + "url": {"resource": "https://open.spotify.com/artist/abc"}, + }, + { + "target-type": "url", + "type": "free streaming", + "url": {"resource": "https://music.apple.com/artist/abc"}, + }, ] result = _parse_url_rels(relations) assert len(result) == 5 @@ -76,12 +100,36 @@ def test_allows_all_target_platforms(self): def test_maps_domains_to_site_names(self): """도메인 기반으로 사이트 이름이 type에 저장되고, 타입 중복 시 첫 번째만 유지된다.""" relations = [ - {"target-type": "url", "type": "social network", "url": {"resource": "https://twitter.com/artist"}}, - {"target-type": "url", "type": "social network", "url": {"resource": "https://x.com/artist"}}, - {"target-type": "url", "type": "social network", "url": {"resource": "https://www.instagram.com/artist"}}, - {"target-type": "url", "type": "youtube", "url": {"resource": "https://www.youtube.com/channel/abc"}}, - {"target-type": "url", "type": "free streaming", "url": {"resource": "https://open.spotify.com/artist/abc"}}, - {"target-type": "url", "type": "free streaming", "url": {"resource": "https://music.apple.com/artist/abc"}}, + { + "target-type": "url", + "type": "social network", + "url": {"resource": "https://twitter.com/artist"}, + }, + { + "target-type": "url", + "type": "social network", + "url": {"resource": "https://x.com/artist"}, + }, + { + "target-type": "url", + "type": "social network", + "url": {"resource": "https://www.instagram.com/artist"}, + }, + { + "target-type": "url", + "type": "youtube", + "url": {"resource": "https://www.youtube.com/channel/abc"}, + }, + { + "target-type": "url", + "type": "free streaming", + "url": {"resource": "https://open.spotify.com/artist/abc"}, + }, + { + "target-type": "url", + "type": "free streaming", + "url": {"resource": "https://music.apple.com/artist/abc"}, + }, ] result = _parse_url_rels(relations) types = [r["type"] for r in result] @@ -90,8 +138,16 @@ def test_maps_domains_to_site_names(self): def test_deduplicates_same_type(self): """동일 type의 URL이 여러 개일 때 첫 번째만 저장된다.""" relations = [ - {"target-type": "url", "type": "youtube", "url": {"resource": "https://www.youtube.com/channel/first"}}, - {"target-type": "url", "type": "youtube", "url": {"resource": "https://www.youtube.com/channel/second"}}, + { + "target-type": "url", + "type": "youtube", + "url": {"resource": "https://www.youtube.com/channel/first"}, + }, + { + "target-type": "url", + "type": "youtube", + "url": {"resource": "https://www.youtube.com/channel/second"}, + }, ] result = _parse_url_rels(relations) assert len(result) == 1 @@ -100,8 +156,16 @@ def test_deduplicates_same_type(self): def test_filters_invalid_url_patterns(self): """패턴 불일치 URL은 필터링된다.""" relations = [ - {"target-type": "url", "type": "free streaming", "url": {"resource": "https://open.spotify.com/playlist/abc"}}, - {"target-type": "url", "type": "youtube", "url": {"resource": "https://youtu.be/videoId"}}, + { + "target-type": "url", + "type": "free streaming", + "url": {"resource": "https://open.spotify.com/playlist/abc"}, + }, + { + "target-type": "url", + "type": "youtube", + "url": {"resource": "https://youtu.be/videoId"}, + }, ] result = _parse_url_rels(relations) assert result == [] @@ -109,16 +173,32 @@ def test_filters_invalid_url_patterns(self): def test_official_homepage_stored_as_official(self): """official homepage type은 도메인과 무관하게 'Official'로 저장되어야 한다.""" relations = [ - {"target-type": "url", "type": "official homepage", "url": {"resource": "https://artist-official.com"}}, + { + "target-type": "url", + "type": "official homepage", + "url": {"resource": "https://artist-official.com"}, + }, ] result = _parse_url_rels(relations) assert result == [{"type": "Official", "url": "https://artist-official.com"}] def test_filters_disallowed_domains(self): relations = [ - {"target-type": "url", "type": "social network", "url": {"resource": "https://facebook.com/artist"}}, - {"target-type": "url", "type": "social network", "url": {"resource": "https://weibo.com/artist"}}, - {"target-type": "url", "type": "free streaming", "url": {"resource": "https://soundcloud.com/artist"}}, + { + "target-type": "url", + "type": "social network", + "url": {"resource": "https://facebook.com/artist"}, + }, + { + "target-type": "url", + "type": "social network", + "url": {"resource": "https://weibo.com/artist"}, + }, + { + "target-type": "url", + "type": "free streaming", + "url": {"resource": "https://soundcloud.com/artist"}, + }, ] assert _parse_url_rels(relations) == [] @@ -205,7 +285,9 @@ def test_stops_at_max_artists_limit(self, mock_search, mock_detail): "count": 10_000, } mock_detail.return_value = { - "id": "mbid-1", "name": "Artist1", "sort-name": "Artist1", + "id": "mbid-1", + "name": "Artist1", + "sort-name": "Artist1", "aliases": [], "relations": [ { @@ -266,7 +348,8 @@ def test_inserts_aliases_for_artist(self): self._run([artist], mock_session) alias_inserts = [ - c for c in mock_session.execute.call_args_list + c + for c in mock_session.execute.call_args_list if "INSERT INTO artist_alias" in str(c.args[0]) ] assert len(alias_inserts) == 2 @@ -287,7 +370,8 @@ def test_inserts_url_rels_for_artist(self): self._run([artist], mock_session) url_inserts = [ - c for c in mock_session.execute.call_args_list + c + for c in mock_session.execute.call_args_list if "INSERT INTO artist_url" in str(c.args[0]) ] assert len(url_inserts) == 2 @@ -305,13 +389,15 @@ def test_selects_artist_id_when_conflict(self): self._run([artist], mock_session) select_calls = [ - c for c in mock_session.execute.call_args_list + c + for c in mock_session.execute.call_args_list if "SELECT id FROM artist" in str(c.args[0]) ] assert len(select_calls) == 1 alias_inserts = [ - c for c in mock_session.execute.call_args_list + c + for c in mock_session.execute.call_args_list if "INSERT INTO artist_alias" in str(c.args[0]) ] assert len(alias_inserts) == 1 @@ -331,7 +417,8 @@ def test_skips_artist_when_id_not_found(self): self._run([artist], mock_session) child_inserts = [ - c for c in mock_session.execute.call_args_list + c + for c in mock_session.execute.call_args_list if "INSERT INTO artist_alias" in str(c.args[0]) or "INSERT INTO artist_url" in str(c.args[0]) ] @@ -344,8 +431,7 @@ def test_handles_empty_aliases_and_urls(self): self._run([artist], mock_session) artist_inserts = [ - c for c in mock_session.execute.call_args_list - if "INSERT INTO artist" in str(c.args[0]) + c for c in mock_session.execute.call_args_list if "INSERT INTO artist" in str(c.args[0]) ] assert len(artist_inserts) == 1 diff --git a/tests/test_release_spotify.py b/tests/test_release_spotify.py index cc648a7..cf5140f 100644 --- a/tests/test_release_spotify.py +++ b/tests/test_release_spotify.py @@ -1,4 +1,5 @@ """collectors/release.py (Spotify) + db/repository.save_releases 단위 테스트.""" + from unittest.mock import MagicMock, patch import requests @@ -220,8 +221,12 @@ def _make_album(self, album_id, album_type="album", tracks=None): @patch("collectors.release._fetch_album_ids") def test_returns_correct_structure(self, mock_ids, mock_individual): track_item = { - "id": "t1", "name": "Song A", "track_number": 1, - "disc_number": 1, "duration_ms": 200000, "explicit": False, + "id": "t1", + "name": "Song A", + "track_number": 1, + "disc_number": 1, + "duration_ms": 200000, + "explicit": False, } mock_ids.return_value = (["alb1"], 1) mock_individual.return_value = [self._make_album("alb1", tracks=[track_item])] @@ -302,6 +307,7 @@ def test_warns_when_tracks_exceed_50(self, mock_ids, mock_individual): mock_individual.return_value = [album] import logging + with patch.object(logging.getLogger("collectors.release"), "warning") as mock_warn: collect_releases("artist-spotify-id") mock_warn.assert_called_once() @@ -370,7 +376,8 @@ def test_artist_id_bound_correctly(self): save_releases(99, [self._make_release()]) rg_call = next( - c for c in mock_session.execute.call_args_list + c + for c in mock_session.execute.call_args_list if "INSERT INTO release_group" in str(c.args[0]) ) assert rg_call.args[1]["artist_id"] == 99 @@ -391,8 +398,7 @@ def test_saves_tracks_with_new_fields(self): save_releases(1, [self._make_release(tracks=[track])]) track_call = next( - c for c in mock_session.execute.call_args_list - if "INSERT INTO track" in str(c.args[0]) + c for c in mock_session.execute.call_args_list if "INSERT INTO track" in str(c.args[0]) ) params = track_call.args[1] assert params["spotify_id"] == "t1" @@ -404,8 +410,12 @@ def test_track_insert_uses_spotify_id_conflict_key(self): mock_session.execute.return_value.fetchone.return_value = (1,) track = { - "spotify_id": "t1", "title": "T", "position": 1, - "disc_number": 1, "length_ms": None, "explicit": False, + "spotify_id": "t1", + "title": "T", + "position": 1, + "disc_number": 1, + "length_ms": None, + "explicit": False, } with patch("db.repository.get_session", return_value=self._patch_session(mock_session)): save_releases(1, [self._make_release(tracks=[track])]) @@ -443,7 +453,8 @@ def make_ctx(): save_releases(1, releases) ok_inserts = [ - c for c in ok_session.execute.call_args_list + c + for c in ok_session.execute.call_args_list if "INSERT INTO release_group" in str(c.args[0]) ] assert len(ok_inserts) >= 1 @@ -459,7 +470,8 @@ def test_total_tracks_bound_in_insert(self): save_releases(1, [release]) rg_call = next( - c for c in mock_session.execute.call_args_list + c + for c in mock_session.execute.call_args_list if "INSERT INTO release_group" in str(c.args[0]) ) assert rg_call.args[1]["total_tracks"] == 12 diff --git a/tests/test_repository.py b/tests/test_repository.py index f33e8bd..2124c4e 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -822,4 +822,3 @@ def test_skips_artist_match_query_when_matched_artist_ids_empty(self): self._run([concert], mock_session) assert mock_session.execute.call_count == 2 - diff --git a/tests/test_repository_scheduler.py b/tests/test_repository_scheduler.py index 448d76c..ac61b74 100644 --- a/tests/test_repository_scheduler.py +++ b/tests/test_repository_scheduler.py @@ -1,4 +1,5 @@ """repository.py의 스케줄러용 신규 함수 단위 테스트.""" + from unittest.mock import MagicMock, patch from db.repository import ( @@ -24,9 +25,9 @@ def test_insert_uses_on_conflict_do_nothing(self): """INSERT SQL에 ON CONFLICT가 포함되어야 한다.""" mock_session = MagicMock() with patch("db.repository.get_session", return_value=_make_session_ctx(mock_session)): - save_concert_artists([ - {"concert_id": 1, "artist_id": 10, "confidence": "HIGH", "matched_by": "prfcast"} - ]) + save_concert_artists( + [{"concert_id": 1, "artist_id": 10, "confidence": "HIGH", "matched_by": "prfcast"}] + ) sql = str(mock_session.execute.call_args_list[0].args[0]) assert "ON CONFLICT" in sql @@ -130,9 +131,7 @@ class TestGetAllArtistMbids: def test_returns_mbid_list(self): """MBID 문자열 리스트를 반환해야 한다.""" mock_session = MagicMock() - mock_session.execute.return_value.fetchall.return_value = [ - ("mbid-1",), ("mbid-2",) - ] + mock_session.execute.return_value.fetchall.return_value = [("mbid-1",), ("mbid-2",)] with patch("db.repository.get_session", return_value=_make_session_ctx(mock_session)): result = get_all_artist_mbids() @@ -155,7 +154,8 @@ def test_returns_concert_dicts(self): """concert_id, title, cast 키를 가진 dict 리스트를 반환해야 한다.""" mock_session = MagicMock() mock_session.execute.return_value.fetchall.return_value = [ - (1, "공연 A", "아이유"), (2, "공연 B", None) + (1, "공연 A", "아이유"), + (2, "공연 B", None), ] with patch("db.repository.get_session", return_value=_make_session_ctx(mock_session)): @@ -183,9 +183,7 @@ class TestGetExistingKopisIds: def test_returns_set_of_kopis_ids(self): """DB의 kopis_id를 집합으로 반환해야 한다.""" mock_session = MagicMock() - mock_session.execute.return_value.fetchall.return_value = [ - ("PF001",), ("PF002",) - ] + mock_session.execute.return_value.fetchall.return_value = [("PF001",), ("PF002",)] with patch("db.repository.get_session", return_value=_make_session_ctx(mock_session)): result = get_existing_kopis_ids() diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 768bbe7..fdc73d8 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -1,4 +1,5 @@ """scheduler.py 단위 테스트.""" + import json import logging import os @@ -44,6 +45,7 @@ def test_logs_duration_on_normal_completion(self, caplog): def test_logs_duration_on_early_return(self, caplog): """with 블록 중간에 return으로 빠져나가도 소요시간이 로깅되어야 한다.""" + def _early_return(): with _job_timer("테스트 잡"): return @@ -674,7 +676,11 @@ class TestRegisterArtistByMbid: "aliases": [], "url_rels": [{"type": "Spotify", "url": "https://open.spotify.com/artist/sp999"}], } - _SAVED = {"id": 42, "name": "TestArtist", "spotify_url": "https://open.spotify.com/artist/sp999"} + _SAVED = { + "id": 42, + "name": "TestArtist", + "spotify_url": "https://open.spotify.com/artist/sp999", + } def test_returns_ok_status_on_success(self): """모든 단계 성공 시 status:ok와 아티스트 정보를 반환해야 한다.""" @@ -718,15 +724,12 @@ def test_saves_artist_before_image_collection(self): """save_artists가 이미지 수집보다 먼저 호출되어야 한다.""" call_order = [] with ( - patch( - "scheduler.musicbrainz.collect_single_artist", return_value=self._ARTIST - ), + patch("scheduler.musicbrainz.collect_single_artist", return_value=self._ARTIST), patch("scheduler.save_artists", side_effect=lambda _: call_order.append("save")), patch("scheduler.get_artist_by_mbid", return_value=self._SAVED), patch( "scheduler.artist_image.collect_artist_image", - side_effect=lambda *a, **kw: call_order.append("image") - or ("http://img", "sp999"), + side_effect=lambda *a, **kw: call_order.append("image") or ("http://img", "sp999"), ), patch("scheduler.update_artist_image"), ): @@ -930,6 +933,7 @@ def test_returns_false_when_no_file(self, tmp_path, monkeypatch): def test_returns_true_within_ban_period(self, tmp_path, monkeypatch): from datetime import datetime, timedelta + ban_path = str(tmp_path / "spotify_ban.json") monkeypatch.setattr("scheduler._BAN_PATH", ban_path) future = (datetime.now() + timedelta(hours=20)).isoformat(timespec="seconds") @@ -939,6 +943,7 @@ def test_returns_true_within_ban_period(self, tmp_path, monkeypatch): def test_returns_false_after_ban_expired(self, tmp_path, monkeypatch): from datetime import datetime, timedelta + ban_path = str(tmp_path / "spotify_ban.json") monkeypatch.setattr("scheduler._BAN_PATH", ban_path) past = (datetime.now() - timedelta(hours=1)).isoformat(timespec="seconds") @@ -1113,6 +1118,7 @@ def test_skips_save_releases_when_no_new_releases(self, tmp_path, monkeypatch): def test_skips_all_when_banned(self, tmp_path, monkeypatch): """banned_until이 유효하면 collect_releases를 호출하지 않아야 한다.""" from datetime import datetime, timedelta + ban_path = str(tmp_path / "spotify_ban.json") monkeypatch.setattr("scheduler._BAN_PATH", ban_path) future = (datetime.now() + timedelta(hours=20)).isoformat(timespec="seconds") @@ -1130,6 +1136,7 @@ class TestRunArtistImageUpdateBanGuard: def test_skips_when_banned(self, tmp_path, monkeypatch): """banned_until이 유효하면 이미지 수집을 실행하지 않아야 한다.""" from datetime import datetime, timedelta + ban_path = str(tmp_path / "spotify_ban.json") monkeypatch.setattr("scheduler._BAN_PATH", ban_path) future = (datetime.now() + timedelta(hours=20)).isoformat(timespec="seconds") @@ -1229,9 +1236,7 @@ def test_upserts_artist_url_when_fallback_finds_spotify_id(self): def test_does_not_upsert_when_spotify_url_already_present(self): """이미 spotify_url이 있던 아티스트는 artist_url을 다시 저장하지 않아야 한다.""" with ( - patch( - "scheduler.get_artists_without_image", return_value=[self._ARTIST_WITH_SPOTIFY] - ), + patch("scheduler.get_artists_without_image", return_value=[self._ARTIST_WITH_SPOTIFY]), patch( "scheduler.artist_image.collect_artist_image", return_value=("http://img.url", "existing"), diff --git a/tests/test_setlist.py b/tests/test_setlist.py index 6f05fc8..cd7f5ff 100644 --- a/tests/test_setlist.py +++ b/tests/test_setlist.py @@ -75,9 +75,7 @@ def test_attribution_url_none_when_missing_from_response(self): """API 응답에 url 필드가 없으면 attribution_url은 None이어야 한다.""" mock_response = MagicMock() mock_response.raise_for_status = MagicMock() - mock_response.json.return_value = _make_api_response( - [_sample_setlist(url=None)] - ) + mock_response.json.return_value = _make_api_response([_sample_setlist(url=None)]) with patch("collectors.setlist.get_completed_concerts", return_value=[_sample_concert()]): with patch("collectors.setlist.requests.get", return_value=mock_response): @@ -364,9 +362,7 @@ def test_records_attempt_when_setlist_found(self): with patch("collectors.setlist.get_completed_concerts", return_value=[_sample_concert()]): with patch("collectors.setlist.requests.get", return_value=mock_response): - with patch( - "collectors.setlist.update_concert_fetch_attempted" - ) as mock_attempted: + with patch("collectors.setlist.update_concert_fetch_attempted") as mock_attempted: collect() mock_attempted.assert_called_once_with(1) @@ -379,9 +375,7 @@ def test_records_attempt_when_no_setlist_found(self): with patch("collectors.setlist.get_completed_concerts", return_value=[_sample_concert()]): with patch("collectors.setlist.requests.get", return_value=mock_response): - with patch( - "collectors.setlist.update_concert_fetch_attempted" - ) as mock_attempted: + with patch("collectors.setlist.update_concert_fetch_attempted") as mock_attempted: collect() mock_attempted.assert_called_once_with(1) @@ -395,9 +389,7 @@ def test_records_attempt_on_404(self): with patch("collectors.setlist.get_completed_concerts", return_value=[_sample_concert()]): with patch("collectors.setlist.requests.get", return_value=mock_response): - with patch( - "collectors.setlist.update_concert_fetch_attempted" - ) as mock_attempted: + with patch("collectors.setlist.update_concert_fetch_attempted") as mock_attempted: collect() mock_attempted.assert_called_once_with(1) @@ -411,9 +403,7 @@ def test_records_attempt_on_api_error(self): with patch("collectors.setlist.get_completed_concerts", return_value=[_sample_concert()]): with patch("collectors.setlist.requests.get", return_value=fail_response): - with patch( - "collectors.setlist.update_concert_fetch_attempted" - ) as mock_attempted: + with patch("collectors.setlist.update_concert_fetch_attempted") as mock_attempted: collect() mock_attempted.assert_called_once_with(1) @@ -430,9 +420,7 @@ def test_records_attempt_when_pagination_exhausted(self): with patch("collectors.setlist.get_completed_concerts", return_value=[_sample_concert()]): with patch("collectors.setlist.requests.get", return_value=mock_response): - with patch( - "collectors.setlist.update_concert_fetch_attempted" - ) as mock_attempted: + with patch("collectors.setlist.update_concert_fetch_attempted") as mock_attempted: collect() mock_attempted.assert_called_once_with(1)