본문으로 건너뛰기

학습 데이터셋 — 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)

  1. 사이드바 ML 허브 › 실험 으로 이동합니다.
  2. 우측 상단 외부 학습 서버 연결 을 누릅니다.
  3. 용도 메모를 적고 발급 을 누릅니다.

트래킹 키 발급 모달 — Client ID 와 Secret 이 1회만 표시된다

시크릿은 이 화면에서 한 번만 표시됩니다. 복사해 두지 않으면 다시 볼 수 없고 키를 회전해야 합니다. 발급된 값은 아래 환경변수로 씁니다.

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 를 조회해 알맞은 엔드포인트로 보냅니다.

modalityCLI 업로드파일비고
text.jsonl .ndjson .parquet100MB 초과 parquet 은 청크 자동 전환
image이미지 파일 · zipzip 은 HF imagefolder 구조 추론
video✅ (v1.2.1+).mp4 .webmzip 미지원 — 개별 파일로 올리세요
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 · 중복 Ncontent-hash 중복 제거 — 이미 같은 내용이 있음정상입니다. 재실행 안전성의 결과입니다
버전 고정이 422snapshot_idnull(전부 중복) 이거나 정수 아님새 행이 있는지 먼저 확인, 값은 10진 문자열로 전달
다운로드가 422part=0 을 요청함part 는 1부터 입니다
다운로드가 409export 가 아직 runningstatuscompleted 가 될 때까지 폴링
업로드가 413파일/요청 크기 상한 초과CLI 는 자동 분할·재시도. 직접 호출이면 파일당 100MB·요청 200MB 이하로

관련 문서