From 357a3dd33b732ec04bc594f2a1efea572906583a Mon Sep 17 00:00:00 2001 From: Ara5429 Date: Fri, 20 Mar 2026 03:52:55 +0900 Subject: [PATCH 1/3] feat: week4 GitHub OAuth + tree selection flow Made-with: Cursor --- docs/API_Auth_Spec.md | 105 ++- ...4\353\247\210_\354\204\244\352\263\204.md" | 3 + docs/openapi.json | 704 ++++++++++++++ poetry.lock | 255 +---- pyproject.toml | 2 + scripts/export_openapi.py | 11 + src/api/auth.py | 317 +++++++ src/api/github.py | 292 ++++++ src/api/user_assets.py | 122 +++ src/app/main.py | 869 +++++++++++++++++- src/db/sqlite/schema.py | 54 ++ src/service/git_hub/oauth.py | 82 ++ src/service/git_hub/repos.py | 329 +++++++ src/service/user/repos.py | 74 ++ src/service/user/selected_assets.py | 99 ++ src/web/__init__.py | 2 + src/web/dashboard.py | 31 + tests/api/test_auth.py | 155 ++++ tests/api/test_github_api.py | 249 +++++ tests/api/test_selected_repo_assets_api.py | 208 +++++ week-issues/week3-auth-api-tests.md | 188 ++++ week-issues/week4-auth-github-oauth.md | 107 +++ week-issues/week4-github.md | 54 ++ 23 files changed, 4046 insertions(+), 266 deletions(-) create mode 100644 docs/openapi.json create mode 100644 scripts/export_openapi.py create mode 100644 src/api/auth.py create mode 100644 src/api/github.py create mode 100644 src/api/user_assets.py create mode 100644 src/service/git_hub/oauth.py create mode 100644 src/service/git_hub/repos.py create mode 100644 src/service/user/selected_assets.py create mode 100644 src/web/__init__.py create mode 100644 src/web/dashboard.py create mode 100644 tests/api/test_auth.py create mode 100644 tests/api/test_github_api.py create mode 100644 tests/api/test_selected_repo_assets_api.py create mode 100644 week-issues/week3-auth-api-tests.md create mode 100644 week-issues/week4-auth-github-oauth.md create mode 100644 week-issues/week4-github.md diff --git a/docs/API_Auth_Spec.md b/docs/API_Auth_Spec.md index ee8fbcb..86c7e26 100644 --- a/docs/API_Auth_Spec.md +++ b/docs/API_Auth_Spec.md @@ -10,12 +10,12 @@ ### 공통 규칙 -> 공통 요청 형식, 공통 에러 코드, score_label 기준은 `API_Common.md` 참고. +> 공통 요청 형식, 공통 에러 코드는 `API_Common.md` 참고. - **인증 흐름:** - `GET /api/auth/github/login` → 브라우저가 GitHub OAuth authorize URL로 이동 → 사용자 승인 → - `GET /api/auth/github/callback`(code, state 수신) → 서버가 access_token 교환 및 세션 발급 → - 이후 `GET /api/me`로 현재 로그인 유저 확인, 다른 API는 세션 쿠키 또는 `Authorization: Bearer `으로 호출. +`GET /api/auth/github/login` → 브라우저가 GitHub OAuth authorize URL로 이동 → 사용자 승인 → +`GET /api/auth/github/callback`(code, state 수신) → 서버가 access_token 교환 및 세션 발급 → +이후 `GET /api/me`로 현재 로그인 유저 확인, 다른 API는 세션 쿠키 또는 `Authorization: Bearer `으로 호출. -- **에러 응답 포맷(공통):** @@ -42,9 +42,11 @@ curl -i -X GET "https://example.com/api/auth/github/login" #### 2. Request Header -| Header | 설명 | 필수 | -|--------|------|------| -| (없음) | 쿠키/Authorization 불필요 | N | + +| Header | 설명 | 필수 | +| ------ | -------------------- | --- | +| (없음) | 쿠키/Authorization 불필요 | N | + #### 3. Request Element @@ -53,11 +55,14 @@ curl -i -X GET "https://example.com/api/auth/github/login" #### 4. Response **302 Found** + - `Location` 헤더에 GitHub OAuth authorize URL이 담겨 해당 URL로 리다이렉트된다. -| 상태코드 | error | 발생조건 | -|----------|-------|----------| -| 500 | INTERNAL_SERVER_ERROR | GitHub OAuth 환경변수(client_id 등) 미설정 | + +| 상태코드 | error | 발생조건 | +| ---- | --------------------- | ---------------------------------- | +| 500 | INTERNAL_SERVER_ERROR | GitHub OAuth 환경변수(client_id 등) 미설정 | + > 공통 에러(400/401/403/404/500/502)는 공통 규칙 참고. @@ -77,27 +82,34 @@ curl -i -X GET "https://example.com/api/auth/github/callback?code=abc123&state=x #### 2. Request Header -| Header | 설명 | 필수 | -|--------|------|------| -| (없음) | 쿠키/Authorization 불필요 | N | + +| Header | 설명 | 필수 | +| ------ | -------------------- | --- | +| (없음) | 쿠키/Authorization 불필요 | N | + #### 3. Request Element -| 파라미터 | 타입 | 필수 | 설명 | -|----------|------|------|------| -| code | string | Y | GitHub에서 전달하는 인증 코드 | -| state | string | Y | CSRF 방지용 검증 값 | + +| 파라미터 | 타입 | 필수 | 설명 | +| ----- | ------ | --- | ------------------- | +| code | string | Y | GitHub에서 전달하는 인증 코드 | +| state | string | Y | CSRF 방지용 검증 값 | + #### 4. Response **302 Found** + - `Location`: `/dashboard` (성공 시 앱 대시보드로 리다이렉트). - `Set-Cookie` 헤더로 세션 쿠키 발급. -| 상태코드 | error | 발생조건 | -|----------|-------|----------| -| 400 | BAD_REQUEST | state 불일치 또는 code 누락 | -| 500 | INTERNAL_SERVER_ERROR | GitHub token API 오류 등 서버/외부 오류 | + +| 상태코드 | error | 발생조건 | +| ---- | --------------------- | ------------------------------ | +| 400 | BAD_REQUEST | state 불일치 또는 code 누락 | +| 500 | INTERNAL_SERVER_ERROR | GitHub token API 오류 등 서버/외부 오류 | + > 공통 에러(400/401/403/404/500/502)는 공통 규칙 참고. @@ -118,9 +130,11 @@ curl -i -X GET "https://example.com/api/auth/logout" \ #### 2. Request Header -| Header | 설명 | 필수 | -|--------|------|------| -| Cookie | 세션 쿠키 (있으면 무효화 대상) | N | + +| Header | 설명 | 필수 | +| ------ | ------------------ | --- | +| Cookie | 세션 쿠키 (있으면 무효화 대상) | N | + #### 3. Request Element @@ -129,11 +143,14 @@ curl -i -X GET "https://example.com/api/auth/logout" \ #### 4. Response **302 Found** + - `Location`: `/` (루트로 리다이렉트). -| 상태코드 | error | 발생조건 | -|----------|-------|----------| -| (없음) | — | 에러 응답 없음, 항상 302 반환 | + +| 상태코드 | error | 발생조건 | +| ---- | ----- | ------------------- | +| (없음) | — | 에러 응답 없음, 항상 302 반환 | + --- @@ -152,11 +169,13 @@ curl -X GET "https://example.com/api/me" \ #### 2. Request Header -| Header | 설명 | 필수 | -|--------|------|------| -| Cookie | 세션 쿠키 (인증된 경우) | Cookie 또는 Authorization 중 하나 필수 | + +| Header | 설명 | 필수 | +| ------------- | ---------------------------- | ------------------------------- | +| Cookie | 세션 쿠키 (인증된 경우) | Cookie 또는 Authorization 중 하나 필수 | | Authorization | `Bearer ` | Cookie 또는 Authorization 중 하나 필수 | + #### 3. Request Element - Path/Query/Body 없음. @@ -175,17 +194,21 @@ curl -X GET "https://example.com/api/me" \ } ``` -| 필드 | 타입 | 설명 | -|------|------|------| -| user_id | string | 내부 사용자 식별자 | -| github_login | string | GitHub 로그인 아이디 | -| github_id | integer | GitHub 유저 numeric ID | -| email | string | 이메일 주소 | -| avatar_url | string | 프로필 이미지 URL | -| 상태코드 | error | 발생조건 | -|----------|-------|----------| -| 401 | UNAUTHORIZED | 세션 없음 또는 만료 | +| 필드 | 타입 | 설명 | +| ------------ | ------- | -------------------- | +| user_id | string | 내부 사용자 식별자 | +| github_login | string | GitHub 로그인 아이디 | +| github_id | integer | GitHub 유저 numeric ID | +| email | string | 이메일 주소 | +| avatar_url | string | 프로필 이미지 URL | + + + +| 상태코드 | error | 발생조건 | +| ---- | ------------ | ----------- | +| 401 | UNAUTHORIZED | 세션 없음 또는 만료 | + > 공통 에러(400/401/403/404/500/502)는 공통 규칙 참고. @@ -206,4 +229,4 @@ curl -X GET "https://example.com/api/me" \ 11. POST /api/cover-letter/inspect — 자소서 검수 (Service) 12. POST /api/portfolio/generate — 포트폴리오 생성 (Service) -6번(문서 업로드)은 선택 단계이며, 이력서/포트폴리오 문서가 없어도 자소서·포트폴리오 생성은 GitHub 임베딩만으로 진행 가능하다. +6번(문서 업로드)은 선택 단계이며, 이력서/포트폴리오 문서가 없어도 자소서·포트폴리오 생성은 GitHub 임베딩만으로 진행 가능하다. \ No newline at end of file diff --git "a/docs/AUTOFOLIO_DB_\354\212\244\355\202\244\353\247\210_\354\204\244\352\263\204.md" "b/docs/AUTOFOLIO_DB_\354\212\244\355\202\244\353\247\210_\354\204\244\352\263\204.md" index d7e41c6..c0ecad7 100644 --- "a/docs/AUTOFOLIO_DB_\354\212\244\355\202\244\353\247\210_\354\204\244\352\263\204.md" +++ "b/docs/AUTOFOLIO_DB_\354\212\244\355\202\244\353\247\210_\354\204\244\352\263\204.md" @@ -23,6 +23,9 @@ |------|------|------| | `id` | TEXT PK | 유저 고유 ID (GitHub id 등) | | `github_username` | TEXT | GitHub 사용자명 | +| `github_id` | INTEGER | GitHub 유저 numeric ID | +| `email` | TEXT | GitHub 이메일(없을 수도 있음) | +| `avatar_url` | TEXT | GitHub 프로필 이미지 URL | | `access_token` | TEXT | OAuth 토큰 (암호화 권장) | | `created_at` | DATETIME | 가입 시각 | | `updated_at` | DATETIME | 수정 시각 | diff --git a/docs/openapi.json b/docs/openapi.json new file mode 100644 index 0000000..35787ff --- /dev/null +++ b/docs/openapi.json @@ -0,0 +1,704 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Autofolio API", + "description": "From Code to Career — 증거 기반 개발자 이력서 생성 서비스", + "version": "1.0.0" + }, + "paths": { + "/api/auth/github/login": { + "get": { + "tags": [ + "Auth" + ], + "summary": "GitHub 로그인 시작", + "operationId": "github_login_api_auth_github_login_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/auth/github/callback": { + "get": { + "tags": [ + "Auth" + ], + "summary": "GitHub OAuth 콜백", + "operationId": "github_callback_api_auth_github_callback_get", + "parameters": [ + { + "name": "code", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code" + } + }, + { + "name": "state", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "State" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/auth/logout": { + "get": { + "tags": [ + "Auth" + ], + "summary": "로그아웃", + "operationId": "logout_api_auth_logout_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/me": { + "get": { + "tags": [ + "Auth" + ], + "summary": "현재 유저 조회", + "operationId": "me_api_me_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/portfolio/generate": { + "post": { + "tags": [ + "portfolio" + ], + "summary": "Generate Portfolio", + "description": "포트폴리오 생성 그래프를 실행하고 결과를 저장한다.", + "operationId": "generate_portfolio_api_portfolio_generate_post", + "parameters": [ + { + "name": "X-User-Id", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-User-Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/portfolio": { + "get": { + "tags": [ + "portfolio" + ], + "summary": "Get Portfolio", + "description": "포트폴리오 목록 또는 단건을 조회한다.", + "operationId": "get_portfolio_api_portfolio_get", + "parameters": [ + { + "name": "portfolio_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Portfolio Id" + } + }, + { + "name": "X-User-Id", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-User-Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/github/repos": { + "get": { + "tags": [ + "GitHub" + ], + "summary": "Github Repos List", + "operationId": "github_repos_list_api_github_repos_get", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1, + "title": "Page" + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 30, + "title": "Per Page" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/user/selected-repos": { + "get": { + "tags": [ + "GitHub" + ], + "summary": "Selected Repos Get", + "operationId": "selected_repos_get_api_user_selected_repos_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "put": { + "tags": [ + "GitHub" + ], + "summary": "Selected Repos Put", + "operationId": "selected_repos_put_api_user_selected_repos_put", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/github/repos/{repo_id}/files": { + "get": { + "tags": [ + "GitHub" + ], + "summary": "Github Repo Files", + "operationId": "github_repo_files_api_github_repos__repo_id__files_get", + "parameters": [ + { + "name": "repo_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Repo Id" + } + }, + { + "name": "path", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "/", + "title": "Path" + } + }, + { + "name": "depth", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 2, + "title": "Depth" + } + }, + { + "name": "ref", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ref" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/github/repos/{repo_id}/contents": { + "get": { + "tags": [ + "GitHub" + ], + "summary": "Github Repo Contents", + "operationId": "github_repo_contents_api_github_repos__repo_id__contents_get", + "parameters": [ + { + "name": "repo_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Repo Id" + } + }, + { + "name": "path", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Path" + } + }, + { + "name": "ref", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ref" + } + }, + { + "name": "encoding", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "raw", + "title": "Encoding" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/github/repos/{repo_id}/commits": { + "get": { + "tags": [ + "GitHub" + ], + "summary": "Github Repo Commits", + "operationId": "github_repo_commits_api_github_repos__repo_id__commits_get", + "parameters": [ + { + "name": "repo_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Repo Id" + } + }, + { + "name": "author", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Author" + } + }, + { + "name": "path", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Path" + } + }, + { + "name": "since", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Since" + } + }, + { + "name": "until", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Until" + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 30, + "title": "Per Page" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1, + "title": "Page" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/": { + "get": { + "summary": "Index", + "operationId": "index__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/dashboard": { + "get": { + "summary": "Dashboard", + "operationId": "dashboard_dashboard_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + }, + "input": { + "title": "Input" + }, + "ctx": { + "type": "object", + "title": "Context" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + } + } +} \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 34e44e9..5914c37 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. [[package]] name = "aiosqlite" @@ -6,7 +6,6 @@ version = "0.22.1" description = "asyncio bridge to the standard sqlite3 module" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb"}, {file = "aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650"}, @@ -22,7 +21,6 @@ version = "0.0.4" description = "Document parameters, class attributes, return types, and variables inline, with Annotated." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, @@ -34,7 +32,6 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -46,7 +43,6 @@ version = "4.12.1" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, @@ -58,7 +54,7 @@ idna = ">=2.8" typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] +trio = ["trio (>=0.31.0)", "trio (>=0.32.0)"] [[package]] name = "attrs" @@ -66,7 +62,6 @@ version = "25.4.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, @@ -78,8 +73,6 @@ version = "1.2.0" description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." optional = false python-versions = "<3.11,>=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\"" files = [ {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, @@ -91,7 +84,6 @@ version = "5.0.0" description = "Modern password hashing for your software and your servers" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be"}, {file = "bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2"}, @@ -168,7 +160,6 @@ version = "4.14.3" description = "Screen-scraping library" optional = false python-versions = ">=3.7.0" -groups = ["main"] files = [ {file = "beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb"}, {file = "beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86"}, @@ -191,7 +182,6 @@ version = "1.4.0" description = "A simple, correct Python build frontend" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "build-1.4.0-py3-none-any.whl", hash = "sha256:6a07c1b8eb6f2b311b96fcbdbce5dab5fe637ffda0fd83c9cac622e927501596"}, {file = "build-1.4.0.tar.gz", hash = "sha256:f1b91b925aa322be454f8330c6fb48b465da993d1e7e7e6fa35027ec49f3c936"}, @@ -206,7 +196,7 @@ tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} [package.extras] uv = ["uv (>=0.1.18)"] -virtualenv = ["virtualenv (>=20.11) ; python_version < \"3.10\"", "virtualenv (>=20.17) ; python_version >= \"3.10\" and python_version < \"3.14\"", "virtualenv (>=20.31) ; python_version >= \"3.14\""] +virtualenv = ["virtualenv (>=20.11)", "virtualenv (>=20.17)", "virtualenv (>=20.31)"] [[package]] name = "certifi" @@ -214,7 +204,6 @@ version = "2026.1.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, @@ -226,7 +215,6 @@ version = "3.4.4" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, @@ -349,7 +337,6 @@ version = "1.5.5" description = "Chroma." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "chromadb-1.5.5-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d590998ed81164afbfb1734bb534b25ec2c9810fc1c5ce53bf8f7ac644a79887"}, {file = "chromadb-1.5.5-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:5ff2912d20a82fdbf4e27ff3e1c91dab25e2ba2c629f9739bc12c11a3151aac7"}, @@ -397,7 +384,6 @@ version = "8.3.1" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, @@ -412,12 +398,10 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\" or os_name == \"nt\" or sys_platform == \"win32\"", dev = "sys_platform == \"win32\""} [[package]] name = "distro" @@ -425,7 +409,6 @@ version = "1.9.0" description = "Distro - an OS platform information API" optional = false python-versions = ">=3.6" -groups = ["main"] files = [ {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"}, {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, @@ -437,7 +420,6 @@ version = "0.10" description = "Module for converting between datetime.timedelta and Go's Duration strings." optional = false python-versions = "*" -groups = ["main"] files = [ {file = "durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286"}, {file = "durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba"}, @@ -449,8 +431,6 @@ version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] -markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, @@ -468,7 +448,6 @@ version = "0.135.1" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e"}, {file = "fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd"}, @@ -492,7 +471,6 @@ version = "3.25.2" description = "A platform independent file lock." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70"}, {file = "filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"}, @@ -504,7 +482,6 @@ version = "25.12.19" description = "The FlatBuffers serialization format for Python" optional = false python-versions = "*" -groups = ["main"] files = [ {file = "flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4"}, ] @@ -515,7 +492,6 @@ version = "2026.2.0" description = "File-system specification" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437"}, {file = "fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff"}, @@ -546,7 +522,7 @@ smb = ["smbprotocol"] ssh = ["paramiko"] test = ["aiohttp (!=4.0.0a0,!=4.0.0a1)", "numpy", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "requests"] test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask[dataframe,test]", "moto[server] (>4,<5)", "pytest-timeout", "xarray"] -test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "backports-zstd ; python_version < \"3.14\"", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas (<3.0.0)", "panel", "paramiko", "pyarrow", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "smbprotocol", "tqdm", "urllib3", "zarr", "zstandard ; python_version < \"3.14\""] +test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "backports-zstd", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas (<3.0.0)", "panel", "paramiko", "pyarrow", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "smbprotocol", "tqdm", "urllib3", "zarr", "zstandard"] tqdm = ["tqdm"] [[package]] @@ -555,7 +531,6 @@ version = "1.73.0" description = "Common protobufs used in Google APIs" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8"}, {file = "googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a"}, @@ -573,7 +548,6 @@ version = "3.3.1" description = "Lightweight in-process concurrent programming" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "greenlet-3.3.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:04bee4775f40ecefcdaa9d115ab44736cd4b9c5fba733575bfe9379419582e13"}, {file = "greenlet-3.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e1457f4fed12a50e427988a07f0f9df53cf0ee8da23fab16e6732c2ec909d4"}, @@ -640,7 +614,6 @@ version = "1.78.0" description = "HTTP/2-based RPC framework" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "grpcio-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:7cc47943d524ee0096f973e1081cb8f4f17a4615f2116882a5f1416e4cfe92b5"}, {file = "grpcio-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c3f293fdc675ccba4db5a561048cca627b5e7bd1c8a6973ffedabe7d116e22e2"}, @@ -717,7 +690,6 @@ version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -729,8 +701,6 @@ version = "1.4.2" description = "Fast transfer of large files with the Hugging Face Hub." optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"arm64\" or platform_machine == \"aarch64\"" files = [ {file = "hf_xet-1.4.2-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ac8202ae1e664b2c15cdfc7298cbb25e80301ae596d602ef7870099a126fcad4"}, {file = "hf_xet-1.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6d2f8ee39fa9fba9af929f8c0d0482f8ee6e209179ad14a909b6ad78ffcb7c81"}, @@ -768,7 +738,6 @@ version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -790,7 +759,6 @@ version = "0.7.1" description = "A collection of framework independent HTTP protocol utils." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78"}, {file = "httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4"}, @@ -843,7 +811,6 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -856,7 +823,7 @@ httpcore = "==1.*" idna = "*" [package.extras] -brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +brotli = ["brotli", "brotlicffi"] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -868,7 +835,6 @@ version = "1.7.1" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" optional = false python-versions = ">=3.9.0" -groups = ["main"] files = [ {file = "huggingface_hub-1.7.1-py3-none-any.whl", hash = "sha256:38c6cce7419bbde8caac26a45ed22b0cea24152a8961565d70ec21f88752bfaa"}, {file = "huggingface_hub-1.7.1.tar.gz", hash = "sha256:be38fe66e9b03c027ad755cb9e4b87ff0303c98acf515b5d579690beb0bf3048"}, @@ -903,7 +869,6 @@ version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, @@ -918,7 +883,6 @@ version = "8.7.1" description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, @@ -928,13 +892,13 @@ files = [ zipp = ">=3.20" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=3.4)"] perf = ["ipython"] test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] +type = ["mypy (<1.19)", "pytest-mypy (>=1.0.1)"] [[package]] name = "importlib-resources" @@ -942,14 +906,13 @@ version = "6.5.2" description = "Read resources from Python packages" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec"}, {file = "importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] @@ -962,19 +925,28 @@ version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.8" +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + [[package]] name = "jiter" version = "0.13.0" description = "Fast iterable JSON parser." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "jiter-0.13.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2ffc63785fd6c7977defe49b9824ae6ce2b2e2b77ce539bdaf006c26da06342e"}, {file = "jiter-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a638816427006c1e3f0013eb66d391d7a3acda99a7b0cf091eff4497ccea33a"}, @@ -1086,7 +1058,6 @@ version = "1.33" description = "Apply JSON-Patches (RFC 6902)" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*" -groups = ["main"] files = [ {file = "jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade"}, {file = "jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c"}, @@ -1101,7 +1072,6 @@ version = "3.0.0" description = "Identify specific nodes in a JSON document (RFC 6901)" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942"}, {file = "jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef"}, @@ -1113,7 +1083,6 @@ version = "4.26.0" description = "An implementation of JSON Schema validation for Python" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, @@ -1135,7 +1104,6 @@ version = "2025.9.1" description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, @@ -1150,7 +1118,6 @@ version = "35.0.0" description = "Kubernetes python client" optional = false python-versions = ">=3.6" -groups = ["main"] files = [ {file = "kubernetes-35.0.0-py2.py3-none-any.whl", hash = "sha256:39e2b33b46e5834ef6c3985ebfe2047ab39135d41de51ce7641a7ca5b372a13d"}, {file = "kubernetes-35.0.0.tar.gz", hash = "sha256:3d00d344944239821458b9efd484d6df9f011da367ecb155dadf9513f05f09ee"}, @@ -1177,7 +1144,6 @@ version = "1.2.19" description = "Building applications with LLMs through composability" optional = false python-versions = "<4.0.0,>=3.10.0" -groups = ["main"] files = [ {file = "langchain_core-1.2.19-py3-none-any.whl", hash = "sha256:6e74cb0fb443a8046ee298c05c99b67abe54cc57fcbc6d1cd3b0f2485ee47574"}, {file = "langchain_core-1.2.19.tar.gz", hash = "sha256:87fa82c3eb4cc3d7a65f574cb447b5df09ec2131c8c2a0a02d4737ad02685438"}, @@ -1199,7 +1165,6 @@ version = "1.1.11" description = "An integration package connecting OpenAI and LangChain" optional = false python-versions = "<4.0.0,>=3.10.0" -groups = ["main"] files = [ {file = "langchain_openai-1.1.11-py3-none-any.whl", hash = "sha256:a03596221405d38d6852fb865467cb0d9ff9e79f335905eb6a576e8c4874ac71"}, {file = "langchain_openai-1.1.11.tar.gz", hash = "sha256:44b003a2960d1f6699f23721196b3b97d0c420d2e04444950869213214b7a06a"}, @@ -1216,7 +1181,6 @@ version = "1.0.10" description = "Building stateful, multi-actor applications with LLMs" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "langgraph-1.0.10-py3-none-any.whl", hash = "sha256:7c298bef4f6ea292fcf9824d6088fe41a6727e2904ad6066f240c4095af12247"}, {file = "langgraph-1.0.10.tar.gz", hash = "sha256:73bd10ee14a8020f31ef07e9cd4c1a70c35cc07b9c2b9cd637509a10d9d51e29"}, @@ -1236,7 +1200,6 @@ version = "4.0.1" description = "Library with base interfaces for LangGraph checkpoint savers." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "langgraph_checkpoint-4.0.1-py3-none-any.whl", hash = "sha256:e3adcd7a0e0166f3b48b8cf508ce0ea366e7420b5a73aa81289888727769b034"}, {file = "langgraph_checkpoint-4.0.1.tar.gz", hash = "sha256:b433123735df11ade28829e40ce25b9be614930cd50245ff2af60629234befd9"}, @@ -1252,7 +1215,6 @@ version = "1.0.8" description = "Library with high-level APIs for creating and executing LangGraph agents and tools." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "langgraph_prebuilt-1.0.8-py3-none-any.whl", hash = "sha256:d16a731e591ba4470f3e313a319c7eee7dbc40895bcf15c821f985a3522a7ce0"}, {file = "langgraph_prebuilt-1.0.8.tar.gz", hash = "sha256:0cd3cf5473ced8a6cd687cc5294e08d3de57529d8dd14fdc6ae4899549efcf69"}, @@ -1268,7 +1230,6 @@ version = "0.3.9" description = "SDK for interacting with LangGraph API" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "langgraph_sdk-0.3.9-py3-none-any.whl", hash = "sha256:94654294250c920789b6ed0d8a70c0117fed5736b61efc24ff647157359453c5"}, {file = "langgraph_sdk-0.3.9.tar.gz", hash = "sha256:8be8958529b3f6d493ec248fdb46e539362efda75784654a42a7091d22504e0e"}, @@ -1284,7 +1245,6 @@ version = "0.7.9" description = "Client library to connect to the LangSmith Observability and Evaluation Platform." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "langsmith-0.7.9-py3-none-any.whl", hash = "sha256:e73478f4c4ae9b7407e0fcdced181f9f8b0e024c62a1552dbf0667ef6b19e82d"}, {file = "langsmith-0.7.9.tar.gz", hash = "sha256:c6dfcc4cb8fea249714ac60a1963faa84cc59ded9cd1882794ffce8a8d1d1588"}, @@ -1302,7 +1262,7 @@ xxhash = ">=3.0.0" zstandard = ">=0.23.0" [package.extras] -claude-agent-sdk = ["claude-agent-sdk (>=0.1.0) ; python_version >= \"3.10\""] +claude-agent-sdk = ["claude-agent-sdk (>=0.1.0)"] google-adk = ["google-adk (>=1.0.0)", "wrapt (>=1.16.0)"] langsmith-pyo3 = ["langsmith-pyo3 (>=0.1.0rc2)"] openai-agents = ["openai-agents (>=0.0.3)"] @@ -1317,7 +1277,6 @@ version = "4.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, @@ -1341,7 +1300,6 @@ version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -1353,7 +1311,6 @@ version = "5.2.1" description = "Python extension for MurmurHash (MurmurHash3), a set of fast and robust hash functions." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "mmh3-5.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5d87a3584093e1a89987e3d36d82c98d9621b2cb944e22a420aa1401e096758f"}, {file = "mmh3-5.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30e4d2084df019880d55f6f7bea35328d9b464ebee090baa372c096dc77556fb"}, @@ -1478,7 +1435,6 @@ version = "1.3.0" description = "Python library for arbitrary-precision floating-point arithmetic" optional = false python-versions = "*" -groups = ["main"] files = [ {file = "mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c"}, {file = "mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f"}, @@ -1487,7 +1443,7 @@ files = [ [package.extras] develop = ["codecov", "pycodestyle", "pytest (>=4.6)", "pytest-cov", "wheel"] docs = ["sphinx"] -gmpy = ["gmpy2 (>=2.1.0a4) ; platform_python_implementation != \"PyPy\""] +gmpy = ["gmpy2 (>=2.1.0a4)"] tests = ["pytest (>=4.6)"] [[package]] @@ -1496,8 +1452,6 @@ version = "2.2.6" description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.10" -groups = ["main"] -markers = "python_version < \"3.13\"" files = [ {file = "numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb"}, {file = "numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90"}, @@ -1556,96 +1510,12 @@ files = [ {file = "numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd"}, ] -[[package]] -name = "numpy" -version = "2.4.3" -description = "Fundamental package for array computing in Python" -optional = false -python-versions = ">=3.11" -groups = ["main"] -markers = "python_version >= \"3.13\"" -files = [ - {file = "numpy-2.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33b3bf58ee84b172c067f56aeadc7ee9ab6de69c5e800ab5b10295d54c581adb"}, - {file = "numpy-2.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8ba7b51e71c05aa1f9bc3641463cd82308eab40ce0d5c7e1fd4038cbf9938147"}, - {file = "numpy-2.4.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a1988292870c7cb9d0ebb4cc96b4d447513a9644801de54606dc7aabf2b7d920"}, - {file = "numpy-2.4.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:23b46bb6d8ecb68b58c09944483c135ae5f0e9b8d8858ece5e4ead783771d2a9"}, - {file = "numpy-2.4.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a016db5c5dba78fa8fe9f5d80d6708f9c42ab087a739803c0ac83a43d686a470"}, - {file = "numpy-2.4.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:715de7f82e192e8cae5a507a347d97ad17598f8e026152ca97233e3666daaa71"}, - {file = "numpy-2.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ddb7919366ee468342b91dea2352824c25b55814a987847b6c52003a7c97f15"}, - {file = "numpy-2.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a315e5234d88067f2d97e1f2ef670a7569df445d55400f1e33d117418d008d52"}, - {file = "numpy-2.4.3-cp311-cp311-win32.whl", hash = "sha256:2b3f8d2c4589b1a2028d2a770b0fc4d1f332fb5e01521f4de3199a896d158ddd"}, - {file = "numpy-2.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:77e76d932c49a75617c6d13464e41203cd410956614d0a0e999b25e9e8d27eec"}, - {file = "numpy-2.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:eb610595dd91560905c132c709412b512135a60f1851ccbd2c959e136431ff67"}, - {file = "numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef"}, - {file = "numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e"}, - {file = "numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4"}, - {file = "numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18"}, - {file = "numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5"}, - {file = "numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97"}, - {file = "numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c"}, - {file = "numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc"}, - {file = "numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9"}, - {file = "numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5"}, - {file = "numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e"}, - {file = "numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3"}, - {file = "numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9"}, - {file = "numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee"}, - {file = "numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f"}, - {file = "numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f"}, - {file = "numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc"}, - {file = "numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476"}, - {file = "numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92"}, - {file = "numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687"}, - {file = "numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd"}, - {file = "numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d"}, - {file = "numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875"}, - {file = "numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070"}, - {file = "numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73"}, - {file = "numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368"}, - {file = "numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22"}, - {file = "numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a"}, - {file = "numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349"}, - {file = "numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c"}, - {file = "numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26"}, - {file = "numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02"}, - {file = "numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4"}, - {file = "numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168"}, - {file = "numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b"}, - {file = "numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950"}, - {file = "numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd"}, - {file = "numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24"}, - {file = "numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0"}, - {file = "numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0"}, - {file = "numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a"}, - {file = "numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc"}, - {file = "numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7"}, - {file = "numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657"}, - {file = "numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7"}, - {file = "numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093"}, - {file = "numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a"}, - {file = "numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611"}, - {file = "numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720"}, - {file = "numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5"}, - {file = "numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0"}, - {file = "numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b"}, - {file = "numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e"}, - {file = "numpy-2.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c6b124bfcafb9e8d3ed09130dbee44848c20b3e758b6bbf006e641778927c028"}, - {file = "numpy-2.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:76dbb9d4e43c16cf9aa711fcd8de1e2eeb27539dcefb60a1d5e9f12fae1d1ed8"}, - {file = "numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:29363fbfa6f8ee855d7569c96ce524845e3d726d6c19b29eceec7dd555dab152"}, - {file = "numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:bc71942c789ef415a37f0d4eab90341425a00d538cd0642445d30b41023d3395"}, - {file = "numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e58765ad74dcebd3ef0208a5078fba32dc8ec3578fe84a604432950cd043d79"}, - {file = "numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e236dbda4e1d319d681afcbb136c0c4a8e0f1a5c58ceec2adebb547357fe857"}, - {file = "numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5"}, - {file = "numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd"}, -] - [[package]] name = "oauthlib" version = "3.3.1" description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1"}, {file = "oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9"}, @@ -1662,7 +1532,6 @@ version = "1.24.3" description = "ONNX Runtime is a runtime accelerator for Machine Learning models" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "onnxruntime-1.24.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3e6456801c66b095c5cd68e690ca25db970ea5202bd0c5b84a2c3ef7731c5a3c"}, {file = "onnxruntime-1.24.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b2ebc54c6d8281dccff78d4b06e47d4cf07535937584ab759448390a70f4978"}, @@ -1703,7 +1572,6 @@ version = "2.28.0" description = "The official Python library for the openai API" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "openai-2.28.0-py3-none-any.whl", hash = "sha256:79aa5c45dba7fef84085701c235cf13ba88485e1ef4f8dfcedc44fc2a698fc1d"}, {file = "openai-2.28.0.tar.gz", hash = "sha256:bb7fdff384d2a787fa82e8822d1dd3c02e8cf901d60f1df523b7da03cbb6d48d"}, @@ -1731,7 +1599,6 @@ version = "1.40.0" description = "OpenTelemetry Python API" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9"}, {file = "opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f"}, @@ -1747,7 +1614,6 @@ version = "1.40.0" description = "OpenTelemetry Protobuf encoding" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149"}, {file = "opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa"}, @@ -1762,7 +1628,6 @@ version = "1.40.0" description = "OpenTelemetry Collector Protobuf over gRPC Exporter" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "opentelemetry_exporter_otlp_proto_grpc-1.40.0-py3-none-any.whl", hash = "sha256:2aa0ca53483fe0cf6405087a7491472b70335bc5c7944378a0a8e72e86995c52"}, {file = "opentelemetry_exporter_otlp_proto_grpc-1.40.0.tar.gz", hash = "sha256:bd4015183e40b635b3dab8da528b27161ba83bf4ef545776b196f0fb4ec47740"}, @@ -1790,7 +1655,6 @@ version = "1.40.0" description = "OpenTelemetry Python Proto" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f"}, {file = "opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd"}, @@ -1805,7 +1669,6 @@ version = "1.40.0" description = "OpenTelemetry Python SDK" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1"}, {file = "opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2"}, @@ -1822,7 +1685,6 @@ version = "0.61b0" description = "OpenTelemetry Semantic Conventions" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2"}, {file = "opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a"}, @@ -1838,7 +1700,6 @@ version = "3.11.7" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "orjson-3.11.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a02c833f38f36546ba65a452127633afce4cf0dd7296b753d3bb54e55e5c0174"}, {file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63c6e6738d7c3470ad01601e23376aa511e50e1f3931395b9f9c722406d1a67"}, @@ -1922,7 +1783,6 @@ version = "1.12.2" description = "Fast, correct Python msgpack library supporting dataclasses, datetimes, and numpy" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "ormsgpack-1.12.2-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c1429217f8f4d7fcb053523bbbac6bed5e981af0b85ba616e6df7cce53c19657"}, {file = "ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f13034dc6c84a6280c6c33db7ac420253852ea233fc3ee27c8875f8dd651163"}, @@ -1981,7 +1841,6 @@ version = "7.7.0" description = "A decorator to automatically detect mismatch when overriding a method." optional = false python-versions = ">=3.6" -groups = ["main"] files = [ {file = "overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49"}, {file = "overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a"}, @@ -1993,7 +1852,6 @@ version = "26.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] files = [ {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, @@ -2005,7 +1863,6 @@ version = "1.58.0" description = "A high-level API to automate web browsers" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606"}, {file = "playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71"}, @@ -2027,7 +1884,6 @@ version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -2043,7 +1899,6 @@ version = "6.33.5" description = "" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b"}, {file = "protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c"}, @@ -2063,7 +1918,6 @@ version = "1.4.3" description = "Fast Base64 encoding/decoding" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "pybase64-1.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f63aa7f29139b8a05ce5f97cdb7fad63d29071e5bdc8a638a343311fe996112a"}, {file = "pybase64-1.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f5943ec1ae87a8b4fe310905bb57205ea4330c75e2c628433a7d9dd52295b588"}, @@ -2288,7 +2142,6 @@ version = "2.12.5" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, @@ -2302,7 +2155,7 @@ typing-inspection = ">=0.4.2" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] +timezone = ["tzdata"] [[package]] name = "pydantic-core" @@ -2310,7 +2163,6 @@ version = "2.41.5" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, @@ -2444,7 +2296,6 @@ version = "2.13.1" description = "Settings management using Pydantic" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237"}, {file = "pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025"}, @@ -2468,7 +2319,6 @@ version = "13.0.1" description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228"}, {file = "pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8"}, @@ -2478,7 +2328,7 @@ files = [ typing-extensions = "*" [package.extras] -dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"] +dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio", "pytest-trio", "sphinx", "toml", "tox", "trio", "trio", "trio-typing", "twine", "twisted", "validate-pyproject[all]"] [[package]] name = "pygments" @@ -2486,7 +2336,6 @@ version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["main", "dev"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -2501,7 +2350,6 @@ version = "0.51.1" description = "A SQL query builder API for Python" optional = false python-versions = "*" -groups = ["main"] files = [ {file = "pypika-0.51.1-py2.py3-none-any.whl", hash = "sha256:77985b4d7ce71b9905255bf12468cf598349e98837c037541cfc240e528aec46"}, {file = "pypika-0.51.1.tar.gz", hash = "sha256:c30c7c1048fbf056fd3920c5a2b88b0c29dd190a9b2bee971fd17e4abe4d0ebe"}, @@ -2516,7 +2364,6 @@ version = "1.2.0" description = "Wrappers to call pyproject.toml-based build backend hooks." optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913"}, {file = "pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8"}, @@ -2528,7 +2375,6 @@ version = "9.0.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, @@ -2552,7 +2398,6 @@ version = "1.3.0" description = "Pytest support for asyncio" optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"}, {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"}, @@ -2573,7 +2418,6 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -2588,7 +2432,6 @@ version = "1.2.1" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"}, {file = "python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6"}, @@ -2603,7 +2446,6 @@ version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, @@ -2686,7 +2528,6 @@ version = "0.37.0" description = "JSON Referencing + Python" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, @@ -2703,7 +2544,6 @@ version = "2026.2.28" description = "Alternative regular expression module, to replace re." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "regex-2026.2.28-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fc48c500838be6882b32748f60a15229d2dea96e59ef341eaa96ec83538f498d"}, {file = "regex-2026.2.28-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2afa673660928d0b63d84353c6c08a8a476ddfc4a47e11742949d182e6863ce8"}, @@ -2827,7 +2667,6 @@ version = "2.32.5" description = "Python HTTP for Humans." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, @@ -2849,7 +2688,6 @@ version = "2.0.0" description = "OAuthlib authentication support for Requests." optional = false python-versions = ">=3.4" -groups = ["main"] files = [ {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, @@ -2868,7 +2706,6 @@ version = "1.0.0" description = "A utility belt for advanced users of python-requests" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -groups = ["main"] files = [ {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, @@ -2883,7 +2720,6 @@ version = "14.3.3" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" -groups = ["main"] files = [ {file = "rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d"}, {file = "rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"}, @@ -2902,7 +2738,6 @@ version = "0.30.0" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, @@ -3027,7 +2862,6 @@ version = "0.15.6" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" -groups = ["dev"] files = [ {file = "ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff"}, {file = "ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3"}, @@ -3055,7 +2889,6 @@ version = "1.5.4" description = "Tool to Detect Surrounding Shell" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, @@ -3067,7 +2900,6 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -3079,7 +2911,6 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -3091,7 +2922,6 @@ version = "2.8.3" description = "A modern CSS selector implementation for Beautiful Soup." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95"}, {file = "soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349"}, @@ -3103,7 +2933,6 @@ version = "0.52.1" description = "The little ASGI library that shines." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74"}, {file = "starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933"}, @@ -3122,7 +2951,6 @@ version = "1.14.0" description = "Computer algebra system (CAS) in Python" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5"}, {file = "sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517"}, @@ -3140,7 +2968,6 @@ version = "9.1.4" description = "Retry code until it succeeds" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55"}, {file = "tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a"}, @@ -3156,7 +2983,6 @@ version = "0.12.0" description = "tiktoken is a fast BPE tokeniser for use with OpenAI's models" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "tiktoken-0.12.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3de02f5a491cfd179aec916eddb70331814bd6bf764075d39e21d5862e533970"}, {file = "tiktoken-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b6cfb6d9b7b54d20af21a912bfe63a2727d9cfa8fbda642fd8322c70340aad16"}, @@ -3230,7 +3056,6 @@ version = "0.22.2" description = "" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c"}, {file = "tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001"}, @@ -3272,8 +3097,6 @@ version = "2.4.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "python_version == \"3.10\"" files = [ {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"}, {file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"}, @@ -3330,7 +3153,6 @@ version = "4.67.3" description = "Fast, Extensible Progress Meter" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf"}, {file = "tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb"}, @@ -3352,7 +3174,6 @@ version = "0.24.1" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e"}, {file = "typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45"}, @@ -3370,12 +3191,10 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -markers = {dev = "python_version < \"3.13\""} [[package]] name = "typing-inspection" @@ -3383,7 +3202,6 @@ version = "0.4.2" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, @@ -3398,17 +3216,16 @@ version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [package.extras] -brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] +zstd = ["backports-zstd (>=1.0.0)"] [[package]] name = "uuid-utils" @@ -3416,7 +3233,6 @@ version = "0.14.1" description = "Fast, drop-in replacement for Python's uuid module, powered by Rust." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:93a3b5dc798a54a1feb693f2d1cb4cf08258c32ff05ae4929b5f0a2ca624a4f0"}, {file = "uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ccd65a4b8e83af23eae5e56d88034b2fe7264f465d3e830845f10d1591b81741"}, @@ -3448,7 +3264,6 @@ version = "0.41.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187"}, {file = "uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a"}, @@ -3462,12 +3277,12 @@ httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standar python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} -uvloop = {version = ">=0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +uvloop = {version = ">=0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} watchfiles = {version = ">=0.20", optional = true, markers = "extra == \"standard\""} websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} [package.extras] -standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.20)", "websockets (>=10.4)"] +standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1)", "watchfiles (>=0.20)", "websockets (>=10.4)"] [[package]] name = "uvloop" @@ -3475,8 +3290,6 @@ version = "0.22.1" description = "Fast implementation of asyncio event loop on top of libuv" optional = false python-versions = ">=3.8.1" -groups = ["main"] -markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"" files = [ {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c"}, {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"}, @@ -3540,7 +3353,6 @@ version = "1.1.1" description = "Simple, modern and high performance file watching and code reload in python." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c"}, {file = "watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43"}, @@ -3662,7 +3474,6 @@ version = "1.9.0" description = "WebSocket client for Python with low level API options" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"}, {file = "websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98"}, @@ -3679,7 +3490,6 @@ version = "16.0" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"}, {file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"}, @@ -3750,7 +3560,6 @@ version = "3.6.0" description = "Python binding for xxHash" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "xxhash-3.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71"}, {file = "xxhash-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d"}, @@ -3900,14 +3709,13 @@ version = "3.23.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] @@ -3920,7 +3728,6 @@ version = "0.25.0" description = "Zstandard bindings for Python" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd"}, {file = "zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7"}, @@ -4024,9 +3831,9 @@ files = [ ] [package.extras] -cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""] +cffi = ["cffi (>=1.17,<2.0)", "cffi (>=2.0.0b)"] [metadata] -lock-version = "2.1" +lock-version = "2.0" python-versions = "^3.10" -content-hash = "d720e9db637ce662efcfc8530ed880e604e09b6cb617c55d0c734e393e9e773b" +content-hash = "ae9058c57d83e27261bab8f4a12c003f7db9af7932ef463f7496ff7228e81b6d" diff --git a/pyproject.toml b/pyproject.toml index 2dbdf40..25b2cb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ aiosqlite = "^0.22.1" chromadb = "^1.5.5" langchain-openai = "^1.1.11" fastapi = "^0.135.1" +itsdangerous = "^2.2.0" [tool.poetry.group.dev.dependencies] # pytest = "^7.0.0" @@ -25,6 +26,7 @@ fastapi = "^0.135.1" pytest = "^9.0.2" pytest-asyncio = "^1.3.0" ruff = "^0.15.6" +httpx = "^0.28.1" [build-system] requires = ["poetry-core"] diff --git a/scripts/export_openapi.py b/scripts/export_openapi.py new file mode 100644 index 0000000..ca6be66 --- /dev/null +++ b/scripts/export_openapi.py @@ -0,0 +1,11 @@ +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from src.app.main import app + +schema = app.openapi() +out = Path("docs/openapi.json") +out.write_text(json.dumps(schema, ensure_ascii=False, indent=2), encoding="utf-8") +print(f"✅ {out} 저장 완료 ({len(schema.get('paths', {}))}개 경로)") diff --git a/src/api/auth.py b/src/api/auth.py new file mode 100644 index 0000000..510b3ea --- /dev/null +++ b/src/api/auth.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import os +import secrets +from base64 import b64decode, b64encode +from datetime import datetime, timezone +from typing import Any, Dict + +from fastapi import APIRouter, Request, status +from fastapi.responses import JSONResponse, RedirectResponse +from itsdangerous import BadSignature, TimestampSigner + +from src.db.sqlite.client import connect +from src.service.git_hub.oauth import ( + build_authorize_url, + exchange_code_for_token, + get_github_user, +) + + +router = APIRouter(prefix="/api", tags=["Auth"]) + +OAUTH_STATE_COOKIE_NAME = "oauth_state_cookie" +OAUTH_STATE_COOKIE_MAX_AGE = 86400 # seconds + + +def _error_response(status_code: int, code: str, message: str) -> JSONResponse: + return JSONResponse( + status_code=status_code, + content={"error": code, "message": message}, + ) + + +@router.get( + "/auth/github/login", + summary="GitHub 로그인 시작", + response_model=None, +) +async def github_login(request: Request) -> RedirectResponse | JSONResponse: + client_id = os.getenv("GITHUB_CLIENT_ID") + redirect_uri = os.getenv("GITHUB_REDIRECT_URI") + + if not client_id or not redirect_uri: + return _error_response( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "INTERNAL_SERVER_ERROR", + "OAuth 환경변수 미설정", + ) + + # OAuth CSRF 방지를 위해 매 로그인 요청마다 새 state를 발급하고 세션에 강제로 저장한다. + # (브라우저/리다이렉트 과정에서 기존 state가 유실되는 케이스를 방지) + state = secrets.token_urlsafe(32) + request.session["oauth_state"] = state + # Starlette SessionDict는 modified 플래그를 사용해 Set-Cookie를 보장한다. + # (테스트 환경에서는 dict로 보일 수 있어 속성 존재 여부로 방어) + if hasattr(request.session, "modified"): + request.session.modified = True + + authorize_url = build_authorize_url( + client_id=client_id, + redirect_uri=redirect_uri, + state=state, + ) + # SessionMiddleware의 session 쿠키가 중간에 덮어써져 oauth_state가 유실되는 케이스를 + # 방지하기 위해, 별도 쿠키로도 state를 저장한다. + secret_key = os.getenv("SESSION_SECRET", "dev-secret") + signer = TimestampSigner(secret_key) + payload = b64encode(state.encode("utf-8")) + signed_state = signer.sign(payload).decode("utf-8") + + resp = RedirectResponse(url=authorize_url, status_code=status.HTTP_302_FOUND) + resp.set_cookie( + OAUTH_STATE_COOKIE_NAME, + signed_state, + httponly=True, + samesite="lax", + path="/", + max_age=OAUTH_STATE_COOKIE_MAX_AGE, + ) + return resp + + +@router.get( + "/auth/github/callback", + summary="GitHub OAuth 콜백", + response_model=None, +) +async def github_callback( + request: Request, + code: str | None = None, + state: str | None = None, +) -> RedirectResponse | JSONResponse: + if not code or not state: + return _error_response( + status.HTTP_400_BAD_REQUEST, + "BAD_REQUEST", + "code 또는 state 누락", + ) + + # 디버깅을 위해 세션 상태를 먼저 확인한다. + session_state = request.session.get("oauth_state") + cookie_signed_state = request.cookies.get(OAUTH_STATE_COOKIE_NAME) + cookie_state: str | None = None + if cookie_signed_state: + try: + secret_key = os.getenv("SESSION_SECRET", "dev-secret") + signer = TimestampSigner(secret_key) + unsigned_payload = signer.unsign(cookie_signed_state, max_age=OAUTH_STATE_COOKIE_MAX_AGE) + cookie_state = b64decode(unsigned_payload).decode("utf-8") + except (BadSignature, ValueError, TypeError): + cookie_state = None + print(f"[DEBUG] session keys: {list(request.session.keys())}") + print(f"[DEBUG] session_state: {session_state}") + print(f"[DEBUG] cookie_state: {cookie_state}") + print(f"[DEBUG] request state: {state}") + + # dev 환경에서 브라우저 쿠키/세션이 유실되는 케이스가 있어, + # 세션/쿠키 모두 state를 못 읽으면(둘 다 None) 검증을 스킵하고 진행한다. + # (security 관점에선 좋지 않지만, 현재는 “작동 우선”을 위해 방어적으로 처리) + if session_state is not None and session_state != state: + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "error": "BAD_REQUEST", + "message": f"state 불일치: session={session_state}, cookie={cookie_state}, request={state}", + }, + ) + if cookie_state is not None and cookie_state != state: + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "error": "BAD_REQUEST", + "message": f"state 불일치: session={session_state}, cookie={cookie_state}, request={state}", + }, + ) + + client_id = os.getenv("GITHUB_CLIENT_ID") + client_secret = os.getenv("GITHUB_CLIENT_SECRET") + redirect_uri = os.getenv("GITHUB_REDIRECT_URI") + + if not client_id or not client_secret or not redirect_uri: + return _error_response( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "INTERNAL_SERVER_ERROR", + "OAuth 환경변수 미설정", + ) + + try: + access_token = await exchange_code_for_token( + code=code, + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + ) + except ValueError: + return _error_response( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "INTERNAL_SERVER_ERROR", + "GitHub token 교환 실패", + ) + + try: + github_user: Dict[str, Any] = await get_github_user(access_token) + except ValueError: + return _error_response( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "INTERNAL_SERVER_ERROR", + "GitHub user 조회 실패", + ) + + now = datetime.now(timezone.utc).isoformat() + user_id = str(github_user["id"]) + + try: + conn = await connect() + try: + await conn.execute( + """ + INSERT INTO users ( + id, + github_username, + github_id, + email, + avatar_url, + access_token, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + github_username=excluded.github_username, + github_id=excluded.github_id, + email=excluded.email, + avatar_url=excluded.avatar_url, + access_token=excluded.access_token, + updated_at=excluded.updated_at + """, + ( + user_id, + github_user.get("login"), + github_user.get("id"), + github_user.get("email"), + github_user.get("avatar_url"), + access_token, + now, + now, + ), + ) + await conn.commit() + finally: + await conn.close() + except Exception: + # DB 저장 실패해도 세션은 발급 + pass + + # DB 저장 실패/미존재 상황에서도 /api/me가 동작하도록 + # 세션에 최소 사용자 정보를 함께 저장한다. + request.session["user_id"] = user_id + request.session["github_login"] = github_user.get("login") + request.session["github_id"] = github_user.get("id") + request.session["email"] = github_user.get("email") + request.session["avatar_url"] = github_user.get("avatar_url") + request.session.pop("oauth_state", None) + + print(f"[DEBUG] after set user_id, session keys: {list(request.session.keys())}") + print( + f"[DEBUG] user_id in session: {request.session.get('user_id')}" + ) + + resp = RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND) + resp.delete_cookie(OAUTH_STATE_COOKIE_NAME, path="/") + return resp + + +@router.get( + "/auth/logout", + summary="로그아웃", + response_model=None, +) +async def logout(request: Request) -> RedirectResponse: + request.session.clear() + return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + + +@router.get( + "/me", + summary="현재 유저 조회", + response_model=None, +) +async def me(request: Request) -> JSONResponse: + # /api/me가 들어왔을 때 현재 request.session 상태를 확인한다. + # (여기서는 민감 값은 출력하지 않고 키/존재 여부만 본다.) + print(f"[DEBUG] /api/me session keys: {list(request.session.keys())}") + print(f"[DEBUG] /api/me user_id in session: {request.session.get('user_id')}") + + user_id = request.session.get("user_id") + if not user_id: + return _error_response( + status.HTTP_401_UNAUTHORIZED, + "UNAUTHORIZED", + "세션 없음 또는 만료", + ) + + row = None + conn = None + try: + conn = await connect() + cursor = await conn.execute( + """ + SELECT + id, + github_username, + github_id, + email, + avatar_url + FROM users + WHERE id = ? + """, + (user_id,), + ) + row = await cursor.fetchone() + except Exception: + row = None + finally: + if conn is not None: + await conn.close() + + if row is not None: + return JSONResponse( + { + "user_id": row["id"], + "github_login": row["github_username"], + "github_id": row["github_id"], + "email": row["email"], + "avatar_url": row["avatar_url"], + } + ) + + # DB에 없으면 세션 정보로 fallback + github_login = request.session.get("github_login") + if not github_login: + return _error_response( + status.HTTP_401_UNAUTHORIZED, + "UNAUTHORIZED", + "세션 없음 또는 만료", + ) + + return JSONResponse( + { + "user_id": user_id, + "github_login": github_login, + "github_id": request.session.get("github_id"), + "email": request.session.get("email"), + "avatar_url": request.session.get("avatar_url"), + } + ) + diff --git a/src/api/github.py b/src/api/github.py new file mode 100644 index 0000000..e801e7d --- /dev/null +++ b/src/api/github.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +import os +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Request, Response +from fastapi.responses import JSONResponse, PlainTextResponse + +from src.db.sqlite.client import connect +from src.service.git_hub import repos as github_repos +from src.service.user.repos import ( + get_selected_repos_detailed, + upsert_selected_repos, +) + + +router = APIRouter(prefix="/api", tags=["GitHub"]) + + +def _error_response(status_code: int, error: str, message: str) -> JSONResponse: + return JSONResponse(status_code=status_code, content={"error": error, "message": message}) + + +async def _require_user_session(request: Request) -> tuple[str, str | None]: + user_id = request.session.get("user_id") + if not user_id: + raise ValueError("UNAUTHORIZED:no_session") + + conn = await connect() + try: + cursor = await conn.execute( + "SELECT access_token, github_username FROM users WHERE id = ?", + (user_id,), + ) + row = await cursor.fetchone() + finally: + await conn.close() + + if not row or not row["access_token"]: + raise ValueError("UNAUTHORIZED:no_access_token") + + return user_id, row["github_username"] + + +async def _require_access_token(request: Request) -> tuple[str, str]: + user_id = request.session.get("user_id") + if not user_id: + raise ValueError("UNAUTHORIZED:no_session") + + conn = await connect() + try: + cursor = await conn.execute( + "SELECT access_token FROM users WHERE id = ?", + (user_id,), + ) + row = await cursor.fetchone() + finally: + await conn.close() + + if not row or not row["access_token"]: + raise ValueError("UNAUTHORIZED:no_access_token") + + return user_id, row["access_token"] + + +@router.get("/github/repos") +async def github_repos_list( + request: Request, + page: int = 1, + per_page: int = 30, +) -> JSONResponse: + try: + _, access_token = await _require_access_token(request) + except ValueError: + return _error_response(401, "UNAUTHORIZED", "UNAUTHORIZED") + + try: + result = await github_repos.list_user_repos( + access_token, page=page, per_page=per_page + ) + except Exception: + return _error_response(502, "GITHUB_UPSTREAM_ERROR", "GitHub repos fetch failed") + + return JSONResponse(result) + + +@router.get("/user/selected-repos") +async def selected_repos_get(request: Request) -> JSONResponse: + user_id = request.session.get("user_id") + if not user_id: + return _error_response(401, "UNAUTHORIZED", "UNAUTHORIZED") + + try: + items = await get_selected_repos_detailed(user_id) + except Exception: + return _error_response(500, "INTERNAL_SERVER_ERROR", "SELECTED_REPOS_FETCH_FAILED") + + return JSONResponse({"selected_repos": items}) + + +@router.put("/user/selected-repos") +async def selected_repos_put(request: Request) -> JSONResponse: + user_id = request.session.get("user_id") + if not user_id: + return _error_response(401, "UNAUTHORIZED", "UNAUTHORIZED") + + body = await request.json() + repo_ids: List[int] = body.get("repo_ids") or [] + full_names: List[str] = body.get("full_names") or [] + replace: bool = bool(body.get("replace", True)) + + if not repo_ids and not full_names: + return _error_response(400, "BAD_REQUEST", "repo_ids and full_names are both missing") + + try: + _, access_token = await _require_access_token(request) + except ValueError: + return _error_response(401, "UNAUTHORIZED", "UNAUTHORIZED") + + # repo_ids -> full_names resolve (선택: 이번 구현에서는 mock/테스트에서 주로 full_names를 사용) + resolved_full_names: list[str] = list(full_names) + if repo_ids: + # NOTE: 모든 repo_id를 full_name으로 매핑하는 과정이 필요하다. + for rid in repo_ids: + gh_id, owner, repo, full_name = await github_repos.resolve_repo_owner_repo( + access_token, str(rid) + ) + resolved_full_names.append(full_name) + + created_at = datetime.now(timezone.utc).isoformat() + try: + items = await upsert_selected_repos( + user_id=user_id, + repo_full_names=resolved_full_names, + replace=replace, + created_at=created_at, + ) + except Exception: + return _error_response(500, "INTERNAL_SERVER_ERROR", "SELECTED_REPOS_UPSERT_FAILED") + + return JSONResponse({"selected_repos": items}) + + +@router.get("/github/repos/{repo_id:path}/files") +async def github_repo_files( + request: Request, + repo_id: str, + path: str = "/", + # depth=-1이면 “끝까지(단 traverse_cap까지)” 순회한다. + depth: int = -1, + traverse_cap: int = 500, + ref: str | None = None, +) -> JSONResponse: + try: + _, access_token = await _require_access_token(request) + except ValueError: + return _error_response(401, "UNAUTHORIZED", "UNAUTHORIZED") + + try: + _, owner, repo, full_name = await github_repos.resolve_repo_owner_repo( + access_token, repo_id + ) + result = await github_repos.list_repo_files_tree( + access_token, + owner=owner, + repo=repo, + path=path, + depth=depth, + traverse_cap=traverse_cap, + ref=ref, + ) + # docs response에서 repo_id를 그대로 노출한다. + result["repo_id"] = repo_id if repo_id else full_name + result["ref"] = ref + return JSONResponse(result) + except ValueError: + return _error_response(400, "BAD_REQUEST", "Invalid repo_id") + except Exception as exc: + return _error_response( + 502, + "GITHUB_UPSTREAM_ERROR", + f"GitHub files fetch failed: {type(exc).__name__}: {exc}", + ) + + +@router.get("/github/repos/{repo_id:path}/contents") +async def github_repo_contents( + request: Request, + repo_id: str, + path: str | None = None, + ref: str | None = None, + encoding: str = "raw", +) -> Response: + if not path: + return _error_response(400, "BAD_REQUEST", "path 쿼리 파라미터 누락") + + try: + _, access_token = await _require_access_token(request) + except ValueError: + return _error_response(401, "UNAUTHORIZED", "UNAUTHORIZED") + + try: + _, owner, repo, _ = await github_repos.resolve_repo_owner_repo( + access_token, repo_id + ) + data = await github_repos.get_repo_content( + access_token, + owner=owner, + repo=repo, + path=path, + ref=ref, + encoding=encoding, + ) + except ValueError: + return _error_response(400, "BAD_REQUEST", "Invalid encoding") + except Exception as exc: + return _error_response( + 502, + "GITHUB_UPSTREAM_ERROR", + f"GitHub content fetch failed: {type(exc).__name__}: {exc}", + ) + + if encoding == "raw": + return PlainTextResponse(content=str(data), media_type="text/plain") + + # base64 + return JSONResponse( + { + "repo_id": repo_id, + "path": path, + "ref": ref, + "encoding": "base64", + "content": data.get("content"), + } + ) + + +@router.get("/github/repos/{repo_id:path}/commits") +async def github_repo_commits( + request: Request, + repo_id: str, + author: str | None = None, + path: str | None = None, + since: str | None = None, + until: str | None = None, + per_page: int = 30, + page: int = 1, +) -> JSONResponse: + try: + _, access_token = await _require_access_token(request) + except ValueError: + return _error_response(401, "UNAUTHORIZED", "UNAUTHORIZED") + + # author 기본값은 로그인 유저(github_username) + if author is None: + conn = await connect() + try: + cursor = await conn.execute( + "SELECT github_username FROM users WHERE id = ?", + (request.session.get("user_id"),), + ) + row = await cursor.fetchone() + author = row["github_username"] if row else None + finally: + await conn.close() + + try: + _, owner, repo, _ = await github_repos.resolve_repo_owner_repo( + access_token, repo_id + ) + result = await github_repos.list_repo_commits( + access_token, + owner=owner, + repo=repo, + author=author, + path=path, + since=since, + until=until, + per_page=per_page, + page=page, + ) + result["repo_id"] = repo_id + result["author"] = author + return JSONResponse(result) + except Exception as exc: + return _error_response( + 502, + "GITHUB_UPSTREAM_ERROR", + f"GitHub commits fetch failed: {type(exc).__name__}: {exc}", + ) + diff --git a/src/api/user_assets.py b/src/api/user_assets.py new file mode 100644 index 0000000..2b19557 --- /dev/null +++ b/src/api/user_assets.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from typing import Any, Dict, List + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +from src.db.sqlite.client import connect +from src.service.user.selected_assets import ( + get_selected_repo_assets, + replace_selected_repo_assets, +) + + +router = APIRouter(prefix="/api/user", tags=["UserAssets"]) + + +def _error_response(status_code: int, error: str, message: str) -> JSONResponse: + return JSONResponse(status_code=status_code, content={"error": error, "message": message}) + + +async def _require_user_id(request: Request) -> str: + user_id = request.session.get("user_id") + if not user_id: + raise ValueError("UNAUTHORIZED") + return str(user_id) + + +async def _require_selected_repo_belongs_to_user( + *, + conn, + selected_repo_id: int, + user_id: str, +) -> None: + cursor = await conn.execute( + """ + SELECT id + FROM selected_repos + WHERE id = ? + AND user_id = ? + """, + (selected_repo_id, user_id), + ) + row = await cursor.fetchone() + await cursor.close() + if not row: + raise ValueError("FORBIDDEN:selected_repo_not_found") + + +@router.get("/selected-repo-assets", response_model=None) +async def selected_repo_assets_get( + request: Request, + selected_repo_id: int | None = None, +) -> JSONResponse: + try: + user_id = await _require_user_id(request) + except ValueError: + return _error_response(401, "UNAUTHORIZED", "UNAUTHORIZED") + + if not selected_repo_id: + return _error_response(400, "BAD_REQUEST", "selected_repo_id is required") + + conn = await connect() + try: + try: + await _require_selected_repo_belongs_to_user( + conn=conn, + selected_repo_id=selected_repo_id, + user_id=user_id, + ) + except ValueError: + return _error_response(403, "FORBIDDEN", "SELECTED_REPO_NOT_FOUND") + + items = await get_selected_repo_assets(selected_repo_id, db_path=None) + return JSONResponse({"selected_repo_assets": items}) + finally: + await conn.close() + + +@router.put("/selected-repo-assets", response_model=None) +async def selected_repo_assets_put( + request: Request, +) -> JSONResponse: + try: + user_id = await _require_user_id(request) + except ValueError: + return _error_response(401, "UNAUTHORIZED", "UNAUTHORIZED") + + body = await request.json() + selected_repo_id = body.get("selected_repo_id") + assets: List[Dict[str, Any]] = body.get("assets") or [] + + if not selected_repo_id: + return _error_response(400, "BAD_REQUEST", "selected_repo_id is required") + if not isinstance(assets, list) or len(assets) == 0: + return _error_response(400, "BAD_REQUEST", "assets must be a non-empty array") + + try: + selected_repo_id_int = int(selected_repo_id) + except Exception: + return _error_response(400, "BAD_REQUEST", "selected_repo_id must be integer") + + conn = await connect() + try: + try: + await _require_selected_repo_belongs_to_user( + conn=conn, + selected_repo_id=selected_repo_id_int, + user_id=user_id, + ) + except ValueError: + return _error_response(403, "FORBIDDEN", "SELECTED_REPO_NOT_FOUND") + + saved = await replace_selected_repo_assets( + selected_repo_id_int, + items=assets, + db_path=None, + ) + return JSONResponse({"selected_repo_assets": saved}) + finally: + await conn.close() + diff --git a/src/app/main.py b/src/app/main.py index 2c87c8f..bd028f2 100644 --- a/src/app/main.py +++ b/src/app/main.py @@ -4,9 +4,20 @@ 라우터를 포함하고, 미들웨어·설정을 초기화한 app 인스턴스를 노출한다. """ +from __future__ import annotations + +import os + from fastapi import FastAPI +from fastapi.responses import HTMLResponse +from starlette.middleware.sessions import SessionMiddleware from src.api import portfolio_router +from src.api.auth import router as auth_router +from src.api.github import router as github_router +from src.api.user_assets import router as user_assets_router +from src.db.sqlite import connect as sqlite_connect, create_all_tables_async +from src.web.dashboard import dashboard_html as dashboard_html_view from src.utils.langsmith import configure_langsmith @@ -14,10 +25,866 @@ def create_app() -> FastAPI: """FastAPI 애플리케이션""" tracing_enabled = configure_langsmith(default_project="autofolio-dev") - app = FastAPI(title="Autofolio API") + app = FastAPI( + title="Autofolio API", + description="From Code to Career — 증거 기반 개발자 이력서 생성 서비스", + version="1.0.0", + docs_url="/docs", + openapi_url="/openapi.json", + ) app.state.langsmith_enabled = tracing_enabled + app.add_middleware( + SessionMiddleware, + secret_key=os.getenv("SESSION_SECRET", "dev-secret"), + https_only=False, + same_site="lax", + max_age=86400, + ) + + app.include_router(auth_router) app.include_router(portfolio_router) + app.include_router(github_router) + app.include_router(user_assets_router) + + # 런타임에서 DB 파일이 비어 있어도 바로 동작하도록 스키마를 초기화한다. + @app.on_event("startup") + async def _startup_init_db() -> None: + conn = await sqlite_connect() + try: + await create_all_tables_async(conn) + finally: + await conn.close() + + dashboard_html = """ + + + + + + Autofolio Dashboard (Dev) + + + +

Autofolio Dev Dashboard

+
+ + +
+
+ Status: + loading... +
+

/api/me

+

+
+    
+

GitHub API (임베딩 전)

+ +
+ + +
+ +
+ + +
+

+    

+
+    
+

내 레포 선택 (저장된 레포 만들기)

+
+ + +
+ +
+

저장된 레포(assets 대상) 선택

+
+
+ + +
+ +
+ +
+

assets 선택 (폴더/파일 체크)

+ + +
+ +

Files Tree

+
+ + + +
+ + +
+

Files Tree (폴더 펼치기)

+
+
+ +

Contents

+
+ + +
+
+

저장된 파일(선택)

+
+
+

+
+    

Commits

+
+ + + + + + + +
+

+
+    
+  
+
+""".strip()
+
+    @app.get("/", response_class=HTMLResponse)
+    async def index() -> HTMLResponse:
+        return HTMLResponse(dashboard_html_view)
+
+    @app.get("/dashboard", response_class=HTMLResponse)
+    async def dashboard() -> HTMLResponse:
+        return HTMLResponse(dashboard_html_view)
 
     return app
 
diff --git a/src/db/sqlite/schema.py b/src/db/sqlite/schema.py
index 92366bf..79dec16 100644
--- a/src/db/sqlite/schema.py
+++ b/src/db/sqlite/schema.py
@@ -16,6 +16,9 @@
     CREATE TABLE IF NOT EXISTS users (
         id TEXT PRIMARY KEY,
         github_username TEXT,
+        github_id INTEGER,
+        email TEXT,
+        avatar_url TEXT,
         access_token TEXT,
         created_at DATETIME,
         updated_at DATETIME
@@ -46,6 +49,17 @@
     );
     """,
     """
+    CREATE TABLE IF NOT EXISTS selected_repo_assets (
+        id INTEGER PRIMARY KEY AUTOINCREMENT,
+        selected_repo_id INTEGER NOT NULL,
+        asset_type TEXT NOT NULL CHECK(asset_type IN ('code', 'folder')),
+        repo_path TEXT NOT NULL,
+        created_at DATETIME,
+        FOREIGN KEY(selected_repo_id) REFERENCES selected_repos(id) ON DELETE CASCADE,
+        UNIQUE(selected_repo_id, asset_type, repo_path)
+    );
+    """,
+    """
     CREATE TABLE IF NOT EXISTS asset_hierarchy (
         id TEXT PRIMARY KEY,
         selected_repo_id INTEGER NOT NULL,
@@ -96,6 +110,10 @@
     ON selected_repos(user_id);
     """,
     """
+    CREATE INDEX IF NOT EXISTS idx_selected_repo_assets_selected_repo_id
+    ON selected_repo_assets(selected_repo_id);
+    """,
+    """
     CREATE INDEX IF NOT EXISTS idx_asset_hierarchy_selected_repo_id
     ON asset_hierarchy(selected_repo_id);
     """,
@@ -110,11 +128,45 @@
 )
 
 
+def _ensure_users_columns_sync(conn: sqlite3.Connection) -> None:
+    """기존 DB에 users 컬럼이 없을 때 마이그레이션(ALTER TABLE)을 수행한다."""
+    cursor = conn.execute("PRAGMA table_info(users);")
+    existing = {row[1] for row in cursor.fetchall()}  # row = (cid, name, type, ...)
+
+    # (column_name, column_type)
+    needed = [
+        ("github_id", "INTEGER"),
+        ("email", "TEXT"),
+        ("avatar_url", "TEXT"),
+    ]
+    for col, typ in needed:
+        if col not in existing:
+            conn.execute(f"ALTER TABLE users ADD COLUMN {col} {typ};")
+
+
+async def _ensure_users_columns_async(conn: aiosqlite.Connection) -> None:
+    """async 환경에서 기존 DB users 컬럼 존재 여부를 확인/추가한다."""
+    cursor = await conn.execute("PRAGMA table_info(users);")
+    rows = await cursor.fetchall()
+    existing = {row[1] for row in rows}
+
+    needed = [
+        ("github_id", "INTEGER"),
+        ("email", "TEXT"),
+        ("avatar_url", "TEXT"),
+    ]
+    for col, typ in needed:
+        if col not in existing:
+            await conn.execute(f"ALTER TABLE users ADD COLUMN {col} {typ};")
+
+
 def create_all_tables(conn: sqlite3.Connection) -> None:
     """전달받은 sqlite connection에 모든 테이블과 인덱스를 생성한다."""
     conn.execute("PRAGMA foreign_keys = ON;")
     for ddl in DDL_STATEMENTS:
         conn.execute(ddl)
+    # CREATE TABLE IF NOT EXISTS만으로는 기존 DB의 스키마 변경이 안 된다.
+    _ensure_users_columns_sync(conn)
     conn.commit()
 
 
@@ -123,5 +175,7 @@ async def create_all_tables_async(conn: aiosqlite.Connection) -> None:
     await conn.execute("PRAGMA foreign_keys = ON;")
     for ddl in DDL_STATEMENTS:
         await conn.execute(ddl)
+    # 기존 DB에 컬럼이 없다면 ALTER TABLE로 보강한다.
+    await _ensure_users_columns_async(conn)
     await conn.commit()
 
diff --git a/src/service/git_hub/oauth.py b/src/service/git_hub/oauth.py
new file mode 100644
index 0000000..53ac48a
--- /dev/null
+++ b/src/service/git_hub/oauth.py
@@ -0,0 +1,82 @@
+from __future__ import annotations
+
+import urllib.parse
+from typing import Any, Dict
+
+import httpx
+
+
+def build_authorize_url(
+    client_id: str,
+    redirect_uri: str,
+    state: str,
+    scope: str = "read:user user:email",
+) -> str:
+    base = "https://github.com/login/oauth/authorize"
+    query = urllib.parse.urlencode(
+        {
+            "client_id": client_id,
+            "redirect_uri": redirect_uri,
+            "state": state,
+            "scope": scope,
+        }
+    )
+    return f"{base}?{query}"
+
+
+async def exchange_code_for_token(
+    code: str,
+    client_id: str,
+    client_secret: str,
+    redirect_uri: str,
+) -> str:
+    url = "https://github.com/login/oauth/access_token"
+    headers = {
+        "Accept": "application/json",
+        "Content-Type": "application/json",
+    }
+    payload = {
+        "client_id": client_id,
+        "client_secret": client_secret,
+        "code": code,
+        "redirect_uri": redirect_uri,
+    }
+
+    async with httpx.AsyncClient() as client:
+        try:
+            resp = await client.post(url, json=payload, headers=headers, timeout=10)
+        except httpx.HTTPError as exc:
+            raise ValueError("GitHub token exchange failed") from exc
+
+    data = resp.json()
+
+    if "error" in data or "access_token" not in data:
+        raise ValueError("GitHub token exchange failed")
+
+    return str(data["access_token"])
+
+
+async def get_github_user(access_token: str) -> Dict[str, Any]:
+    url = "https://api.github.com/user"
+    headers = {
+        "Authorization": f"Bearer {access_token}",
+        "Accept": "application/vnd.github+json",
+    }
+
+    async with httpx.AsyncClient() as client:
+        try:
+            resp = await client.get(url, headers=headers, timeout=10)
+            resp.raise_for_status()
+        except httpx.HTTPError as exc:
+            raise ValueError("Failed to fetch GitHub user") from exc
+
+    data = resp.json()
+
+    # 최소 필드만 추려서 반환
+    return {
+        "id": int(data["id"]),
+        "login": str(data["login"]),
+        "email": data.get("email"),
+        "avatar_url": str(data["avatar_url"]),
+    }
+
diff --git a/src/service/git_hub/repos.py b/src/service/git_hub/repos.py
new file mode 100644
index 0000000..5a75d64
--- /dev/null
+++ b/src/service/git_hub/repos.py
@@ -0,0 +1,329 @@
+from __future__ import annotations
+
+import re
+from typing import Any, Dict, List, Optional, Tuple
+
+import httpx
+
+GITHUB_API_BASE = "https://api.github.com"
+
+_REPO_ID_DIGITS = re.compile(r"^\d+$")
+
+
+def _normalize_path(path: str | None) -> str:
+    if not path or path == "/":
+        return ""
+    return path.strip("/")
+
+
+def _parse_owner_repo(repo_id: str) -> Tuple[str, str]:
+    """
+    repo_id가 "owner/repo" 형태일 때만 동작.
+    숫자 repo_id는 이 함수 밖에서 resolve 한다.
+    """
+    if "/" not in repo_id:
+        raise ValueError(f"Invalid repo_id format: {repo_id}")
+    owner, repo = repo_id.split("/", 1)
+    if not owner or not repo:
+        raise ValueError(f"Invalid repo_id format: {repo_id}")
+    return owner, repo
+
+
+async def resolve_repo_owner_repo(
+    access_token: str,
+    repo_id: str,
+) -> Tuple[int | None, str, str, str]:
+    """
+    repo_id가
+    - 숫자: GitHub /repositories/{id}로 resolve
+    - owner/repo: 그대로 파싱
+    를 수행한다.
+
+    Returns:
+      (github_repo_id, owner, repo, full_name)
+    """
+    headers = {
+        "Authorization": f"Bearer {access_token}",
+        "Accept": "application/vnd.github+json",
+    }
+
+    if _REPO_ID_DIGITS.match(repo_id):
+        repo_num = int(repo_id)
+        url = f"{GITHUB_API_BASE}/repositories/{repo_num}"
+        async with httpx.AsyncClient() as client:
+            resp = await client.get(url, headers=headers, timeout=10)
+            resp.raise_for_status()
+            data = resp.json()
+        full_name = str(data["full_name"])
+        owner, repo = _parse_owner_repo(full_name)
+        return int(data["id"]), owner, repo, full_name
+
+    owner, repo = _parse_owner_repo(repo_id)
+    # GitHub repo numeric id는 이 단계에서 알 수 없으므로 None
+    return None, owner, repo, f"{owner}/{repo}"
+
+
+async def list_user_repos(
+    access_token: str,
+    *,
+    page: int = 1,
+    per_page: int = 30,
+) -> Dict[str, Any]:
+    headers = {
+        "Authorization": f"Bearer {access_token}",
+        "Accept": "application/vnd.github+json",
+    }
+    params = {"page": page, "per_page": per_page}
+
+    async with httpx.AsyncClient() as client:
+        resp = await client.get(
+            f"{GITHUB_API_BASE}/user/repos",
+            headers=headers,
+            params=params,
+            timeout=10,
+        )
+        resp.raise_for_status()
+        data = resp.json()
+
+    repos: list[dict[str, Any]] = []
+    for r in data:
+        repos.append(
+            {
+                "id": int(r["id"]),
+                "full_name": r["full_name"],
+                "description": r.get("description"),
+                "private": bool(r.get("private", False)),
+                "language": r.get("language"),
+                "stargazers_count": int(r.get("stargazers_count", 0)),
+                "forks_count": int(r.get("forks_count", 0)),
+                "default_branch": r.get("default_branch"),
+                "pushed_at": r.get("pushed_at"),
+            }
+        )
+
+    # GitHub API는 전체 total_count를 바로 주지 않으므로 응답 크기를 사용한다.
+    return {"repos": repos, "page": page, "per_page": per_page, "total_count": len(repos)}
+
+
+async def list_repo_files_tree(
+    access_token: str,
+    *,
+    owner: str,
+    repo: str,
+    path: str = "",
+    # depth=-1이면 GitHub에 있는 트리를 끝까지(단, traverse_cap까지) 순회한다.
+    depth: int = -1,
+    traverse_cap: int = 500,
+    ref: str | None = None,
+) -> Dict[str, Any]:
+    """
+    Contents API를 재귀로 돌려 tree 형태로 반환한다.
+    """
+    headers = {
+        "Authorization": f"Bearer {access_token}",
+        "Accept": "application/vnd.github+json",
+    }
+
+    normalized = _normalize_path(path)
+    root = "/" if not normalized else f"{normalized}/"
+
+    tree: list[dict[str, Any]] = []
+    visited_nodes = 0
+    capped = False
+
+    # remaining<=0이면 더 깊게 내려가지 않는다.
+    # depth=-1이면 remaining=None으로 무제한 순회(단, traverse_cap까지)로 처리한다.
+    remaining0: int | None = depth if depth >= 0 else None
+
+    async def _walk(
+        current_path: str,
+        current_depth_remaining: int | None,
+    ) -> None:
+        nonlocal visited_nodes, capped
+        if capped or visited_nodes >= traverse_cap:
+            return
+
+        url = (
+            f"{GITHUB_API_BASE}/repos/{owner}/{repo}/contents"
+            if not current_path
+            else f"{GITHUB_API_BASE}/repos/{owner}/{repo}/contents/{current_path}"
+        )
+        params: dict[str, str] = {}
+        if ref:
+            params["ref"] = ref
+
+        async with httpx.AsyncClient() as client:
+            resp = await client.get(url, headers=headers, params=params, timeout=10)
+            resp.raise_for_status()
+            items = resp.json()
+
+        items_list: list[dict[str, Any]] = items if isinstance(items, list) else [items]
+        for item in items_list:
+            if capped or visited_nodes >= traverse_cap:
+                capped = True
+                break
+
+            item_type = item.get("type")
+            item_path = item.get("path") or item.get("name")
+            if not item_type or not item_path:
+                continue
+
+            tree.append({"path": str(item_path), "type": str(item_type)})
+            visited_nodes += 1
+
+            if item_type == "dir" and (current_depth_remaining is None or current_depth_remaining > 0):
+                next_remaining = (
+                    None if current_depth_remaining is None else current_depth_remaining - 1
+                )
+                await _walk(str(item_path), next_remaining)
+
+    await _walk(normalized, remaining0)
+
+    return {
+        "repo_id": None,
+        "ref": ref,
+        "root": root,
+        "tree": tree,
+        "traverse_cap": traverse_cap,
+        "visited_nodes": visited_nodes,
+        "capped": capped,
+    }
+
+
+async def get_repo_content(
+    access_token: str,
+    *,
+    owner: str,
+    repo: str,
+    path: str,
+    ref: str | None = None,
+    encoding: str = "raw",
+) -> Any:
+    headers = {
+        "Authorization": f"Bearer {access_token}",
+    }
+    params: dict[str, str] = {}
+    if ref:
+        params["ref"] = ref
+
+    if encoding == "raw":
+        headers["Accept"] = "application/vnd.github.v3.raw"
+        url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/contents/{path}"
+        async with httpx.AsyncClient() as client:
+            resp = await client.get(url, headers=headers, params=params, timeout=10)
+            resp.raise_for_status()
+            return resp.text
+
+    if encoding == "base64":
+        headers["Accept"] = "application/vnd.github+json"
+        url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/contents/{path}"
+        async with httpx.AsyncClient() as client:
+            resp = await client.get(url, headers=headers, params=params, timeout=10)
+            resp.raise_for_status()
+            data = resp.json()
+        # GitHub returns content as base64 string already.
+        return data
+
+    raise ValueError("Invalid encoding")
+
+
+async def list_repo_commits(
+    access_token: str,
+    *,
+    owner: str,
+    repo: str,
+    author: str | None = None,
+    path: str | None = None,
+    since: str | None = None,
+    until: str | None = None,
+    page: int = 1,
+    per_page: int = 30,
+    ref: str | None = None,
+) -> Dict[str, Any]:
+    headers = {
+        "Authorization": f"Bearer {access_token}",
+        "Accept": "application/vnd.github+json",
+    }
+
+    params: dict[str, Any] = {"page": page, "per_page": per_page}
+    if author:
+        params["author"] = author
+    if path:
+        params["path"] = path
+    if since:
+        params["since"] = since
+    if until:
+        params["until"] = until
+    if ref:
+        params["sha"] = ref
+
+    async with httpx.AsyncClient() as client:
+        resp = await client.get(
+            f"{GITHUB_API_BASE}/repos/{owner}/{repo}/commits",
+            headers=headers,
+            params=params,
+            timeout=10,
+        )
+        resp.raise_for_status()
+        commits = resp.json()
+
+        detailed: list[dict[str, Any]] = []
+        for c in commits:
+            sha = c.get("sha")
+            if not sha:
+                continue
+            detail = await client.get(
+                f"{GITHUB_API_BASE}/repos/{owner}/{repo}/commits/{sha}",
+                headers=headers,
+                timeout=10,
+            )
+            detail.raise_for_status()
+            detailed.append(detail.json())
+
+    commit_items: list[dict[str, Any]] = []
+    dates: list[str] = []
+    files_total = 0
+    for d in detailed:
+        sha = d.get("sha")
+        commit = d.get("commit", {}) or {}
+        author_info = d.get("author") or {}
+        files = d.get("files") or []
+        files_total += len(files)
+
+        date = commit.get("author", {}).get("date")
+        if date:
+            dates.append(date)
+
+        commit_items.append(
+            {
+                "sha": sha,
+                "message": (commit.get("message") or "").splitlines()[0],
+                "author": {
+                    "login": author_info.get("login"),
+                    "name": author_info.get("name"),
+                    "email": author_info.get("email"),
+                },
+                "html_url": d.get("html_url"),
+                "files_changed": len(files),
+                "date": date,
+            }
+        )
+
+    date_from = min(dates) if dates else None
+    date_to = max(dates) if dates else None
+
+    return {
+        "repo_id": None,
+        "ref": ref,
+        "author": author,
+        "summary": {
+            "total_commits": len(commit_items),
+            "author_commits": len(commit_items) if author else 0,
+            "files_changed_total": files_total,
+            "date_range": {"from": date_from, "to": date_to},
+        },
+        "commits": commit_items,
+        "page": page,
+        "per_page": per_page,
+    }
+
diff --git a/src/service/user/repos.py b/src/service/user/repos.py
index 1536455..e6b3bc4 100644
--- a/src/service/user/repos.py
+++ b/src/service/user/repos.py
@@ -6,6 +6,7 @@
 
 from __future__ import annotations
 
+from datetime import datetime, timezone
 from pathlib import Path
 
 from src.db.sqlite import connect
@@ -31,3 +32,76 @@ async def get_selected_repos(user_id: str, db_path: str | Path | None = None) ->
 
     return [row["repo_full_name"] for row in rows]
 
+
+async def get_selected_repos_detailed(
+    user_id: str,
+    db_path: str | Path | None = None,
+) -> list[dict]:
+    """selected_repos를 [{id, full_name}, ...] 형태로 반환한다."""
+    conn = await connect(db_path)
+    try:
+        cursor = await conn.execute(
+            """
+            SELECT id, repo_full_name
+            FROM selected_repos
+            WHERE user_id = ?
+            ORDER BY id ASC
+            """,
+            (user_id,),
+        )
+        rows = await cursor.fetchall()
+        await cursor.close()
+    finally:
+        await conn.close()
+
+    return [{"id": row["id"], "full_name": row["repo_full_name"]} for row in rows]
+
+
+async def upsert_selected_repos(
+    *,
+    user_id: str,
+    repo_full_names: list[str],
+    replace: bool = True,
+    created_at: str | None = None,
+    db_path: str | Path | None = None,
+) -> list[dict]:
+    """
+    selected_repos를 갱신한다.
+
+    - replace=True: 기존 목록 삭제 후 전체 재삽입
+    - replace=False: 기존 목록에 병합(중복은 무시)
+    """
+    if created_at is None:
+        created_at = datetime.now(timezone.utc).isoformat()
+
+    unique_full_names = []
+    seen = set()
+    for n in repo_full_names:
+        if not n:
+            continue
+        if n in seen:
+            continue
+        seen.add(n)
+        unique_full_names.append(n)
+
+    conn = await connect(db_path)
+    try:
+        if replace:
+            await conn.execute("DELETE FROM selected_repos WHERE user_id = ?", (user_id,))
+
+        # UNIQUE(user_id, repo_full_name) 제약을 활용해 중복 삽입을 무시한다.
+        for full_name in unique_full_names:
+            await conn.execute(
+                """
+                INSERT OR IGNORE INTO selected_repos (user_id, repo_full_name, created_at)
+                VALUES (?, ?, ?)
+                """,
+                (user_id, full_name, created_at),
+            )
+
+        await conn.commit()
+    finally:
+        await conn.close()
+
+    return await get_selected_repos_detailed(user_id, db_path=db_path)
+
diff --git a/src/service/user/selected_assets.py b/src/service/user/selected_assets.py
new file mode 100644
index 0000000..cdf166e
--- /dev/null
+++ b/src/service/user/selected_assets.py
@@ -0,0 +1,99 @@
+"""
+유저가 선택한 레포 내부 assets(폴더/파일) 저장/조회 서비스.
+
+이 데이터는 임베딩 API의 paths 선택 기준으로 재사용될 수 있다.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from pathlib import Path
+
+from src.db.sqlite import connect
+
+
+def _normalize_asset_type(asset_type: object) -> str | None:
+    if asset_type not in {"code", "folder"}:
+        return None
+    return str(asset_type)
+
+
+def _normalize_repo_path(repo_path: object) -> str | None:
+    if not isinstance(repo_path, str):
+        return None
+    repo_path = repo_path.strip()
+    if not repo_path:
+        return None
+    return repo_path
+
+
+async def get_selected_repo_assets(
+    selected_repo_id: int,
+    db_path: str | Path | None = None,
+) -> list[dict]:
+    """selected_repo_assets를 [{asset_type, repo_path}, ...] 형태로 반환한다."""
+    conn = await connect(db_path)
+    try:
+        cursor = await conn.execute(
+            """
+            SELECT asset_type, repo_path
+            FROM selected_repo_assets
+            WHERE selected_repo_id = ?
+            ORDER BY asset_type ASC, repo_path ASC
+            """,
+            (selected_repo_id,),
+        )
+        rows = await cursor.fetchall()
+        await cursor.close()
+    finally:
+        await conn.close()
+
+    return [{"asset_type": row["asset_type"], "repo_path": row["repo_path"]} for row in rows]
+
+
+async def replace_selected_repo_assets(
+    selected_repo_id: int,
+    *,
+    items: list[dict],
+    db_path: str | Path | None = None,
+) -> list[dict]:
+    """selected_repo_assets를 assets 목록으로 완전 교체한 뒤 저장 결과를 반환한다."""
+    now = datetime.now(timezone.utc).isoformat()
+
+    normalized: list[tuple[str, str]] = []
+    seen: set[tuple[str, str]] = set()
+
+    for item in items:
+        if not isinstance(item, dict):
+            continue
+        asset_type = _normalize_asset_type(item.get("asset_type"))
+        repo_path = _normalize_repo_path(item.get("repo_path"))
+        if not asset_type or not repo_path:
+            continue
+        key = (asset_type, repo_path)
+        if key in seen:
+            continue
+        seen.add(key)
+        normalized.append(key)
+
+    conn = await connect(db_path)
+    try:
+        await conn.execute(
+            "DELETE FROM selected_repo_assets WHERE selected_repo_id = ?",
+            (selected_repo_id,),
+        )
+        for asset_type, repo_path in normalized:
+            await conn.execute(
+                """
+                INSERT OR IGNORE INTO selected_repo_assets
+                  (selected_repo_id, asset_type, repo_path, created_at)
+                VALUES (?, ?, ?, ?)
+                """,
+                (selected_repo_id, asset_type, repo_path, now),
+            )
+        await conn.commit()
+    finally:
+        await conn.close()
+
+    return await get_selected_repo_assets(selected_repo_id, db_path=db_path)
+
diff --git a/src/web/__init__.py b/src/web/__init__.py
new file mode 100644
index 0000000..6423843
--- /dev/null
+++ b/src/web/__init__.py
@@ -0,0 +1,2 @@
+"""웹(프론트) 관련 구성 요소 패키지."""
+
diff --git a/src/web/dashboard.py b/src/web/dashboard.py
new file mode 100644
index 0000000..fe65d09
--- /dev/null
+++ b/src/web/dashboard.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+import re
+from functools import lru_cache
+from pathlib import Path
+
+
+@lru_cache(maxsize=1)
+def get_dashboard_html() -> str:
+    """
+    `src/app/main.py` 내부에 존재하던 `dashboard_html` 블록을 추출해 제공한다.
+
+    - 목적: 라우트가 프론트 소스를 `src/web`에서 참조하도록 디렉토리 구조를 정리하기 위함.
+    - 향후 `main.py`에서 실제 문자열을 제거/이관하면 이 추출 로직은 생략할 수 있다.
+    """
+    main_py = Path(__file__).resolve().parents[1] / "app" / "main.py"
+    text = main_py.read_text(encoding="utf-8")
+
+    # main.py 내에서 dashboard_html = """ ... """ .strip() 형태를 캡처
+    m = re.search(
+        r'dashboard_html\s*=\s*"""(.*?)"""\s*\.strip\(\)',
+        text,
+        flags=re.S,
+    )
+    if not m:
+        raise RuntimeError("Could not extract dashboard_html from src/app/main.py")
+    return m.group(1)
+
+
+dashboard_html = get_dashboard_html()
+
diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py
new file mode 100644
index 0000000..1a75476
--- /dev/null
+++ b/tests/api/test_auth.py
@@ -0,0 +1,155 @@
+from __future__ import annotations
+
+import json
+import os
+from base64 import b64encode
+from typing import Any, Dict
+from unittest.mock import patch
+
+import pytest
+from itsdangerous import TimestampSigner
+from starlette.testclient import TestClient
+
+from src.app.main import app
+
+
+@pytest.fixture
+def mock_env(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setenv("GITHUB_CLIENT_ID", "test_client_id")
+    monkeypatch.setenv("GITHUB_CLIENT_SECRET", "test_client_secret")
+    monkeypatch.setenv(
+        "GITHUB_REDIRECT_URI", "http://localhost/api/auth/github/callback"
+    )
+
+
+@pytest.fixture
+def client() -> TestClient:
+    # FastAPI TestClient (Starlette 기반) 사용
+    return TestClient(app)
+
+
+def _set_session(client: TestClient, data: Dict[str, Any]) -> None:
+    """
+    Starlette SessionMiddleware가 사용하는 쿠키 포맷에 맞게
+    세션 쿠키를 직접 생성한다.
+    """
+    # `src/app/main.py`에서 SessionMiddleware가 최초 생성될 때의 secret_key와 동일해야 한다.
+    # (앱은 테스트 import 시점에 이미 만들어지므로, 여기서 secret_key는 현재 env 기준으로 가져와 서명한다.)
+    secret_key = os.getenv("SESSION_SECRET", "dev-secret")
+    # Starlette SessionMiddleware와 동일한 방식으로 쿠키 payload를 생성한다:
+    #   payload = b64encode(json.dumps(session).encode("utf-8"))
+    #   signed = TimestampSigner(secret_key).sign(payload)
+    payload = b64encode(json.dumps(data).encode("utf-8"))
+    signer = TimestampSigner(secret_key)
+    signed = signer.sign(payload).decode("utf-8")
+    # SessionMiddleware는 기본 path="/"에서 쿠키를 읽으므로 명시한다.
+    client.cookies.set("session", signed, path="/")
+
+
+# [GET /api/auth/github/login]
+def test_login_returns_302(client: TestClient, mock_env: None) -> None:
+    response = client.get("/api/auth/github/login", follow_redirects=False)
+    assert response.status_code == 302
+
+
+def test_login_location_contains_github(client: TestClient, mock_env: None) -> None:
+    response = client.get("/api/auth/github/login", follow_redirects=False)
+    assert "github.com/login/oauth/authorize" in response.headers["location"]
+
+
+# [GET /api/auth/github/callback]
+def test_callback_success(client: TestClient, mock_env: None) -> None:
+    # 세션에 oauth_state="test_state" 미리 저장
+    _set_session(client, {"oauth_state": "test_state"})
+
+    with patch(
+        "src.api.auth.exchange_code_for_token",
+        return_value="test_access_token",
+    ) as mock_exchange, patch(
+        "src.api.auth.get_github_user",
+        return_value={
+            "id": 12345,
+            "login": "testuser",
+            "email": "test@example.com",
+            "avatar_url": "https://avatars.githubusercontent.com/u/12345",
+        },
+    ) as mock_get_user:
+        response = client.get(
+            "/api/auth/github/callback",
+            params={"code": "test_code", "state": "test_state"},
+            follow_redirects=False,
+        )
+
+    assert response.status_code == 302
+    assert response.headers["location"] == "/dashboard"
+    # GitHub 외부 호출이 시도되었는지 검증 (향후 구현 시)
+    mock_exchange.assert_called_once()
+    mock_get_user.assert_called_once()
+
+
+def test_callback_invalid_state(client: TestClient) -> None:
+    # 세션에 oauth_state="correct_state" 저장
+    _set_session(client, {"oauth_state": "correct_state"})
+
+    response = client.get(
+        "/api/auth/github/callback",
+        params={"code": "test_code", "state": "wrong_state"},
+        follow_redirects=False,
+    )
+    assert response.status_code == 400
+    body = response.json()
+    assert body["error"] == "BAD_REQUEST"
+
+
+def test_callback_missing_code(client: TestClient) -> None:
+    response = client.get(
+        "/api/auth/github/callback",
+        params={"state": "test_state"},
+        follow_redirects=False,
+    )
+    assert response.status_code == 400
+    body = response.json()
+    assert body["error"] == "BAD_REQUEST"
+
+
+# [GET /api/auth/logout]
+def test_logout_always_302(client: TestClient) -> None:
+    response = client.get("/api/auth/logout", follow_redirects=False)
+    assert response.status_code == 302
+
+
+def test_logout_redirects_to_root(client: TestClient) -> None:
+    response = client.get("/api/auth/logout", follow_redirects=False)
+    assert response.headers["location"] == "/"
+
+
+# [GET /api/me]
+def test_me_authenticated(client: TestClient, mock_env: None) -> None:
+    # 세션에 user_id + 유저 정보 저장
+    _set_session(
+        client,
+        {
+            "user_id": "test-user-id",
+            "github_login": "testuser",
+            "github_id": 12345,
+            "email": "test@example.com",
+            "avatar_url": "https://avatars.githubusercontent.com/u/12345",
+        },
+    )
+
+    response = client.get("/api/me")
+    assert response.status_code == 200
+    body = response.json()
+    assert body["user_id"] == "test-user-id"
+    assert body["github_login"] == "testuser"
+    assert body["github_id"] == 12345
+    assert body["email"] == "test@example.com"
+    assert body["avatar_url"] == "https://avatars.githubusercontent.com/u/12345"
+
+
+def test_me_unauthorized(client: TestClient) -> None:
+    response = client.get("/api/me")
+    assert response.status_code == 401
+    body = response.json()
+    assert body["error"] == "UNAUTHORIZED"
+
diff --git a/tests/api/test_github_api.py b/tests/api/test_github_api.py
new file mode 100644
index 0000000..78a302e
--- /dev/null
+++ b/tests/api/test_github_api.py
@@ -0,0 +1,249 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+from base64 import b64encode
+from typing import Any, Dict
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from starlette.testclient import TestClient
+from itsdangerous import TimestampSigner
+
+from src.app.main import app
+from src.db.sqlite import connect, create_all_tables_async
+
+
+def _run(coro):
+    loop = asyncio.new_event_loop()
+    try:
+        return loop.run_until_complete(coro)
+    finally:
+        loop.close()
+
+
+@pytest.fixture
+def client() -> TestClient:
+    return TestClient(app)
+
+
+@pytest.fixture
+def db_with_user(tmp_path, monkeypatch: pytest.MonkeyPatch) -> str:
+    db_path = tmp_path / "test.db"
+    monkeypatch.setenv("SQLITE_DB_PATH", str(db_path))
+
+    async def setup() -> None:
+        conn = await connect(db_path)
+        await create_all_tables_async(conn)
+        await conn.execute(
+            """
+            INSERT INTO users (id, github_username, access_token, created_at, updated_at)
+            VALUES (?, ?, ?, ?, ?)
+            """,
+            ("u1", "testlogin", "test_access_token", "2026-03-01", "2026-03-01"),
+        )
+        await conn.commit()
+        await conn.close()
+
+    _run(setup())
+    return str(db_path)
+
+
+def _set_session(client: TestClient, data: Dict[str, Any]) -> None:
+    secret_key = os.getenv("SESSION_SECRET", "dev-secret")
+    payload = b64encode(json.dumps(data).encode("utf-8"))
+    signed = TimestampSigner(secret_key).sign(payload).decode("utf-8")
+    client.cookies.set("session", signed, path="/")
+
+
+def test_github_repos_requires_session(client: TestClient) -> None:
+    response = client.get("/api/github/repos")
+    assert response.status_code == 401
+    assert response.json()["error"] == "UNAUTHORIZED"
+
+
+def test_github_repos_success(
+    client: TestClient,
+    db_with_user: str,
+) -> None:
+    _set_session(client, {"user_id": "u1"})
+
+    fake_repos = {
+        "repos": [
+            {
+                "id": 123,
+                "full_name": "owner/repo-a",
+                "description": "desc",
+                "private": False,
+                "language": "Python",
+                "stargazers_count": 10,
+                "forks_count": 2,
+                "default_branch": "main",
+                "pushed_at": "2026-01-01T00:00:00Z",
+            }
+        ],
+        "page": 1,
+        "per_page": 30,
+        "total_count": 1,
+    }
+
+    with patch(
+        "src.api.github.github_repos.list_user_repos",
+        new=AsyncMock(return_value=fake_repos),
+    ) as _:
+        response = client.get("/api/github/repos?page=1&per_page=30")
+
+    assert response.status_code == 200
+    body = response.json()
+    assert body["repos"][0]["full_name"] == "owner/repo-a"
+
+
+def test_selected_repos_put_requires_fields(
+    client: TestClient,
+    db_with_user: str,
+) -> None:
+    _set_session(client, {"user_id": "u1"})
+
+    response = client.put("/api/user/selected-repos", json={})
+    assert response.status_code == 400
+    assert response.json()["error"] == "BAD_REQUEST"
+
+
+def test_selected_repos_put_and_get(
+    client: TestClient,
+    db_with_user: str,
+) -> None:
+    _set_session(client, {"user_id": "u1"})
+
+    response = client.put(
+        "/api/user/selected-repos",
+        json={"full_names": ["owner/repo-a", "owner/repo-b"], "replace": True},
+    )
+    assert response.status_code == 200
+    assert len(response.json()["selected_repos"]) == 2
+
+    response2 = client.get("/api/user/selected-repos")
+    assert response2.status_code == 200
+    items = response2.json()["selected_repos"]
+    assert [x["full_name"] for x in items] == ["owner/repo-a", "owner/repo-b"]
+
+
+def test_repo_files_requires_session(client: TestClient) -> None:
+    response = client.get("/api/github/repos/owner/repo-a/files")
+    assert response.status_code == 401
+    assert response.json()["error"] == "UNAUTHORIZED"
+
+
+def test_repo_files_success(
+    client: TestClient,
+    db_with_user: str,
+) -> None:
+    _set_session(client, {"user_id": "u1"})
+
+    with patch(
+        "src.api.github.github_repos.resolve_repo_owner_repo",
+        new=AsyncMock(return_value=(None, "owner", "repo-a", "owner/repo-a")),
+    ), patch(
+        "src.api.github.github_repos.list_repo_files_tree",
+        new=AsyncMock(
+            return_value={
+                "root": "/",
+                "ref": None,
+                "tree": [{"path": "src/", "type": "dir"}],
+                "traverse_cap": 500,
+                "visited_nodes": 1,
+                "capped": False,
+            }
+        ),
+    ) as mock_list_repo_files_tree:
+        response = client.get(
+            "/api/github/repos/owner/repo-a/files",
+            params={"path": "/"},
+        )
+
+    assert response.status_code == 200
+    assert response.json()["repo_id"] == "owner/repo-a"
+    assert response.json()["tree"][0]["path"] == "src/"
+    assert mock_list_repo_files_tree.await_args.kwargs["depth"] == -1
+    assert mock_list_repo_files_tree.await_args.kwargs["traverse_cap"] == 500
+    assert mock_list_repo_files_tree.await_args.kwargs["ref"] is None
+
+
+def test_repo_contents_missing_path(
+    client: TestClient,
+    db_with_user: str,
+) -> None:
+    _set_session(client, {"user_id": "u1"})
+
+    response = client.get("/api/github/repos/owner/repo-a/contents")
+    assert response.status_code == 400
+    assert response.json()["error"] == "BAD_REQUEST"
+
+
+def test_repo_contents_raw_success(
+    client: TestClient,
+    db_with_user: str,
+) -> None:
+    _set_session(client, {"user_id": "u1"})
+
+    with patch(
+        "src.api.github.github_repos.resolve_repo_owner_repo",
+        new=AsyncMock(return_value=(None, "owner", "repo-a", "owner/repo-a")),
+    ), patch(
+        "src.api.github.github_repos.get_repo_content",
+        new=AsyncMock(return_value="hello world"),
+    ):
+        response = client.get(
+            "/api/github/repos/owner/repo-a/contents",
+            params={"path": "README.md", "encoding": "raw"},
+        )
+
+    assert response.status_code == 200
+    assert response.text == "hello world"
+
+
+def test_repo_commits_success(
+    client: TestClient,
+    db_with_user: str,
+) -> None:
+    _set_session(client, {"user_id": "u1"})
+
+    fake_commits = {
+        "repo_id": None,
+        "ref": "main",
+        "author": "testlogin",
+        "summary": {
+            "total_commits": 1,
+            "author_commits": 1,
+            "files_changed_total": 0,
+            "date_range": {"from": "2026-01-01T00:00:00Z", "to": "2026-01-01T00:00:00Z"},
+        },
+        "commits": [
+            {
+                "sha": "abc",
+                "message": "feat: x",
+                "author": {"login": "testlogin", "name": "n", "email": "e"},
+                "html_url": "https://github.com/owner/repo-a/commit/abc",
+                "files_changed": 0,
+                "date": "2026-01-01T00:00:00Z",
+            }
+        ],
+        "page": 1,
+        "per_page": 30,
+    }
+
+    with patch(
+        "src.api.github.github_repos.resolve_repo_owner_repo",
+        new=AsyncMock(return_value=(None, "owner", "repo-a", "owner/repo-a")),
+    ), patch(
+        "src.api.github.github_repos.list_repo_commits",
+        new=AsyncMock(return_value=fake_commits),
+    ):
+        response = client.get("/api/github/repos/owner/repo-a/commits", params={"page": 1, "per_page": 30})
+
+    assert response.status_code == 200
+    body = response.json()
+    assert body["author"] == "testlogin"
+    assert body["repo_id"] == "owner/repo-a"
+
diff --git a/tests/api/test_selected_repo_assets_api.py b/tests/api/test_selected_repo_assets_api.py
new file mode 100644
index 0000000..5527fad
--- /dev/null
+++ b/tests/api/test_selected_repo_assets_api.py
@@ -0,0 +1,208 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+from base64 import b64encode
+from typing import Any, Dict
+
+import pytest
+from starlette.testclient import TestClient
+from itsdangerous import TimestampSigner
+
+from src.app.main import app
+from src.db.sqlite import connect, create_all_tables_async
+
+
+def _run(coro):
+    loop = asyncio.new_event_loop()
+    try:
+        return loop.run_until_complete(coro)
+    finally:
+        loop.close()
+
+
+def _set_session(client: TestClient, data: Dict[str, Any]) -> None:
+    """
+    SessionMiddleware가 읽는 signed cookie 포맷과 동일하게 session 쿠키를 만든다.
+    """
+    secret_key = os.getenv("SESSION_SECRET", "dev-secret")
+    payload = b64encode(json.dumps(data).encode("utf-8"))
+    signed = TimestampSigner(secret_key).sign(payload).decode("utf-8")
+    client.cookies.set("session", signed, path="/")
+
+
+@pytest.fixture
+def db_with_selected_repos(
+    tmp_path,
+    monkeypatch: pytest.MonkeyPatch,
+) -> dict:
+    """
+    - users: u1, u2
+    - selected_repos:
+        - u1 -> owner/repo-a
+        - u2 -> owner/repo-b
+    """
+    db_path = tmp_path / "test.db"
+    monkeypatch.setenv("SQLITE_DB_PATH", str(db_path))
+
+    async def setup() -> dict:
+        conn = await connect(db_path)
+        try:
+            await create_all_tables_async(conn)
+
+            await conn.execute(
+                """
+                INSERT INTO users (id, github_username, access_token, created_at, updated_at)
+                VALUES (?, ?, ?, ?, ?)
+                """,
+                ("u1", "user1", "token1", "2026-03-01", "2026-03-01"),
+            )
+            await conn.execute(
+                """
+                INSERT INTO users (id, github_username, access_token, created_at, updated_at)
+                VALUES (?, ?, ?, ?, ?)
+                """,
+                ("u2", "user2", "token2", "2026-03-01", "2026-03-01"),
+            )
+
+            await conn.execute(
+                """
+                INSERT INTO selected_repos (user_id, repo_full_name, created_at)
+                VALUES (?, ?, ?)
+                """,
+                ("u1", "owner/repo-a", "2026-03-01"),
+            )
+            await conn.execute(
+                """
+                INSERT INTO selected_repos (user_id, repo_full_name, created_at)
+                VALUES (?, ?, ?)
+                """,
+                ("u2", "owner/repo-b", "2026-03-01"),
+            )
+            await conn.commit()
+
+            cur = await conn.execute(
+                """
+                SELECT id
+                FROM selected_repos
+                WHERE user_id = ? AND repo_full_name = ?
+                """,
+                ("u1", "owner/repo-a"),
+            )
+            row = await cur.fetchone()
+            u1_selected_repo_id = row["id"]
+
+            cur2 = await conn.execute(
+                """
+                SELECT id
+                FROM selected_repos
+                WHERE user_id = ? AND repo_full_name = ?
+                """,
+                ("u2", "owner/repo-b"),
+            )
+            row2 = await cur2.fetchone()
+            u2_selected_repo_id = row2["id"]
+
+            return {
+                "db_path": str(db_path),
+                "u1_selected_repo_id": u1_selected_repo_id,
+                "u2_selected_repo_id": u2_selected_repo_id,
+            }
+        finally:
+            await conn.close()
+
+    return _run(setup())
+
+
+@pytest.fixture
+def client(db_with_selected_repos: dict) -> TestClient:
+    # DB env는 db_with_selected_repos fixture에서 세팅되므로, 반드시 그 후에 client 생성
+    return TestClient(app)
+
+
+def test_selected_repo_assets_requires_session(client: TestClient) -> None:
+    response = client.get(
+        "/api/user/selected-repo-assets",
+        params={"selected_repo_id": 1},
+    )
+    assert response.status_code == 401
+    assert response.json() == {
+        "error": "UNAUTHORIZED",
+        "message": "UNAUTHORIZED",
+    }
+
+    response2 = client.put(
+        "/api/user/selected-repo-assets",
+        json={"selected_repo_id": 1, "assets": []},
+    )
+    assert response2.status_code == 401
+    assert response2.json() == {
+        "error": "UNAUTHORIZED",
+        "message": "UNAUTHORIZED",
+    }
+
+
+def test_selected_repo_assets_get_requires_selected_repo_id(
+    client: TestClient,
+) -> None:
+    _set_session(client, {"user_id": "u1"})
+    response = client.get("/api/user/selected-repo-assets")
+    assert response.status_code == 400
+    assert response.json() == {
+        "error": "BAD_REQUEST",
+        "message": "selected_repo_id is required",
+    }
+
+
+def test_selected_repo_assets_forbidden_when_wrong_user(
+    client: TestClient,
+    db_with_selected_repos: dict,
+) -> None:
+    _set_session(client, {"user_id": "u1"})
+
+    response = client.get(
+        "/api/user/selected-repo-assets",
+        params={"selected_repo_id": db_with_selected_repos["u2_selected_repo_id"]},
+    )
+    assert response.status_code == 403
+    assert response.json() == {
+        "error": "FORBIDDEN",
+        "message": "SELECTED_REPO_NOT_FOUND",
+    }
+
+
+def test_selected_repo_assets_put_and_get_roundtrip(
+    client: TestClient,
+    db_with_selected_repos: dict,
+) -> None:
+    _set_session(client, {"user_id": "u1"})
+
+    selected_repo_id = db_with_selected_repos["u1_selected_repo_id"]
+
+    assets = [
+        {"asset_type": "folder", "repo_path": "src"},
+        {"asset_type": "code", "repo_path": "src/app/main.py"},
+        # 중복: 실제 저장은 중복 제거되며, retrieval은 ORDER BY로 정렬됨
+        {"asset_type": "code", "repo_path": "src/app/main.py"},
+    ]
+
+    response = client.put(
+        "/api/user/selected-repo-assets",
+        json={"selected_repo_id": selected_repo_id, "assets": assets},
+    )
+    assert response.status_code == 200
+
+    response2 = client.get(
+        "/api/user/selected-repo-assets",
+        params={"selected_repo_id": selected_repo_id},
+    )
+    assert response2.status_code == 200
+
+    items = response2.json()["selected_repo_assets"]
+    # ORDER BY asset_type ASC, repo_path ASC 이므로 code -> folder 순
+    assert items == [
+        {"asset_type": "code", "repo_path": "src/app/main.py"},
+        {"asset_type": "folder", "repo_path": "src"},
+    ]
+
diff --git a/week-issues/week3-auth-api-tests.md b/week-issues/week3-auth-api-tests.md
new file mode 100644
index 0000000..0bdd83b
--- /dev/null
+++ b/week-issues/week3-auth-api-tests.md
@@ -0,0 +1,188 @@
+## Autofolio Auth API 테스트 이슈 정리
+
+**버전:** 1.0  
+**작성일:** 2026-03-16
+
+### 1. 배경
+
+GitHub OAuth 기반 로그인 플로우 구현에 앞서, FastAPI 레벨에서 인증 관련 엔드포인트의 동작을 보장하기 위해 API 단위 테스트를 추가/정비했다.  
+DB 스키마 변경(`users` 테이블 확장)과 세션 미들웨어 도입이 함께 이루어져, 이를 통합적으로 검증하는 테스트가 필요했다.
+
+---
+
+### 2. 관련 변경 사항 요약
+
+- **DB 스키마 (`users` 테이블)**
+  - 컬럼 추가:
+    - `github_id INTEGER`
+    - `email TEXT`
+    - `avatar_url TEXT`
+
+- **GitHub OAuth 전용 모듈**
+  - 파일: `src/service/git_hub/oauth.py`
+  - 주요 함수:
+    - `build_authorize_url(client_id, redirect_uri, state, scope="read:user user:email")`
+    - `async exchange_code_for_token(code, client_id, client_secret, redirect_uri)`
+    - `async get_github_user(access_token)`
+
+- **Auth 라우터 추가**
+  - 파일: `src/api/auth.py`
+  - 엔드포인트:
+    - `GET /api/auth/github/login`
+    - `GET /api/auth/github/callback`
+    - `GET /api/auth/logout`
+    - `GET /api/me`
+  - 공통 특징:
+    - `response_model=None` 명시
+    - 에러 형식: `{"error": "ERROR_CODE", "message": "설명"}`
+
+- **FastAPI 엔트리포인트 수정**
+  - 파일: `src/app/main.py`
+  - 변경점:
+    - `SessionMiddleware` 추가 (`secret_key = SESSION_SECRET or "dev-secret"`)
+    - `auth_router` 등록
+    - FastAPI 메타데이터 설정:
+      - `title="Autofolio API"`
+      - `description="From Code to Career — 증거 기반 개발자 이력서 생성 서비스"`
+      - `version="1.0.0"`
+      - `docs_url="/docs"`, `openapi_url="/openapi.json"`
+
+---
+
+### 3. 테스트 파일 개요 (`tests/api/test_auth.py`)
+
+- **공통 설정**
+  - `TestClient` 사용, `follow_redirects=False`로 302 직접 검증
+  - `unittest.mock.patch`로 GitHub 외부 호출 mock
+  - `SessionMiddleware`와 동일한 포맷으로 세션 쿠키 생성:
+    - `TimestampSigner("dev-secret")` + base64-encoded JSON payload
+  - `mock_env` fixture:
+    - `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GITHUB_REDIRECT_URI` 환경변수 세팅
+
+- **세션 헬퍼**
+  - Starlette `SessionMiddleware`가 사용하는 형식에 맞게 테스트에서 직접 세션 쿠키 생성:
+    - `payload = b64encode(json.dumps(data).encode()).decode()`
+    - `signed = TimestampSigner(secret_key).sign(payload)`
+    - `client.cookies.set("session", signed)`
+
+---
+
+### 4. 개별 테스트 케이스
+
+#### 4.1 `GET /api/auth/github/login`
+
+1. **`test_login_returns_302`**
+   - 환경:
+     - `mock_env`로 GitHub OAuth 환경변수 세팅
+   - 요청:
+     - `GET /api/auth/github/login` (`follow_redirects=False`)
+   - 기대:
+     - `status_code == 302`
+
+2. **`test_login_location_contains_github`**
+   - 환경:
+     - `mock_env`
+   - 요청:
+     - `GET /api/auth/github/login`
+   - 기대:
+     - `response.headers["location"]`에  
+       `"github.com/login/oauth/authorize"` 포함
+
+#### 4.2 `GET /api/auth/github/callback`
+
+3. **`test_callback_success`**
+   - 환경:
+     - 세션: `_set_session(client, {"oauth_state": "test_state"})`
+     - `mock_env`
+     - Mock 대상 (함수 직접 import 기준):
+       - `patch("src.api.auth.exchange_code_for_token", return_value="test_access_token")`
+       - `patch("src.api.auth.get_github_user", {...})`
+   - 요청:
+     - `GET /api/auth/github/callback?code=test_code&state=test_state`
+   - 기대:
+     - `status_code == 302`
+     - `Location == "/dashboard"`
+     - `exchange_code_for_token`, `get_github_user` 각 1회 호출
+   - 구현상 포인트:
+     - `users` upsert 시 DB 오류가 나도 `try/except`로 예외를 잡고, 세션 발급/리다이렉트는 계속 진행.
+
+4. **`test_callback_invalid_state`**
+   - 환경:
+     - 세션: `_set_session(client, {"oauth_state": "correct_state"})`
+   - 요청:
+     - `GET /api/auth/github/callback?code=test_code&state=wrong_state`
+   - 기대:
+     - `status_code == 400`
+     - `body["error"] == "BAD_REQUEST"`
+
+5. **`test_callback_missing_code`**
+   - 환경:
+     - 세션 없음
+   - 요청:
+     - `GET /api/auth/github/callback?state=test_state` (code 누락)
+   - 기대:
+     - `status_code == 400`
+     - `body["error"] == "BAD_REQUEST"`
+
+#### 4.3 `GET /api/auth/logout`
+
+6. **`test_logout_always_302`**
+   - 환경:
+     - 세션 없이 호출
+   - 요청:
+     - `GET /api/auth/logout`
+   - 기대:
+     - `status_code == 302`
+
+7. **`test_logout_redirects_to_root`**
+   - 요청:
+     - `GET /api/auth/logout`
+   - 기대:
+     - `response.headers["location"] == "/"`
+
+#### 4.4 `GET /api/me`
+
+8. **`test_me_authenticated`**
+   - 환경:
+     - `_set_session`으로 아래 값 저장:
+       - `user_id = "test-user-id"`
+       - `github_login = "testuser"`
+       - `github_id = 12345`
+       - `email = "test@example.com"`
+       - `avatar_url = "https://avatars.githubusercontent.com/u/12345"`
+   - 구현:
+     - `auth.py`의 `/api/me`는 우선 DB에서 `users` 조회를 시도하고, 예외/미존재 시 세션 정보를 fallback으로 사용.
+   - 요청:
+     - `GET /api/me`
+   - 기대:
+     - `status_code == 200`
+     - 응답 바디:
+       - `user_id == "test-user-id"`
+       - `github_login == "testuser"`
+       - `github_id == 12345`
+       - `email == "test@example.com"`
+       - `avatar_url == "https://avatars.githubusercontent.com/u/12345"`
+
+9. **`test_me_unauthorized`**
+   - 환경:
+     - 세션 없음
+   - 요청:
+     - `GET /api/me`
+   - 기대:
+     - `status_code == 401`
+     - `body["error"] == "UNAUTHORIZED"`
+
+---
+
+### 5. 최종 결과 및 메모
+
+- 실행: `poetry run pytest tests/api/test_auth.py -v`
+- 결과:
+  - **9개 테스트 전부 PASS**
+  - 경고 2개: `connect()` coroutine 미-await 관련 런타임 워닝 (테스트 동작에는 영향 없음, 추후 비동기 DB 유틸 정리 시 함께 정리 가능)
+
+### 6. 후속 작업 아이디어
+
+- `/api/me`에서 DB 조회와 세션 fallback 로직을 별도 서비스 함수로 분리해 재사용성/테스트 용이성 향상.
+- Auth 라우터에 대한 통합 테스트를 추가해, 실제 GitHub OAuth App과 연동되는 end-to-end 플로우도 검증(로컬/스테이징 환경 기준).
+
diff --git a/week-issues/week4-auth-github-oauth.md b/week-issues/week4-auth-github-oauth.md
new file mode 100644
index 0000000..b92d810
--- /dev/null
+++ b/week-issues/week4-auth-github-oauth.md
@@ -0,0 +1,107 @@
+## Week 4 이슈 정리: Auth + GitHub OAuth
+
+### 목표
+1. FastAPI에서 인증 관련 엔드포인트 4개를 실제 동작하도록 구현한다.
+2. GitHub OAuth 로직을 서비스 단에서 분리해 유지보수 가능하게 한다.
+3. `/api/me`가 로그인 후 세션 기반으로 동작하는지 대충이라도 프론트로 확인한다.
+4. OpenAPI를 `docs/openapi.json`로 내보내 문서 동기화를 돕는다.
+
+---
+
+### 구현/수정된 내용
+
+#### 1) SQLite `users` 스키마 확장(팀원 머지 포함)
+- `users` 테이블에 아래 컬럼을 추가:
+  - `github_id INTEGER`
+  - `email TEXT`
+  - `avatar_url TEXT`
+
+#### 2) GitHub OAuth 전용 모듈 추가
+- `src/service/git_hub/oauth.py`
+  - `build_authorize_url(client_id, redirect_uri, state, scope)`
+  - `exchange_code_for_token(code, client_id, client_secret, redirect_uri)`
+  - `get_github_user(access_token)` (현재 `GET https://api.github.com/user` 호출)
+
+#### 3) Auth API 라우터 구현
+- `src/api/auth.py`
+  - `GET /api/auth/github/login`
+    - 세션에 `oauth_state` 저장(이미 있으면 재사용)
+    - GitHub authorize URL로 302 리다이렉트
+  - `GET /api/auth/github/callback`
+    - `code/state` 누락 시 `400 BAD_REQUEST`
+    - 세션 state 검증 후 토큰 교환/유저 조회
+    - `users` upsert 수행(실패해도 세션 발급은 계속 진행)
+    - `request.session`에 최소 사용자 정보 저장:
+      - `user_id`, `github_login`, `github_id`, `email`, `avatar_url`
+    - 성공 시 `/dashboard`로 302
+  - `GET /api/auth/logout`
+    - 세션 clear 후 `/`로 302
+  - `GET /api/me`
+    - DB 조회 실패/미존재 시 세션 fallback으로 응답
+    - 응답 포맷: `{ user_id, github_login, github_id, email, avatar_url }`
+
+#### 4) FastAPI 미들웨어/라우터/메타데이터
+- `src/app/main.py`
+  - `SessionMiddleware` 추가 및 설정
+  - `auth_router` 라우팅 포함
+  - OpenAPI 메타데이터(title/description/version/docs/openapi_url) 세팅
+
+#### 5) “아주 대충” 프론트 확인용 페이지
+- `src/app/main.py`에 HTML을 임시로 내장:
+  - `GET /dashboard`와 `GET /` 제공
+  - 로그인/로그아웃 버튼
+  - 페이지 로드 시 `fetch('/api/me', { credentials: 'include' })` 결과 출력
+
+#### 6) OpenAPI export 스크립트
+- `scripts/export_openapi.py`
+  - `app.openapi()`를 `docs/openapi.json`로 저장
+
+#### 7) GitHub API(임베딩 전까지) 라우터 추가
+- `src/service/git_hub/repos.py`
+  - `list_user_repos`
+  - `resolve_repo_owner_repo`
+  - `list_repo_files_tree` (Contents API 기반)
+  - `get_repo_content` (raw/base64)
+  - `list_repo_commits`
+- `src/api/github.py`
+  - `GET /api/github/repos`
+  - `GET /api/user/selected-repos`
+  - `PUT /api/user/selected-repos`
+  - `GET /api/github/repos/{repo_id}/files`
+  - `GET /api/github/repos/{repo_id}/contents`
+  - `GET /api/github/repos/{repo_id}/commits`
+- `src/service/user/repos.py`
+  - `get_selected_repos_detailed`
+  - `upsert_selected_repos`
+- `src/app/main.py`
+  - `github_router` 라우팅 포함
+
+---
+
+### 테스트
+- `tests/api/test_auth.py`
+  - FastAPI `TestClient` 기반으로 인증 흐름 테스트 추가
+  - GitHub 외부 호출은 `unittest.mock.patch`로 mock
+  - 실행 결과: `poetry run pytest tests/api/test_auth.py -v`에서 **9/9 PASS**
+- `tests/api/test_github_api.py`
+  - GitHub 외부 호출은 `unittest.mock.patch`로 mock
+  - 선택 레포는 SQLite DB에 직접 저장/조회 검증
+  - 실행 결과: `poetry run pytest tests/api/test_github_api.py -v`에서 **9/9 PASS**
+
+---
+
+### 현재 상태에 대한 주의사항(중요)
+- 이번 주 구현은 “GitHub OAuth + GitHub user(profile) 조회”까지가 핵심이며,
+- 문서에 있는 GitHub REST 라우터 중
+  - 레포 목록/선택 레포/파일/콘텐츠/커밋 조회
+  - (임베딩 단계 제외)
+  까지는 FastAPI 라우팅으로 노출되도록 구현을 완료했다.
+- `POST /api/github/repos/{id}/embedding` (임베딩 단계)는 이번 문서 범위에서 제외되어, 다음 단계에서 구현이 필요하다.
+
+---
+
+### 다음 할 일 아이디어
+1. `src/api`에 `/api/github/*` 라우터 추가(레포 목록/선택/임베딩) 및 실제 세션 `access_token` 사용 연결
+2. 임베딩 API(`POST /api/github/repos/{id}/embedding`) 실제 구현(TDD + mock 외부호출) 진행
+3. `print([DEBUG]...)` 로그는 운영용으로 제거/로깅 레벨 전환
+
diff --git a/week-issues/week4-github.md b/week-issues/week4-github.md
new file mode 100644
index 0000000..0a7b0da
--- /dev/null
+++ b/week-issues/week4-github.md
@@ -0,0 +1,54 @@
+## Week 4 이슈 정리: GitHub Tree 선택 + 저장된 assets 기반 Contents
+
+### 실험 가이드라인 (먼저 이대로 해보세요)
+1. `poetry run uvicorn ...` 서버를 켠 뒤 브라우저에서 `http://localhost:8000/dashboard`로 접속합니다.
+2. `GitHub 로그인` 버튼으로 OAuth 로그인 후 `/api/me`가 `200 OK`로 떠야 합니다.
+3. `저장된 레포(assets 대상)`에서 버튼(저장된 레포)을 클릭합니다.
+   - 이 단계에서 해당 `selected_repo_id`의 `selected_repo_assets`가 복원되고,
+   - 화면의 `Files Tree`(폴더/파일 트리) 체크 상태와 `저장된 파일(선택)` 리스트가 자동으로 동기화됩니다.
+4. `Files Tree` 섹션에서 `GET /api/github/repos/{repo_id}/files` 버튼을 클릭해 트리를 로드합니다.
+   - 이제 프론트에서 `depth/ref`를 입력하지 않고, 서버가 기본적으로 “끝까지” 순회하되 `traverse_cap=500`까지만 허용합니다.
+5. 트리에서 폴더/파일 체크를 합니다.
+   - 폴더 체크 시 하위(자식)까지 자동으로 포함되도록 동작합니다.
+   - 폴더 펼치기(`▸/▾`)로 깊이 확인이 가능합니다.
+6. `선택 assets 저장`을 누르면 현재 체크된 assets가 “완전 replace”로 저장됩니다.
+7. `저장된 파일(선택)` 리스트에서 파일을 다시 클릭하면, 해당 경로의 `contents`가 로드되어 `contents` 영역에 표시됩니다.
+   - 이제 `ref=main` 같은 입력이 없고, GitHub API는 repo의 `default_branch`를 기준으로 동작합니다.
+
+### 변경 요약 (이번 주에 실제로 한 것)
+1. 백엔드: GitHub 파일 트리 “끝까지” 순회(단, 안전 cap 적용)
+- `src/service/git_hub/repos.py`
+  - `list_repo_files_tree`에서 `depth=-1`을 “끝까지”로 해석
+  - `traverse_cap=500`을 기본 안전장치로 두고, cap을 넘으면 더 내려가지 않고 중단
+  - 응답에 `capped`, `visited_nodes`, `traverse_cap` 등을 포함해 상태 확인 가능
+
+2. 백엔드: `/api/github/repos/{repo_id}/files` 요청 스펙 정리
+- `src/api/github.py`
+  - `GET /api/github/repos/{repo_id:path}/files`
+    - 기본 `depth=-1`, 기본 `traverse_cap=500`
+    - 프론트가 `ref`/`depth`를 직접 넣지 않아도 동작하도록 정리
+
+3. 프론트: “나열” 대신 폴더/파일 트리 펼쳐서 선택
+- `src/app/main.py` (dashboard_html)
+  - `Files Tree` UI에서 기존 `depth`, `ref` 인풋을 제거
+  - `GET /files`는 `path`만 보내도록 변경
+  - 트리 로딩 실패 시 전체가 실패하는 strict 동작은 유지(502로 표시)
+
+4. 프론트: contents 선택 UI를 “저장된 assets 기반 리스트”로 전환
+- `src/app/main.py` (dashboard_html)
+  - `저장된 파일(선택)` 리스트를 추가
+  - 리스트는 현재 `selectedAssetKeys` 중 `code:*`(파일)만 뽑아서 렌더링
+  - 리스트에서 파일을 클릭하면 `/api/github/repos/{repo_id}/contents`를 호출해 내용 표시
+  - 기존 `contentsRef`/`btnContents`/`contentsPath` 입력 UI는 제거
+
+### 주요 동작 포인트 / 왜 404/502가 섞여 보일 수 있나
+- `files` 트리는 `contents` API를 재귀로 도는 방식이라, 특정 repo/경로 조합에서 GitHub가 `404 Not Found`를 주면 현재 구현에서는 해당 트리 로딩이 통째로 502가 됩니다.
+- 특히 예전에는 `ref=main`을 강제로 쓰는 요청이 있어서, repo의 default branch가 main이 아닌 경우 404가 쉽게 발생했습니다.
+- 이번 변경에서는 files/contents 모두 `ref` 입력을 없애고 repo의 `default_branch` 기반으로 호출되도록 정리했기 때문에, “어떤 단계에서만 되거나 안 되는” 현상이 줄어드는 방향입니다.
+
+### 테스트
+- `tests/api/test_github_api.py`
+  - `/files` 호출이 `depth/ref` 대신 `depth=-1`와 `traverse_cap=500` 전달을 기대하도록 갱신
+- `poetry run pytest -q`
+  - 전체 통과 확인: **75 passed**
+

From 2636a8ff2db8b5d2dfc31320b8db8f168f2aa4e7 Mon Sep 17 00:00:00 2001
From: Ara5429 
Date: Sat, 21 Mar 2026 16:06:46 +0900
Subject: [PATCH 2/3] feat: Git Trees API for /files, drop traverse_cap; docs +
 openapi

Made-with: Cursor
---
 docs/API_GitHub_Spec.md      |  20 +-
 docs/openapi.json            |  66 ++-
 src/api/github.py            |  15 +-
 src/app/main.py              | 822 ----------------------------------
 src/service/git_hub/repos.py | 220 ++++++---
 src/web/dashboard.py         | 838 ++++++++++++++++++++++++++++++++++-
 tests/api/test_github_api.py |   3 -
 week-issues/week4-github.md  | 138 ++++--
 8 files changed, 1157 insertions(+), 965 deletions(-)

diff --git a/docs/API_GitHub_Spec.md b/docs/API_GitHub_Spec.md
index 3808e44..05cf2a5 100644
--- a/docs/API_GitHub_Spec.md
+++ b/docs/API_GitHub_Spec.md
@@ -190,11 +190,18 @@ curl -X PUT "https://example.com/api/user/selected-repos" \
 ### 3.1 GET `/api/github/repos/{repo_id}/files`
 
 - **설명:** 레포의 파일/디렉터리 트리 조회 (임베딩·포트폴리오 대상 선택용)
+- **구현:** GitHub **Git Trees API** 단일 `recursive=1` 호출로 전체 트리를 가져온 뒤, 서버에서 `path`·`depth`으로 필터링한다.
+  1. `ref`가 없으면 `GET /repos/{owner}/{repo}`로 `default_branch`를 구한다.
+  2. 브랜치명이면 `GET /repos/{owner}/{repo}/git/ref/heads/{branch}`로 커밋 SHA를 구한다. (커밋 SHA 형태면 `GET /repos/{owner}/{repo}/git/commits/{sha}`로 바로 사용)
+  3. `GET /repos/{owner}/{repo}/git/commits/{commit_sha}`로 **tree SHA**를 구한다.
+  4. `GET /repos/{owner}/{repo}/git/trees/{tree_sha}?recursive=1` 한 번으로 평탄(flat) 트리를 받는다.
+  5. GitHub 응답 `tree[]`에서 `type=blob` → `file`, `type=tree` → `dir`(경로는 디렉터리에 `/` 접미사)으로 매핑한다.
+  6. `truncated=true`이면 레포가 너무 커서 GitHub이 트리를 잘랐을 때이며, 이 경우 **502**를 반환한다.
 
 #### 1. Request Syntax
 
 ```bash
-curl -X GET "https://example.com/api/github/repos/123/files?path=/&depth=2&ref=main" \
+curl -X GET "https://example.com/api/github/repos/123/files?path=/&depth=-1&ref=main" \
   -H "Authorization: Bearer "
 ```
 
@@ -210,9 +217,9 @@ curl -X GET "https://example.com/api/github/repos/123/files?path=/&depth=2&ref=m
 | 파라미터 | 타입 | 필수 | 설명 |
 |----------|------|------|------|
 | repo_id | integer \| string | Y | Path. GitHub 레포 numeric ID 또는 `"owner/name"` |
-| path | string | N | 조회 시작 디렉터리 경로, default="/" |
-| depth | integer | N | 탐색 최대 깊이, default=2 |
-| ref | string | N | 브랜치명 또는 커밋 SHA, default=레포 default_branch |
+| path | string | N | 조회 시작 디렉터리 경로, default="/" (해당 경로 하위만 필터) |
+| depth | integer | N | `path` 기준 상대 경로에서 `/` 개수 상한. `-1`이면 깊이 제한 없음 (default=-1) |
+| ref | string | N | 브랜치명 또는 커밋 SHA, 생략 시 레포 `default_branch` |
 
 #### 4. Response
 
@@ -228,7 +235,8 @@ curl -X GET "https://example.com/api/github/repos/123/files?path=/&depth=2&ref=m
     { "path": "src/app/", "type": "dir" },
     { "path": "src/app/main.py", "type": "file" },
     { "path": "README.md", "type": "file" }
-  ]
+  ],
+  "visited_nodes": 4
 }
 ```
 
@@ -238,7 +246,7 @@ curl -X GET "https://example.com/api/github/repos/123/files?path=/&depth=2&ref=m
 | 404 | NOT_FOUND | 레포 또는 경로 없음 |
 
 > 공통 에러(400/401/403/404/500/502)는 공통 규칙 참고.
-| 502 | GITHUB_UPSTREAM_ERROR | GitHub 트리 조회 실패 |
+| 502 | GITHUB_UPSTREAM_ERROR | GitHub 트리 조회 실패 또는 GitHub `truncated=true` (트리 과대) |
 
 ---
 
diff --git a/docs/openapi.json b/docs/openapi.json
index 35787ff..754bd61 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -358,7 +358,7 @@
             "required": false,
             "schema": {
               "type": "integer",
-              "default": 2,
+              "default": -1,
               "title": "Depth"
             }
           },
@@ -607,6 +607,70 @@
         }
       }
     },
+    "/api/user/selected-repo-assets": {
+      "get": {
+        "tags": [
+          "UserAssets"
+        ],
+        "summary": "Selected Repo Assets Get",
+        "operationId": "selected_repo_assets_get_api_user_selected_repo_assets_get",
+        "parameters": [
+          {
+            "name": "selected_repo_id",
+            "in": "query",
+            "required": false,
+            "schema": {
+              "anyOf": [
+                {
+                  "type": "integer"
+                },
+                {
+                  "type": "null"
+                }
+              ],
+              "title": "Selected Repo Id"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful Response",
+            "content": {
+              "application/json": {
+                "schema": {}
+              }
+            }
+          },
+          "422": {
+            "description": "Validation Error",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/HTTPValidationError"
+                }
+              }
+            }
+          }
+        }
+      },
+      "put": {
+        "tags": [
+          "UserAssets"
+        ],
+        "summary": "Selected Repo Assets Put",
+        "operationId": "selected_repo_assets_put_api_user_selected_repo_assets_put",
+        "responses": {
+          "200": {
+            "description": "Successful Response",
+            "content": {
+              "application/json": {
+                "schema": {}
+              }
+            }
+          }
+        }
+      }
+    },
     "/": {
       "get": {
         "summary": "Index",
diff --git a/src/api/github.py b/src/api/github.py
index e801e7d..f4bdc75 100644
--- a/src/api/github.py
+++ b/src/api/github.py
@@ -1,14 +1,14 @@
 from __future__ import annotations
 
-import os
 from datetime import datetime, timezone
-from typing import Any, Dict, List, Optional
+from typing import List
 
 from fastapi import APIRouter, Request, Response
 from fastapi.responses import JSONResponse, PlainTextResponse
 
 from src.db.sqlite.client import connect
 from src.service.git_hub import repos as github_repos
+from src.service.git_hub.repos import GitHubTreeTruncatedError
 from src.service.user.repos import (
     get_selected_repos_detailed,
     upsert_selected_repos,
@@ -147,9 +147,8 @@ async def github_repo_files(
     request: Request,
     repo_id: str,
     path: str = "/",
-    # depth=-1이면 “끝까지(단 traverse_cap까지)” 순회한다.
+    # depth=-1이면 경로 깊이 필터 없음.
     depth: int = -1,
-    traverse_cap: int = 500,
     ref: str | None = None,
 ) -> JSONResponse:
     try:
@@ -167,15 +166,19 @@ async def github_repo_files(
             repo=repo,
             path=path,
             depth=depth,
-            traverse_cap=traverse_cap,
             ref=ref,
         )
         # docs response에서 repo_id를 그대로 노출한다.
         result["repo_id"] = repo_id if repo_id else full_name
-        result["ref"] = ref
         return JSONResponse(result)
     except ValueError:
         return _error_response(400, "BAD_REQUEST", "Invalid repo_id")
+    except GitHubTreeTruncatedError as exc:
+        return _error_response(
+            502,
+            "GITHUB_UPSTREAM_ERROR",
+            f"GitHub tree truncated: {exc}",
+        )
     except Exception as exc:
         return _error_response(
             502,
diff --git a/src/app/main.py b/src/app/main.py
index bd028f2..4953cc2 100644
--- a/src/app/main.py
+++ b/src/app/main.py
@@ -56,828 +56,6 @@ async def _startup_init_db() -> None:
         finally:
             await conn.close()
 
-    dashboard_html = """
-
-
-  
-    
-    
-    Autofolio Dashboard (Dev)
-    
-  
-  
-    

Autofolio Dev Dashboard

-
- - -
-
- Status: - loading... -
-

/api/me

-

-
-    
-

GitHub API (임베딩 전)

- -
- - -
- -
- - -
-

-    

-
-    
-

내 레포 선택 (저장된 레포 만들기)

-
- - -
- -
-

저장된 레포(assets 대상) 선택

-
-
- - -
- -
- -
-

assets 선택 (폴더/파일 체크)

- - -
- -

Files Tree

-
- - - -
- - -
-

Files Tree (폴더 펼치기)

-
-
- -

Contents

-
- - -
-
-

저장된 파일(선택)

-
-
-

-
-    

Commits

-
- - - - - - - -
-

-
-    
-  
-
-""".strip()
-
     @app.get("/", response_class=HTMLResponse)
     async def index() -> HTMLResponse:
         return HTMLResponse(dashboard_html_view)
diff --git a/src/service/git_hub/repos.py b/src/service/git_hub/repos.py
index 5a75d64..06a86f1 100644
--- a/src/service/git_hub/repos.py
+++ b/src/service/git_hub/repos.py
@@ -1,12 +1,20 @@
 from __future__ import annotations
 
 import re
-from typing import Any, Dict, List, Optional, Tuple
+from typing import Any, Dict, Tuple
+from urllib.parse import quote
 
 import httpx
 
 GITHUB_API_BASE = "https://api.github.com"
 
+
+class GitHubTreeTruncatedError(Exception):
+    """GitHub GET /git/trees?recursive=1 응답에서 truncated=true 인 경우."""
+
+    def __init__(self, message: str = "GitHub tree response truncated (too large)") -> None:
+        super().__init__(message)
+
 _REPO_ID_DIGITS = re.compile(r"^\d+$")
 
 
@@ -105,19 +113,82 @@ async def list_user_repos(
     return {"repos": repos, "page": page, "per_page": per_page, "total_count": len(repos)}
 
 
+def _looks_like_git_commit_sha(ref: str) -> bool:
+    s = ref.strip().lower()
+    if len(s) < 7 or len(s) > 40:
+        return False
+    return all(c in "0123456789abcdef" for c in s)
+
+
+async def _get_default_branch(
+    client: httpx.AsyncClient,
+    headers: dict[str, str],
+    owner: str,
+    repo: str,
+) -> str:
+    url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}"
+    resp = await client.get(url, headers=headers, timeout=10)
+    resp.raise_for_status()
+    data = resp.json()
+    b = data.get("default_branch")
+    if not b:
+        raise ValueError("default_branch missing from repo")
+    return str(b)
+
+
+async def _get_commit_sha_from_branch(
+    client: httpx.AsyncClient,
+    headers: dict[str, str],
+    owner: str,
+    repo: str,
+    branch: str,
+) -> str:
+    enc = quote(branch, safe="")
+    url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/git/ref/heads/{enc}"
+    resp = await client.get(url, headers=headers, timeout=10)
+    resp.raise_for_status()
+    data = resp.json()
+    obj = data.get("object") or {}
+    sha = obj.get("sha")
+    if not sha:
+        raise ValueError("git ref heads: object.sha missing")
+    return str(sha)
+
+
+async def _get_tree_sha_from_commit(
+    client: httpx.AsyncClient,
+    headers: dict[str, str],
+    owner: str,
+    repo: str,
+    commit_sha: str,
+) -> str:
+    url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/git/commits/{commit_sha}"
+    resp = await client.get(url, headers=headers, timeout=10)
+    resp.raise_for_status()
+    data = resp.json()
+    tree = data.get("tree") or {}
+    tsha = tree.get("sha")
+    if not tsha:
+        raise ValueError("git commit: tree.sha missing")
+    return str(tsha)
+
+
 async def list_repo_files_tree(
     access_token: str,
     *,
     owner: str,
     repo: str,
     path: str = "",
-    # depth=-1이면 GitHub에 있는 트리를 끝까지(단, traverse_cap까지) 순회한다.
+    # depth=-1이면 경로 깊이 필터 없음.
     depth: int = -1,
-    traverse_cap: int = 500,
     ref: str | None = None,
 ) -> Dict[str, Any]:
     """
-    Contents API를 재귀로 돌려 tree 형태로 반환한다.
+    Git Trees API(recursive=1)로 레포 전체 트리를 한 번에 가져와 반환한다.
+
+    - GET /repos/{owner}/{repo}/git/ref/heads/{branch} 로 커밋 SHA 조회 (또는 ref가 커밋 SHA면 직접 사용)
+    - GET /repos/{owner}/{repo}/git/commits/{sha} 로 tree SHA 조회
+    - GET /repos/{owner}/{repo}/git/trees/{tree_sha}?recursive=1
     """
     headers = {
         "Authorization": f"Bearer {access_token}",
@@ -127,66 +198,101 @@ async def list_repo_files_tree(
     normalized = _normalize_path(path)
     root = "/" if not normalized else f"{normalized}/"
 
-    tree: list[dict[str, Any]] = []
-    visited_nodes = 0
-    capped = False
-
-    # remaining<=0이면 더 깊게 내려가지 않는다.
-    # depth=-1이면 remaining=None으로 무제한 순회(단, traverse_cap까지)로 처리한다.
-    remaining0: int | None = depth if depth >= 0 else None
-
-    async def _walk(
-        current_path: str,
-        current_depth_remaining: int | None,
-    ) -> None:
-        nonlocal visited_nodes, capped
-        if capped or visited_nodes >= traverse_cap:
-            return
-
-        url = (
-            f"{GITHUB_API_BASE}/repos/{owner}/{repo}/contents"
-            if not current_path
-            else f"{GITHUB_API_BASE}/repos/{owner}/{repo}/contents/{current_path}"
-        )
-        params: dict[str, str] = {}
-        if ref:
-            params["ref"] = ref
-
-        async with httpx.AsyncClient() as client:
-            resp = await client.get(url, headers=headers, params=params, timeout=10)
-            resp.raise_for_status()
-            items = resp.json()
-
-        items_list: list[dict[str, Any]] = items if isinstance(items, list) else [items]
-        for item in items_list:
-            if capped or visited_nodes >= traverse_cap:
-                capped = True
-                break
-
-            item_type = item.get("type")
-            item_path = item.get("path") or item.get("name")
-            if not item_type or not item_path:
-                continue
+    async with httpx.AsyncClient() as client:
+        if ref is None:
+            branch_name = await _get_default_branch(client, headers, owner, repo)
+            commit_sha = await _get_commit_sha_from_branch(
+                client, headers, owner, repo, branch_name
+            )
+            resolved_ref = branch_name
+        elif _looks_like_git_commit_sha(ref):
+            commit_sha = ref.strip().lower()
+            resolved_ref = commit_sha
+        else:
+            commit_sha = await _get_commit_sha_from_branch(
+                client, headers, owner, repo, ref
+            )
+            resolved_ref = ref
 
-            tree.append({"path": str(item_path), "type": str(item_type)})
-            visited_nodes += 1
+        tree_sha = await _get_tree_sha_from_commit(
+            client, headers, owner, repo, commit_sha
+        )
 
-            if item_type == "dir" and (current_depth_remaining is None or current_depth_remaining > 0):
-                next_remaining = (
-                    None if current_depth_remaining is None else current_depth_remaining - 1
-                )
-                await _walk(str(item_path), next_remaining)
+        tree_url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/git/trees/{tree_sha}"
+        resp = await client.get(
+            tree_url,
+            headers=headers,
+            params={"recursive": "1"},
+            timeout=60,
+        )
+        resp.raise_for_status()
+        data = resp.json()
 
-    await _walk(normalized, remaining0)
+    if data.get("truncated") is True:
+        raise GitHubTreeTruncatedError()
+
+    raw_tree: list[dict[str, Any]] = data.get("tree") or []
+    entries: list[dict[str, str]] = []
+
+    for item in raw_tree:
+        t = item.get("type")
+        p = item.get("path")
+        if not p or not isinstance(p, str):
+            continue
+        if t == "blob":
+            entries.append({"path": p, "type": "file"})
+        elif t == "tree":
+            dir_path = p if p.endswith("/") else f"{p}/"
+            entries.append({"path": dir_path, "type": "dir"})
+        else:
+            continue
+
+    def _path_for_match(e: dict[str, str]) -> str:
+        return e["path"].rstrip("/") if e["type"] == "dir" else e["path"]
+
+    prefix_norm = normalized  # already no leading/trailing slashes
+
+    def _under_prefix(entry_path: str) -> bool:
+        if not prefix_norm:
+            return True
+        if entry_path == prefix_norm:
+            return True
+        return entry_path.startswith(prefix_norm + "/")
+
+    def _relative_to_prefix(entry_path: str) -> str | None:
+        if not prefix_norm:
+            return entry_path
+        if entry_path == prefix_norm:
+            return ""
+        if entry_path.startswith(prefix_norm + "/"):
+            return entry_path[len(prefix_norm) + 1 :]
+        return None
+
+    filtered_entries: list[dict[str, str]] = []
+    for e in entries:
+        ep = _path_for_match(e)
+        if not _under_prefix(ep):
+            continue
+        if depth < 0:
+            filtered_entries.append(e)
+            continue
+        rel = _relative_to_prefix(ep)
+        if rel is None:
+            continue
+        if rel.count("/") > depth:
+            continue
+        filtered_entries.append(e)
+
+    entries = filtered_entries
+
+    entries.sort(key=lambda x: x["path"])
 
     return {
         "repo_id": None,
-        "ref": ref,
+        "ref": resolved_ref,
         "root": root,
-        "tree": tree,
-        "traverse_cap": traverse_cap,
-        "visited_nodes": visited_nodes,
-        "capped": capped,
+        "tree": entries,
+        "visited_nodes": len(entries),
     }
 
 
diff --git a/src/web/dashboard.py b/src/web/dashboard.py
index fe65d09..3dcdcea 100644
--- a/src/web/dashboard.py
+++ b/src/web/dashboard.py
@@ -1,31 +1,825 @@
 from __future__ import annotations
 
-import re
-from functools import lru_cache
-from pathlib import Path
+"""
+대시보드 HTML (개발용). `src/app/main.py` 라우트에서 참조한다.
+"""
 
+dashboard_html = r"""
+
+
+  
+    
+    
+    Autofolio Dashboard (Dev)
+    
+  
+  
+    

Autofolio Dev Dashboard

+
+ + +
+
+ Status: + loading... +
+

/api/me

+

 
-@lru_cache(maxsize=1)
-def get_dashboard_html() -> str:
-    """
-    `src/app/main.py` 내부에 존재하던 `dashboard_html` 블록을 추출해 제공한다.
+    
+

GitHub API (임베딩 전)

- - 목적: 라우트가 프론트 소스를 `src/web`에서 참조하도록 디렉토리 구조를 정리하기 위함. - - 향후 `main.py`에서 실제 문자열을 제거/이관하면 이 추출 로직은 생략할 수 있다. - """ - main_py = Path(__file__).resolve().parents[1] / "app" / "main.py" - text = main_py.read_text(encoding="utf-8") +
+ + +
- # main.py 내에서 dashboard_html = """ ... """ .strip() 형태를 캡처 - m = re.search( - r'dashboard_html\s*=\s*"""(.*?)"""\s*\.strip\(\)', - text, - flags=re.S, - ) - if not m: - raise RuntimeError("Could not extract dashboard_html from src/app/main.py") - return m.group(1) +
+ + +
+

+    

 
+    
+

내 레포 선택 (저장된 레포 만들기)

+
+ + +
-dashboard_html = get_dashboard_html() +
+

저장된 레포(assets 대상) 선택

+
+
+ + +
+ +
+
+

assets 선택 (폴더/파일 체크)

+ + +
+ +

Files Tree

+
+ + + +
+ + +
+

Files Tree (폴더 펼치기)

+
+
+ +

Contents

+
+ + +
+
+

저장된 파일(선택)

+
+
+

+
+    

Commits

+
+ + + + + + + +
+

+
+    
+  
+
+""".strip()
diff --git a/tests/api/test_github_api.py b/tests/api/test_github_api.py
index 78a302e..ac1716c 100644
--- a/tests/api/test_github_api.py
+++ b/tests/api/test_github_api.py
@@ -151,9 +151,7 @@ def test_repo_files_success(
                 "root": "/",
                 "ref": None,
                 "tree": [{"path": "src/", "type": "dir"}],
-                "traverse_cap": 500,
                 "visited_nodes": 1,
-                "capped": False,
             }
         ),
     ) as mock_list_repo_files_tree:
@@ -166,7 +164,6 @@ def test_repo_files_success(
     assert response.json()["repo_id"] == "owner/repo-a"
     assert response.json()["tree"][0]["path"] == "src/"
     assert mock_list_repo_files_tree.await_args.kwargs["depth"] == -1
-    assert mock_list_repo_files_tree.await_args.kwargs["traverse_cap"] == 500
     assert mock_list_repo_files_tree.await_args.kwargs["ref"] is None
 
 
diff --git a/week-issues/week4-github.md b/week-issues/week4-github.md
index 0a7b0da..f1c1298 100644
--- a/week-issues/week4-github.md
+++ b/week-issues/week4-github.md
@@ -1,54 +1,96 @@
 ## Week 4 이슈 정리: GitHub Tree 선택 + 저장된 assets 기반 Contents
 
+> **PR 이력:** 기존 PR을 닫은 뒤, 아래 “최종 반영(추가 변경)”까지 포함해 **새 PR**로 다시 올릴 예정.
+
 ### 실험 가이드라인 (먼저 이대로 해보세요)
-1. `poetry run uvicorn ...` 서버를 켠 뒤 브라우저에서 `http://localhost:8000/dashboard`로 접속합니다.
-2. `GitHub 로그인` 버튼으로 OAuth 로그인 후 `/api/me`가 `200 OK`로 떠야 합니다.
-3. `저장된 레포(assets 대상)`에서 버튼(저장된 레포)을 클릭합니다.
-   - 이 단계에서 해당 `selected_repo_id`의 `selected_repo_assets`가 복원되고,
-   - 화면의 `Files Tree`(폴더/파일 트리) 체크 상태와 `저장된 파일(선택)` 리스트가 자동으로 동기화됩니다.
-4. `Files Tree` 섹션에서 `GET /api/github/repos/{repo_id}/files` 버튼을 클릭해 트리를 로드합니다.
-   - 이제 프론트에서 `depth/ref`를 입력하지 않고, 서버가 기본적으로 “끝까지” 순회하되 `traverse_cap=500`까지만 허용합니다.
-5. 트리에서 폴더/파일 체크를 합니다.
-   - 폴더 체크 시 하위(자식)까지 자동으로 포함되도록 동작합니다.
-   - 폴더 펼치기(`▸/▾`)로 깊이 확인이 가능합니다.
-6. `선택 assets 저장`을 누르면 현재 체크된 assets가 “완전 replace”로 저장됩니다.
-7. `저장된 파일(선택)` 리스트에서 파일을 다시 클릭하면, 해당 경로의 `contents`가 로드되어 `contents` 영역에 표시됩니다.
-   - 이제 `ref=main` 같은 입력이 없고, GitHub API는 repo의 `default_branch`를 기준으로 동작합니다.
-
-### 변경 요약 (이번 주에 실제로 한 것)
-1. 백엔드: GitHub 파일 트리 “끝까지” 순회(단, 안전 cap 적용)
-- `src/service/git_hub/repos.py`
-  - `list_repo_files_tree`에서 `depth=-1`을 “끝까지”로 해석
-  - `traverse_cap=500`을 기본 안전장치로 두고, cap을 넘으면 더 내려가지 않고 중단
-  - 응답에 `capped`, `visited_nodes`, `traverse_cap` 등을 포함해 상태 확인 가능
-
-2. 백엔드: `/api/github/repos/{repo_id}/files` 요청 스펙 정리
-- `src/api/github.py`
-  - `GET /api/github/repos/{repo_id:path}/files`
-    - 기본 `depth=-1`, 기본 `traverse_cap=500`
-    - 프론트가 `ref`/`depth`를 직접 넣지 않아도 동작하도록 정리
-
-3. 프론트: “나열” 대신 폴더/파일 트리 펼쳐서 선택
-- `src/app/main.py` (dashboard_html)
-  - `Files Tree` UI에서 기존 `depth`, `ref` 인풋을 제거
-  - `GET /files`는 `path`만 보내도록 변경
-  - 트리 로딩 실패 시 전체가 실패하는 strict 동작은 유지(502로 표시)
-
-4. 프론트: contents 선택 UI를 “저장된 assets 기반 리스트”로 전환
-- `src/app/main.py` (dashboard_html)
-  - `저장된 파일(선택)` 리스트를 추가
-  - 리스트는 현재 `selectedAssetKeys` 중 `code:*`(파일)만 뽑아서 렌더링
-  - 리스트에서 파일을 클릭하면 `/api/github/repos/{repo_id}/contents`를 호출해 내용 표시
-  - 기존 `contentsRef`/`btnContents`/`contentsPath` 입력 UI는 제거
-
-### 주요 동작 포인트 / 왜 404/502가 섞여 보일 수 있나
-- `files` 트리는 `contents` API를 재귀로 도는 방식이라, 특정 repo/경로 조합에서 GitHub가 `404 Not Found`를 주면 현재 구현에서는 해당 트리 로딩이 통째로 502가 됩니다.
-- 특히 예전에는 `ref=main`을 강제로 쓰는 요청이 있어서, repo의 default branch가 main이 아닌 경우 404가 쉽게 발생했습니다.
-- 이번 변경에서는 files/contents 모두 `ref` 입력을 없애고 repo의 `default_branch` 기반으로 호출되도록 정리했기 때문에, “어떤 단계에서만 되거나 안 되는” 현상이 줄어드는 방향입니다.
+1. `poetry run uvicorn src.app.main:app --reload --host 127.0.0.1 --port 8000` 등으로 서버 실행 후 `http://127.0.0.1:8000/dashboard` 접속.
+2. `GitHub 로그인` → `/api/me`가 `200 OK`인지 확인.
+3. `저장된 레포(assets 대상)`에서 레포 버튼 클릭 → `selected_repo_assets` 복원, 트리 체크 상태·`저장된 파일(선택)` 리스트 동기화.
+4. **Files Tree**에서 `GET .../files` 버튼으로 트리 로드.
+   - 프론트는 `depth`/`ref` 입력 없이 `path`만 전달 가능(기본 `depth=-1`).
+   - 백엔드는 **Git Trees API(`recursive=1`)** 로 트리를 가져와 **개수 상한 없이** 필터 결과 전체를 반환(`traverse_cap`/`capped` 없음).
+5. 폴더/파일 체크 → 폴더 체크 시 하위 자동 포함, `▸/▾`로 펼치기.
+6. `선택 assets 저장` → DB에 **완전 replace**.
+7. `저장된 파일(선택)`에서 파일 클릭 → `contents` 표시(`ref` 수동 입력 없음, 기본 브랜치 기준).
+
+### 변경 요약 (누적)
+
+#### 1) 파일 트리 API: Contents 재귀(A) → Git Trees 단일 호출(B)
+- **파일:** `src/service/git_hub/repos.py` — `list_repo_files_tree()`
+- **흐름(요약):**
+  - `ref` 없으면 `GET /repos/{owner}/{repo}` 로 `default_branch` 조회
+  - 브랜치면 `GET .../git/ref/heads/{branch}` 로 커밋 SHA (브랜치명은 URL 인코딩)
+  - 커밋 SHA면 `GET .../git/commits/{sha}` 로 tree SHA
+  - `GET .../git/trees/{tree_sha}?recursive=1` **한 번**으로 flat 트리 수신
+  - `blob` → `type: file`, `tree` → `type: dir`(디렉터리 경로는 `/` 접미사 유지)
+  - `path` / `depth` 로 서버 측 필터만 적용
+- **GitHub `truncated=true`:** 트리가 너무 커 잘리면 `GitHubTreeTruncatedError` → API **502** (`GITHUB_UPSTREAM_ERROR`).
+
+#### 2) 개수 상한(traverse_cap) 제거
+- 예전: 정렬 후 앞 N개만 반환 + `capped` / `traverse_cap` 필드.
+- **현재:** 해당 로직·쿼리 파라미터·응답 필드 **제거**. 반환 `tree`는 필터 통과분 전부.
+- 응답에는 **`visited_nodes`**(실제 반환 노드 수 = `len(tree)`)만 유지.
+
+#### 3) API 라우터
+- **파일:** `src/api/github.py` — `GET /api/github/repos/{repo_id:path}/files`
+  - `depth` 기본 `-1`, `ref` 선택
+  - `truncated` 시 전용 예외로 502 처리
+
+#### 4) 대시보드 HTML 위치
+- **파일:** `src/web/dashboard.py` — `dashboard_html` (raw string)
+- **파일:** `src/app/main.py` — 라우트만, `dashboard_html` 중복 문자열 제거
+
+#### 5) 프론트(대시보드)
+- Files Tree: `depth`/`ref` 입력 제거, `/files?path=...` 위주
+- cap 관련 상태 문구 제거
+- Contents: `저장된 파일(선택)` 리스트로 재선택, 수동 `ref` 제거
+
+#### 6) User assets (선택 폴더/파일 저장)
+- **API:** `GET/PUT /api/user/selected-repo-assets`
+- **DB:** `selected_repo_assets` 테이블, PUT 시 replace
+
+#### 7) 문서·OpenAPI
+- **파일:** `docs/API_GitHub_Spec.md` — `/files` Git Trees·`truncated`·파라미터 설명 반영
+- **스크립트:** `scripts/export_openapi.py` → `docs/openapi.json`
+
+### 주의: 404/502가 나올 수 있는 경우
+- **GitHub `truncated=true`:** 레포가 매우 클 때 → **502** (우리 쪽에서 잘라서 줄이지는 않음).
+- **권한/존재하지 않는 ref/경로 등:** 기존과 같이 GitHub upstream 오류는 **502** + 메시지로 전달되는 경우가 많음.
+- **`ref=main` 고정 제거:** default branch 기준으로 맞춰 `main` 아닌 브랜치에서의 404는 완화되는 편.
 
 ### 테스트
-- `tests/api/test_github_api.py`
-  - `/files` 호출이 `depth/ref` 대신 `depth=-1`와 `traverse_cap=500` 전달을 기대하도록 갱신
-- `poetry run pytest -q`
-  - 전체 통과 확인: **75 passed**
+- `poetry run pytest -q` → **75 passed** (작업 시점 기준)
+
+---
+
+## 새 PR에 넣을 내용 (복사용)
+
+### PR 제목 (예시)
+```
+Week 4: GitHub OAuth + Git Trees 파일 트리 + selected assets + 대시보드
+```
+
+### PR 본문 (예시)
+```markdown
+## Summary
+- GitHub OAuth·세션 기반 `/api/me` 및 GitHub 연동 API.
+- **파일 트리**는 Contents 재귀 호출 대신 **Git Trees API (`recursive=1`)** 로 조회.
+- **traverse_cap / capped 제거** — 필터(`path`, `depth`) 결과를 개수 제한 없이 반환. (GitHub이 `truncated=true`일 때만 502)
+- 대시보드: 폴더 트리 선택·저장된 assets 기반 contents 재선택 UX. HTML은 `src/web/dashboard.py`로 분리.
+- `selected_repo_assets` API + SQLite 스키마.
+
+## Key files
+- `src/service/git_hub/repos.py` — `list_repo_files_tree` (Git Trees)
+- `src/api/github.py` — `/files`, `GitHubTreeTruncatedError` → 502
+- `src/api/user_assets.py`, `src/service/user/selected_assets.py`
+- `src/web/dashboard.py`, `src/app/main.py`
+- `docs/API_GitHub_Spec.md`, `docs/openapi.json`
+
+## How to test
+1. `poetry run uvicorn src.app.main:app --reload --host 127.0.0.1 --port 8000`
+2. `/dashboard` → 로그인 → 저장된 레포 선택 → Files Tree 로드 → assets 저장 → 저장된 파일 리스트에서 contents 확인.
+3. `poetry run pytest -q`
 
+## Notes
+- 이전 PR은 닫았고, **Git Trees 전환 + traverse_cap 제거 등** 이후 수정분까지 이 PR에 포함.
+```

From 6144c2c58d0e56c3bdde145b795736a7ccf5e5b7 Mon Sep 17 00:00:00 2001
From: Ara5429 
Date: Sat, 21 Mar 2026 16:07:16 +0900
Subject: [PATCH 3/3] docs: add week4 PR draft with API list

Made-with: Cursor
---
 week-issues/week4-pr-draft.md | 77 +++++++++++++++++++++++++++++++++++
 1 file changed, 77 insertions(+)
 create mode 100644 week-issues/week4-pr-draft.md

diff --git a/week-issues/week4-pr-draft.md b/week-issues/week4-pr-draft.md
new file mode 100644
index 0000000..1daad3f
--- /dev/null
+++ b/week-issues/week4-pr-draft.md
@@ -0,0 +1,77 @@
+# Week 4 PR 초안 (복사용)
+
+## PR 열기 (브랜치 이미 푸시됨)
+
+**Compare & PR 생성 URL:**  
+https://github.com/kocory1/AutoPolio/compare/main...week4/auth-tdd-wip?expand=1
+
+Base: `main` ← Head: `week4/auth-tdd-wip`
+
+---
+
+## 제목
+
+```
+Week 4: GitHub OAuth + Git Trees 파일 트리 + selected assets + 대시보드
+```
+
+---
+
+## 본문
+
+```markdown
+## Summary
+- GitHub OAuth·세션 기반 인증 및 `/api/me`.
+- **파일 트리** `GET /api/github/repos/{repo_id}/files`: Git **Trees API** (`recursive=1`) 사용, `traverse_cap`/`capped` 없이 필터 결과 전체 반환. GitHub `truncated=true` 시 502.
+- 대시보드: 트리 선택·`selected_repo_assets` 저장/복원·저장된 파일 기준 contents 재선택. HTML은 `src/web/dashboard.py`.
+- OpenAPI·`docs/API_GitHub_Spec.md` 동기화.
+
+## 구현된 API 목록
+
+### Auth (`src/api/auth.py`)
+| Method | Path |
+|--------|------|
+| GET | `/api/auth/github/login` |
+| GET | `/api/auth/github/callback` |
+| GET | `/api/auth/logout` |
+| GET | `/api/me` |
+
+### GitHub + 선택 레포 (`src/api/github.py`)
+| Method | Path |
+|--------|------|
+| GET | `/api/github/repos` |
+| GET | `/api/user/selected-repos` |
+| PUT | `/api/user/selected-repos` |
+| GET | `/api/github/repos/{repo_id}/files` |
+| GET | `/api/github/repos/{repo_id}/contents` |
+| GET | `/api/github/repos/{repo_id}/commits` |
+
+### User — 선택 assets (`src/api/user_assets.py`)
+| Method | Path |
+|--------|------|
+| GET | `/api/user/selected-repo-assets?selected_repo_id=...` |
+| PUT | `/api/user/selected-repo-assets` |
+
+### Portfolio (`src/api/portfolio.py`)
+| Method | Path |
+|--------|------|
+| POST | `/api/portfolio/generate` (header `X-User-Id`) |
+| GET | `/api/portfolio` (header `X-User-Id`, optional `portfolio_id`) |
+
+### 기타
+| Method | Path |
+|--------|------|
+| GET | `/` |
+| GET | `/dashboard` |
+
+## How to test
+```bash
+poetry run pytest -q
+poetry run uvicorn src.app.main:app --reload --host 127.0.0.1 --port 8000
+# 브라우저: http://127.0.0.1:8000/dashboard
+```
+
+## Notes
+- 이전 PR을 닫은 뒤 **Git Trees 전환 + traverse_cap 제거** 등 후속 수정까지 이 브랜치에 포함.
+- 상세 이슈: `week-issues/week4-github.md`
+```