학습 데이터셋 — CLI·Python 으로 올리고 내려받기 (v1.2+)
외부 학습 서버·개인 노트북에서 GenD 학습 데이터셋에 데이터를 올리고(push) 내려받는(pull) 방법입니다. UI 절차는 학습 데이터셋 을, 엔드포인트 명세는 API 레퍼런스 를 보세요.
객체 스토리지(S3)는 외부에 열려 있지 않습니다. 업로드·다운로드는 모두 API 를 경유하므로 S3 자격증명은 필요 없고, JWT 인가와 감사 로그를 그대로 통과합니다.
1. 자격증명 준비
용도에 따라 세 가지입니다. 사람이 직접 다룰 때는 gend auth login, 자동화(스크립트·학습 잡)에는
키 방식을 쓰되 — v1.2 의 키 방식은 워크스페이스 격리가 적용되지 않으므로(아래 경고) 워크스페이스
간 데이터 분리가 요구되는 환경에서는 자동화에도 사람 계정을 쓰세요.
| 방식 | 언제 | 발급 | 갱신 |
|---|---|---|---|
gend auth login | 사람이 터미널에서 직접 | 브라우저 SSO(PKCE) / --device | 자동 |
| 개인 트래킹 키 | 외부 학습 서버·스크립트 | 본인이 UI 에서 (관리자 불필요) | 토큰 자동 재발급 |
| M2M 서비스 클라이언트 | 팀 공용·CI | 관리자가 /admin/service-clients 에서 | 토큰 자동 재발급 |
:::warning 현재 키 방식의 스코프 한계 (v1.2)
키(개인 트래킹 키·M2M) 로 만든 토큰은 워크스페이스 스코프가 적용되지 않습니다.
GEND_ACTIVE_WORKSPACE_SLUG 를 지정하면 오히려 Permission denied 가 나고, 지정하지 않으면
본인이 속하지 않은 워크스페이스의 데이터셋까지 조회됩니다. 또한 키 방식은 데이터셋
생성만 차단되고 기존 데이터셋에 대한 적재·다운로드는 가능합니다.
민감한 데이터가 있는 환경에서는 사람 계정(gend auth login)을 쓰고, 키는 신뢰 경계 안에서만
사용하세요. 개선은 #3040 에서 추적합니다.
:::
개인 트래킹 키 발급 (UI)
- 사이드바 ML 허브 › 실험 으로 이동합니다.
- 우측 상단 외부 학습 서버 연결 을 누릅니다.
- 용도 메모를 적고 발급 을 누릅니다.

시크릿은 이 화면에서 한 번만 표시됩니다. 복사해 두지 않으면 다시 볼 수 없고 키를 회전해야 합니다. 발급된 값은 아래 환경변수로 씁니다.
export GEND_API_URL='https://gend.genon.ai'
export GEND_CLIENT_ID='gend-user-...' # 발급 화면의 Client ID
export GEND_CLIENT_SECRET='...' # 발급 화면의 Client Secret
모달리티별 지원 범위
gend dataset upload 는 데이터셋의 modality 를 조회해 알맞은 엔드포인트로 보냅니다.
| modality | CLI 업로드 | 파일 | 비고 |
|---|---|---|---|
text | ✅ | .jsonl .ndjson .parquet | 100MB 초과 parquet 은 청크 자동 전환 |
image | ✅ | 이미지 파일 · zip | zip 은 HF imagefolder 구조 추론 |
video | ✅ (v1.2.1+) | .mp4 .webm | zip 미지원 — 개별 파일로 올리세요 |
image_text | ❌ | — | 서버에 적재 경로가 아직 없습니다 (#3258) |
:::warning 비디오는 zip 으로 올리지 마세요 서버의 zip 추출 경로는 이미지 확장자만 훑습니다. 비디오 zip 을 올리면 오류 없이 0건 수용으로 끝나 "올렸는데 아무것도 없는" 상태가 됩니다. CLI 가 앞단에서 막지만, API 를 직접 호출할 때는 주의하세요. :::
2. CLI 로 올리고 내려받기
pip install "gend-cli @ git+https://github.com/genonai/DataX.git@main#subdirectory=apps/cli"
# 데이터셋 목록 — 연결 확인
gend dataset list
# 업로드 (JSONL/Parquet = text · 이미지/비디오 = 파일)
gend dataset upload <DATASET_ID> ./train.jsonl
# 다운로드 — 버전 고정 export 를 만들거나 재사용해 JSONL 로 저장
gend dataset download <DATASET_ID> --version 1 -o ./out
실측 예 (500행 왕복):
$ gend dataset upload 8c406097-... ./cli-e2e.jsonl
cli-e2e.jsonl: +500 rows (subformat=plain, dup=0, parse_err=0)
적재 완료: rows +500 · 중복 스킵 0 · 파싱 오류 0
$ gend dataset download 8c406097-... --version 1 -o ./out
저장 완료: ./out/cli-e2e-text-v1.jsonl (166,390B, parts=1)
주요 옵션:
| 명령 | 옵션 | 의미 |
|---|---|---|
upload | --dry-run | 쓰기 없이 서브포맷·파싱 오류만 미리보기 (JSONL) |
upload | --mask-pii | 텍스트 필드 PII 마스킹 적재 (JSONL 전용, 5,000행 이하) |
upload | --batch-mb N | 배치 크기(기본 45MB). 413 이 나면 자동으로 절반씩 재시도 |
upload | --split / --label | 기본 split·label (v1.2+). zip 폴더 구조·metadata.jsonl 이 우선하고, text 는 레코드의 split 이 우선 |
download | --fresh | 완료된 export 를 재사용하지 않고 새로 생성 |
download | --with-blobs | 이미지·비디오 원본까지 함께 내려받기 |
:::tip 재실행은 안전합니다
같은 파일을 다시 올리면 content-hash 중복 제거로 +0 rows · 중복 스킵 N 이 되고 데이터가
불어나지 않습니다. 중단된 업로드를 그대로 다시 돌려도 됩니다.
:::
100MB 를 넘는 Parquet 은 CLI 가 자동으로 청크 업로드로 전환합니다(서버가 조립) — 별도 옵션이 필요 없습니다.
3. Python 코드로 직접
의존성 없이 표준 라이브러리만으로 동작합니다. 키 → 토큰 → API 순서입니다.
import json, os, urllib.parse, urllib.request, uuid, io
BASE = os.environ.get("GEND_API_URL", "https://gend.genon.ai")
API = f"{BASE}/api/v1"
def get_token() -> str:
data = urllib.parse.urlencode({
"grant_type": "client_credentials",
"client_id": os.environ["GEND_CLIENT_ID"],
"client_secret": os.environ["GEND_CLIENT_SECRET"],
}).encode()
url = f"{BASE}/auth/realms/gend/protocol/openid-connect/token"
with urllib.request.urlopen(urllib.request.Request(url, data=data), timeout=20) as r:
return json.load(r)["access_token"]
def multipart(field, filename, payload):
b = uuid.uuid4().hex
buf = io.BytesIO()
buf.write(f"--{b}\r\n".encode())
buf.write(f'Content-Disposition: form-data; name="{field}"; filename="{filename}"\r\n'
"Content-Type: application/octet-stream\r\n\r\n".encode())
buf.write(payload)
buf.write(f"\r\n--{b}--\r\n".encode())
return buf.getvalue(), f"multipart/form-data; boundary={b}"
업로드
token, ds = get_token(), os.environ["GEND_DATASET_ID"]
rows = b"".join(json.dumps({"text": f"row {i}"}).encode() + b"\n" for i in range(300))
payload, ctype = multipart("file", "train.jsonl", rows)
req = urllib.request.Request(f"{API}/datasets/{ds}/jsonl", data=payload)
req.add_header("Authorization", f"Bearer {token}")
req.add_header("Content-Type", ctype)
with urllib.request.urlopen(req, timeout=120) as r:
up = json.load(r)
print(up["rows_added"], up["resulting_snapshot_id"]) # 300 3039588534430915799
버전 고정 → export → 다운로드
snapshot = up["resulting_snapshot_id"]
# ★ 새 행이 하나도 없으면(전부 중복) snapshot 은 null 이다 — 버전 고정 전에 확인할 것
# (assert 는 python -O 에서 제거되므로 명시적 예외로)
if snapshot is None:
raise RuntimeError("신규 행이 0 (전부 중복) — 고정할 스냅샷이 없습니다")
def call(method, path, body=None, headers=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{API}{path}", method=method, data=data)
req.add_header("Authorization", f"Bearer {token}")
if body is not None:
req.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
req.add_header(k, v)
with urllib.request.urlopen(req, timeout=120) as r:
# HTTP/2 는 헤더명이 소문자로 온다
return r.status, r.read(), {k.lower(): v for k, v in r.headers.items()}
_, b, _ = call("POST", f"/datasets/{ds}/versions", {"snapshot_id": str(snapshot)})
version = json.loads(b)["version"]
_, b, _ = call("POST", f"/datasets/{ds}/exports", {"version": version})
export_id = json.loads(b)["id"]
import time
deadline = time.monotonic() + 600 # 무기한 대기 방지
while True: # export 는 202 + 폴링이다
_, b, _ = call("GET", f"/datasets/{ds}/exports/{export_id}")
export = json.loads(b)
if export["status"] == "completed":
break
if export["status"] == "failed": # 실패를 성공 경로로 흘리지 말 것
raise RuntimeError(f"export 실패: {export.get('error') or export}")
if time.monotonic() > deadline:
raise TimeoutError(f"export 가 10분 내 끝나지 않음 (status={export['status']})")
time.sleep(2)
# ★ part 는 1부터 — 총 개수는 X-Export-Part-Count 헤더
# 파트를 메모리에 모으지 말고 순차로 파일에 쓴다(수 GB export 대비).
import os
_, first, hdr = call("GET", f"/datasets/{ds}/exports/{export_id}/download?part=1")
# 헤더가 없거나 형식이 깨졌으면 중단한다 — 기본값 1 로 폴백하면 다중 파트 export 의
# 첫 파트만 저장하고 "성공" 으로 끝나 조용히 데이터가 잘린다.
raw_total = hdr.get("x-export-part-count")
if raw_total is None or not raw_total.isdigit():
raise RuntimeError(f"X-Export-Part-Count 헤더가 없거나 유효하지 않음: {raw_total!r}")
total = int(raw_total)
tmp = "dataset.jsonl.part"
with open(tmp, "wb") as f:
f.write(first)
for p in range(2, total + 1):
_, chunk, _ = call("GET", f"/datasets/{ds}/exports/{export_id}/download?part={p}")
f.write(chunk)
os.replace(tmp, "dataset.jsonl") # 전 파트 성공 후에만 최종 파일로 교체
실측 결과(위 코드 그대로, prod):
{
"upload": "rows_added=300 dup=0 snapshot=3039588534430915799",
"version": "v5 rows=2000",
"export": "completed rows=2000",
"download": "parts=1 bytes=678540 lines=2000 range=bytes 0-15/678540"
}
4. 막혔을 때
| 증상 | 원인 | 대처 |
|---|---|---|
Permission denied (목록·조회) | 키에 GEND_ACTIVE_WORKSPACE_SLUG 를 지정함 | v1.2 에서는 키 방식에 이 변수를 쓰지 마세요(#3040) |
Permission denied (데이터셋 생성) | 키 방식은 생성이 차단됨 | UI 나 gend auth login 세션으로 데이터셋을 먼저 만들고, 적재만 키로 |
| 생성이 403 (사람 계정인데도) | 워크스페이스 스키마(iceberg.ws_<슬러그>)에 write 바인딩이 없음 | 관리자에게 바인딩 발급 요청 (워크스페이스 프로비저닝 항목) |
업로드가 +0 rows · 중복 N | content-hash 중복 제거 — 이미 같은 내용이 있음 | 정상입니다. 재실행 안전성의 결과입니다 |
| 버전 고정이 422 | snapshot_id 가 null(전부 중복) 이거나 정수 아님 | 새 행이 있는지 먼저 확인, 값은 10진 문자열로 전달 |
| 다운로드가 422 | part=0 을 요청함 | part 는 1부터 입니다 |
| 다운로드가 409 | export 가 아직 running | status 가 completed 가 될 때까지 폴링 |
| 업로드가 413 | 파일/요청 크기 상한 초과 | CLI 는 자동 분할·재시도. 직접 호출이면 파일당 100MB·요청 200MB 이하로 |
관련 문서
- 학습 데이터셋 (UI)
- 데이터셋 API 레퍼런스
- CLI 빠른 시작 —
gend auth login·워크스페이스 전환 - 실험 추적 — 같은 키로 MLflow 로깅